Files
Dune/tests/nexus-choam-betrayal-engine.test.ts
T

148 lines
7.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard } from '../game/nexus-cards';
import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options';
import { nexusChoamBetrayalSignature } from '../game/nexus-choam-betrayal';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
function position(advanced = false, phase = 2) {
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'choam', advanced, phase });
const owner = 'p', target = 'q', observer = 'r';
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
const victim = g.players.find(player => player.id === target)!;
victim.hand.push(...g.deck.splice(0, 3));
nexusTraitorInventory(g);
const action = nexusChoamBetrayalAction(viewGame(g, owner));
assert.ok(action);
return { g, owner, target, observer, action };
}
function reload(g: Game): Game {
return JSON.parse(JSON.stringify(g)) as Game;
}
function reject(g: Game, owner: string, action: { type: string; [key: string]: unknown }) {
const before = reload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
void test('CHOAM Betrayal spends one Nexus card and randomly discards one actual held card without spice in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const { g, owner, target, observer, action } = position(advanced);
const before = reload(g);
const beforeLog = g.log.length;
const done = applyAction(g, owner, action);
assert.deepEqual(g, before);
const victim = done.players.find(player => player.id === target)!;
const original = before.players.find(player => player.id === target)!;
const removed = original.hand.filter(card => !victim.hand.some(held => held.id === card.id));
assert.equal(removed.length, 1);
assert.deepEqual(victim.hand, original.hand.filter(card => card.id !== removed[0].id));
assert.equal(done.discard.filter(card => card.id === removed[0].id).length, 1);
assert.equal(victim.spice, original.spice);
assert.equal(done.nexusCards!.cards!.hands[owner], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.equal(done.nexusChoamBetrayals?.at(-1)?.stage, 'complete');
assert.equal(done.nexusChoamBetrayals?.at(-1)?.card, removed[0].id);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.log.slice(beforeLog).some(row => row.text.includes(removed[0].name)), false);
assert.equal(viewGame(done, observer).nexusChoamBetrayal, null);
assert.equal(Object.hasOwn(viewGame(done, observer), 'nexusChoamBetrayals'), false);
nexusTraitorInventory(done);
reject(done, owner, action);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
}
});
void test('CHOAM Betrayal hides non-Bidding hand count; all bot levels take a legal action during public-count Bidding', () => {
const { g, owner, target, observer } = position();
const offer = viewGame(g, owner).nexusChoamBetrayal!;
assert.equal(offer.blocked, null);
assert.equal(offer.target.id, target);
assert.equal(offer.target.handSize, null);
assert.equal(viewGame(g, target).nexusChoamBetrayal, null);
assert.equal(viewGame(g, observer).nexusChoamBetrayal, null);
for (const card of g.players.find(player => player.id === target)!.hand)
assert.equal(JSON.stringify(offer).includes(card.id), false);
const publicCount = position(false, 3);
const biddingOffer = viewGame(publicCount.g, owner).nexusChoamBetrayal!;
assert.equal(biddingOffer.target.handSize, publicCount.g.players.find(player => player.id === target)!.hand.length);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(publicCount.g, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], publicCount.action);
}
const botPlayed = applyAction(publicCount.g, owner, publicCount.action);
assert.equal(botPlayed.nexusChoamBetrayals?.at(-1)?.stage, 'complete');
nexusTraitorInventory(botPlayed);
});
void test('CHOAM Betrayal rejects forged, stale, foreign and interrupted plays without spending the card', () => {
const { g, owner, target, observer, action } = position();
reject(g, target, action);
reject(g, observer, action);
reject(g, owner, { ...action, event: 'stale' });
reject(g, owner, { ...action, card: 'forged' });
const pending = reload(g);
pending.decision = { kind: 'choamMarket', player: target };
assert.ok(viewGame(pending, owner).nexusChoamBetrayal?.blocked);
reject(pending, owner, action);
const empty = reload(g);
empty.deck.push(...empty.players.find(player => player.id === target)!.hand.splice(0));
assert.equal(viewGame(empty, owner).nexusChoamBetrayal?.blocked, null);
reject(empty, owner, action);
assert.equal(g.nexusCards!.cards!.hands[owner], 'choam');
});
void test('a committed random discard resumes exactly once after JSON restore and rejects damaged saved proof', () => {
const { g, owner, target, action } = position();
const done = applyAction(g, owner, action);
const paused = reload(done), record = paused.nexusChoamBetrayals!.at(-1)!;
record.stage = 'discard'; record.signature = nexusChoamBetrayalSignature(record);
paused.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
const sequence = paused.treacheryDiscardSequence!;
paused.resolvedTreacheryDiscardSequence = sequence - 1;
paused.pendingTreacheryDiscard = {
sequence,
batch: {
event: `discard:${paused.turn}:${paused.phase}:${sequence}`,
turn: paused.turn,
phase: paused.phase,
cause: 'nexus:choamBetrayal',
entries: [{ card: structuredClone(paused.discard.find(card => card.id === record.card)!), discardedBy: target, publicFace: false }],
},
continuation: { kind: 'nexusChoamBetrayal', event: record.event, owner, target, card: record.card, handBefore: record.handBefore },
};
assert.equal(viewGame(paused, owner).automaticContinuationPending, true);
assert.deepEqual(normalizeAutomaticGame(reload(paused)), done);
reject(paused, owner, action);
for (const damage of [
(state: Game) => { state.nexusChoamBetrayals![0].card = 'forged'; },
(state: Game) => { state.pendingTreacheryDiscard!.continuation = { kind: 'nexusChoamBetrayal', event: record.event, owner, target, card: 'forged', handBefore: record.handBefore }; },
(state: Game) => { state.pendingTreacheryDiscard = null; state.resolvedTreacheryDiscardSequence = sequence; },
]) {
const bad = reload(paused); damage(bad); const before = reload(bad);
assert.throws(() => viewGame(bad, owner));
assert.deepEqual(bad, before);
}
});
void test('a completed Betrayal remains valid when its Nexus discard is recycled by a later draw in the same turn', () => {
const { g, owner, action } = position(false, 0);
const done = applyAction(g, owner, action);
const recycled = reload(done), cards = recycled.nexusCards!.cards!;
cards.discard.push(...cards.deck.splice(0));
recycled.nexusCards!.cards = drawNexusCard(cards, owner, recycled.players, () => 0);
assert.equal(recycled.nexusCards!.cards!.discard.includes('choam'), false);
nexusTraitorInventory(recycled);
assert.doesNotThrow(() => viewGame(recycled, owner));
assert.deepEqual(normalizeAutomaticGame(reload(recycled)), recycled);
});