828 lines
26 KiB
TypeScript
828 lines
26 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { webcrypto } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import { baseDeck, leaders, createAuditorLeader } from '../game/cards';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
import {
|
|
quoteBattleAftermath,
|
|
BattleAftermathQuoteError,
|
|
type BattleAftermathInput,
|
|
} from '../game/battle-aftermath-quote';
|
|
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
|
|
function fixture(): BattleAftermathInput {
|
|
const cards = baseDeck(),
|
|
held = (kind: string) => cards.find((c) => c.kind === kind)!;
|
|
const players = [
|
|
{
|
|
id: 'a',
|
|
faction: 'harkonnen' as const,
|
|
hand: [held('worthless')],
|
|
leaders: leaders('harkonnen'),
|
|
},
|
|
{
|
|
id: 'd',
|
|
faction: 'choam' as const,
|
|
hand: [held('shield'), held('snooper')],
|
|
leaders: [...leaders('choam'), createAuditorLeader()],
|
|
},
|
|
{
|
|
id: 'm',
|
|
faction: 'moritani' as const,
|
|
hand: [],
|
|
leaders: leaders('moritani'),
|
|
},
|
|
{
|
|
id: 't',
|
|
faction: 'tleilaxu' as const,
|
|
hand: [],
|
|
leaders: leaders('tleilaxu'),
|
|
},
|
|
];
|
|
const techTokens = createTechTokens();
|
|
techTokens.axlotl.owner = 'd';
|
|
return {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
advanced: true,
|
|
battlePresent: false,
|
|
lastBattle: ['a', 'd'],
|
|
territoryIds: ['arrakeen', 'carthag'],
|
|
context: {
|
|
event: 'resolved',
|
|
turn: 2,
|
|
territory: 'arrakeen',
|
|
combatants: ['a', 'd'],
|
|
winner: 'a',
|
|
result: 'normal',
|
|
},
|
|
players,
|
|
leaderIds: players.flatMap((p) => p.leaders.map((l) => l.id)),
|
|
physicalCards: cards,
|
|
techTokens,
|
|
pending: {
|
|
retention: {
|
|
owner: 'm',
|
|
player: 'd',
|
|
turn: 2,
|
|
territory: 'arrakeen',
|
|
stage: 'choose',
|
|
played: players[1].hand.map((c) => c.id),
|
|
eligible: players[1].hand.map((c) => c.id),
|
|
},
|
|
income: { owner: 'd', amount: 1 },
|
|
tech: { player: 'a', loser: 'd', choices: ['axlotl'] },
|
|
capture: { player: 'a', loser: 'd', territory: 'arrakeen' },
|
|
auditor: {
|
|
owner: 'd',
|
|
opponent: 'a',
|
|
territory: 'arrakeen',
|
|
event: 'resolved',
|
|
turn: 2,
|
|
stage: 'offer',
|
|
survived: true,
|
|
usedCards: [],
|
|
},
|
|
faceDance: {
|
|
player: 't',
|
|
winner: 'a',
|
|
territory: 'arrakeen',
|
|
leader: players[0].leaders[0].id,
|
|
identity: players[0].leaders[0].id,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
function withoutBefore(
|
|
g: BattleAftermathInput,
|
|
slot: keyof BattleAftermathInput['pending'],
|
|
) {
|
|
for (const key of [
|
|
'retention',
|
|
'income',
|
|
'tech',
|
|
'capture',
|
|
'auditor',
|
|
'faceDance',
|
|
] as const) {
|
|
if (key === slot) break;
|
|
delete g.pending[key];
|
|
}
|
|
return g;
|
|
}
|
|
function reject(g: BattleAftermathInput, pattern?: RegExp) {
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() => quoteBattleAftermath(g),
|
|
pattern ?? BattleAftermathQuoteError,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
void test('the existing aftermath priority stops at each next real decision without inspecting later choices', () => {
|
|
for (const [slot, expected] of [
|
|
['retention', 'moritaniRetention'],
|
|
['income', 'choamBattleIncome'],
|
|
['tech', 'techToken'],
|
|
['capture', 'captureOffer'],
|
|
['auditor', 'choamAudit'],
|
|
['faceDance', 'faceDance'],
|
|
] as const) {
|
|
const g = withoutBefore(fixture(), slot);
|
|
if (slot === 'tech') {
|
|
g.techTokens!.production.owner = 'd';
|
|
g.pending.tech = {
|
|
player: 'a',
|
|
loser: 'd',
|
|
choices: ['axlotl', 'production'],
|
|
};
|
|
}
|
|
const before = structuredClone(g),
|
|
q = quoteBattleAftermath(g);
|
|
assert.equal(q.next.kind, expected);
|
|
assert.deepEqual(q.steps, []);
|
|
assert.deepEqual(g, before);
|
|
if (slot !== 'faceDance') {
|
|
g.pending.faceDance = { invalidFutureChoice: true };
|
|
assert.equal(quoteBattleAftermath(g).next.kind, expected);
|
|
}
|
|
}
|
|
});
|
|
void test('one token transfers automatically before capture; multiple tokens require a real choice and preserve their order', () => {
|
|
const g = withoutBefore(fixture(), 'tech'),
|
|
before = structuredClone(g);
|
|
assert.deepEqual(quoteBattleAftermath(g).steps, [
|
|
{ kind: 'transferTech', id: 'axlotl', from: 'd', to: 'a' },
|
|
]);
|
|
assert.equal(quoteBattleAftermath(g).next.kind, 'captureOffer');
|
|
assert.deepEqual(g, before);
|
|
g.techTokens!.production.owner = 'd';
|
|
g.pending.tech = {
|
|
player: 'a',
|
|
loser: 'd',
|
|
choices: ['production', 'axlotl'],
|
|
};
|
|
const q = quoteBattleAftermath(g);
|
|
assert.deepEqual(q.steps, []);
|
|
assert.deepEqual(q.next, {
|
|
kind: 'techToken',
|
|
player: 'a',
|
|
loser: 'd',
|
|
choices: ['production', 'axlotl'],
|
|
});
|
|
});
|
|
void test('empty Auditor clearing follows a sole token transfer and stops at Face Dance without sampling', () => {
|
|
const g = withoutBefore(fixture(), 'tech');
|
|
delete g.pending.capture;
|
|
(g.pending.auditor as { usedCards: string[] }).usedCards =
|
|
g.players[0].hand.map((c) => c.id);
|
|
const before = structuredClone(g),
|
|
q = quoteBattleAftermath(g);
|
|
assert.deepEqual(q.steps, [
|
|
{ kind: 'transferTech', id: 'axlotl', from: 'd', to: 'a' },
|
|
{ kind: 'emptyAuditor', event: 'resolved' },
|
|
]);
|
|
assert.equal(q.next.kind, 'faceDance');
|
|
assert.deepEqual(g, before);
|
|
delete g.pending.faceDance;
|
|
assert.equal(quoteBattleAftermath(g).next.kind, 'board');
|
|
});
|
|
void test('legacy saves bind only available facts, including technology without territory and mutual-death audit without winner', () => {
|
|
const empty = fixture();
|
|
delete empty.context;
|
|
empty.pending = {};
|
|
assert.deepEqual(quoteBattleAftermath(empty), {
|
|
context: { kind: 'legacy', turn: 2, combatants: ['a', 'd'] },
|
|
steps: [],
|
|
next: { kind: 'board' },
|
|
});
|
|
const tech = withoutBefore(fixture(), 'tech');
|
|
delete tech.context;
|
|
delete tech.pending.capture;
|
|
delete tech.pending.auditor;
|
|
delete tech.pending.faceDance;
|
|
assert.deepEqual(quoteBattleAftermath(tech).context, {
|
|
kind: 'legacy',
|
|
turn: 2,
|
|
combatants: ['a', 'd'],
|
|
winner: 'a',
|
|
});
|
|
const audit = withoutBefore(fixture(), 'auditor');
|
|
delete audit.context;
|
|
delete audit.pending.faceDance;
|
|
audit.legacy = { winner: null };
|
|
assert.deepEqual(quoteBattleAftermath(audit).context, {
|
|
kind: 'legacy',
|
|
turn: 2,
|
|
combatants: ['a', 'd'],
|
|
winner: null,
|
|
territory: 'arrakeen',
|
|
});
|
|
assert.equal(quoteBattleAftermath(audit).next.kind, 'choamAudit');
|
|
const retained = fixture();
|
|
delete retained.context;
|
|
assert.deepEqual(quoteBattleAftermath(retained).context, {
|
|
kind: 'legacy',
|
|
turn: 2,
|
|
combatants: ['a', 'd'],
|
|
winner: 'a',
|
|
territory: 'arrakeen',
|
|
});
|
|
});
|
|
void test('modern receipts bind turn, territory, ordered combatants, winner and semantic result', () => {
|
|
for (const mutate of [
|
|
(g: BattleAftermathInput) => {
|
|
g.turn++;
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
g.phase = 5;
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
g.battlePresent = true;
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
g.lastBattle = ['d', 'a'];
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
(g.context as Record<string, unknown>).winner = 'd';
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
(g.context as Record<string, unknown>).territory = 'carthag';
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
(g.context as Record<string, unknown>).result = 'explosion';
|
|
},
|
|
(g: BattleAftermathInput) => {
|
|
(g.context as Record<string, unknown>).event = '';
|
|
},
|
|
]) {
|
|
const g = fixture();
|
|
mutate(g);
|
|
reject(g);
|
|
}
|
|
for (const context of [false, [], {}, 'invalid']) {
|
|
const g = fixture();
|
|
g.context = context;
|
|
reject(g);
|
|
}
|
|
});
|
|
void test('Moritani receipt validates exact owner, loser, stage and unique physically held played/eligible cards', () => {
|
|
const mutations = [
|
|
{ owner: 'a' },
|
|
{ player: 'a' },
|
|
{ turn: 1 },
|
|
{ stage: 'response' },
|
|
{ played: [] },
|
|
{ eligible: ['missing'] },
|
|
{ played: ['x', 'x'] },
|
|
];
|
|
for (const mutation of mutations) {
|
|
const g = fixture();
|
|
Object.assign(g.pending.retention as object, mutation);
|
|
reject(g);
|
|
}
|
|
const missing = fixture();
|
|
missing.players[1].hand = [];
|
|
reject(missing, /custody/);
|
|
const duplicate = fixture();
|
|
duplicate.physicalCards = [
|
|
...duplicate.physicalCards,
|
|
duplicate.players[1].hand[0],
|
|
];
|
|
reject(duplicate, /custody/);
|
|
});
|
|
void test('technology rejects empty, duplicated, foreign or transferred tokens before any automatic step', () => {
|
|
for (const choices of [
|
|
[],
|
|
['axlotl', 'axlotl'],
|
|
['missing'],
|
|
['production'],
|
|
]) {
|
|
const g = withoutBefore(fixture(), 'tech');
|
|
g.pending.tech = { player: 'a', loser: 'd', choices };
|
|
reject(g);
|
|
}
|
|
const moved = withoutBefore(fixture(), 'tech');
|
|
moved.techTokens!.axlotl.owner = 'a';
|
|
reject(moved);
|
|
const omitted = withoutBefore(fixture(), 'tech');
|
|
omitted.techTokens!.production.owner = 'd';
|
|
reject(omitted);
|
|
const missing = withoutBefore(fixture(), 'tech');
|
|
missing.techTokens = null;
|
|
reject(missing);
|
|
const invalid = withoutBefore(fixture(), 'tech');
|
|
invalid.techTokens!.axlotl.spice = Number.NaN;
|
|
reject(invalid);
|
|
});
|
|
void test('canceled income quotes zero payment and then its sole technology transfer before capture', () => {
|
|
const g = withoutBefore(fixture(), 'income');
|
|
g.cancel = { kind: 'choamBattleIncome', owner: 'd' };
|
|
const before = structuredClone(g),
|
|
q = quoteBattleAftermath(g);
|
|
assert.deepEqual(q.steps, [
|
|
{ kind: 'cancel', slot: 'income' },
|
|
{ kind: 'transferTech', id: 'axlotl', from: 'd', to: 'a' },
|
|
]);
|
|
assert.equal(q.next.kind, 'captureOffer');
|
|
assert.deepEqual(g, before);
|
|
g.cancel.owner = 'a';
|
|
reject(g, /owner/);
|
|
});
|
|
void test('canceled capture does not demand a random candidate and progresses to the actual current audit count', () => {
|
|
const g = withoutBefore(fixture(), 'capture');
|
|
g.cancel = { kind: 'capture', owner: 'a' };
|
|
// No capturable foreign leader is needed to skip the random capture branch.
|
|
g.players[1].leaders = [createAuditorLeader()];
|
|
const q = quoteBattleAftermath(g);
|
|
assert.deepEqual(q.steps, [{ kind: 'cancel', slot: 'capture' }]);
|
|
assert.deepEqual(q.next, {
|
|
kind: 'choamAudit',
|
|
player: 'd',
|
|
event: 'resolved',
|
|
count: 1,
|
|
});
|
|
});
|
|
void test('canceled Auditor requires its response identity and validates hand candidates without charging or inspecting', () => {
|
|
const g = withoutBefore(fixture(), 'auditor');
|
|
Object.assign(g.pending.auditor as object, { stage: 'response' });
|
|
g.cancel = { kind: 'choamAudit', owner: 'd', intent: 'resolved' };
|
|
const before = structuredClone(g),
|
|
q = quoteBattleAftermath(g);
|
|
assert.deepEqual(q.steps, [{ kind: 'cancel', slot: 'auditor' }]);
|
|
assert.equal(q.next.kind, 'faceDance');
|
|
assert.deepEqual(g, before);
|
|
g.cancel.intent = 'other';
|
|
reject(g, /stale/);
|
|
g.cancel.intent = 'resolved';
|
|
g.players[0].hand = [...g.players[0].hand, ...g.players[0].hand];
|
|
reject(g, /unique physical cards/);
|
|
});
|
|
void test('impossible earlier obligations cannot be skipped by a currently canceled aftermath response', () => {
|
|
for (const cancel of [
|
|
{ kind: 'capture', owner: 'a' },
|
|
{ kind: 'choamBattleIncome', owner: 'd' },
|
|
{ kind: 'choamAudit', owner: 'd', intent: 'resolved' },
|
|
] as const) {
|
|
const g = fixture();
|
|
g.cancel = cancel;
|
|
reject(g, /preceding|earlier/);
|
|
}
|
|
const g = withoutBefore(fixture(), 'auditor');
|
|
g.cancel = { kind: 'choamAudit', owner: 'd', intent: 'resolved' };
|
|
reject(g, /stage/);
|
|
});
|
|
void test('Face Dance retains the winning disc identity after custody changes, and accepts a null or physical hero identity only correctly', () => {
|
|
const g = withoutBefore(fixture(), 'faceDance');
|
|
const captured = g.players[0].leaders[0].id;
|
|
g.players[0].leaders = []; // Actual native/shared registry independently retains its ID.
|
|
assert.equal(quoteBattleAftermath(g).next.kind, 'faceDance');
|
|
g.pending.faceDance = {
|
|
player: 't',
|
|
winner: 'a',
|
|
leader: null,
|
|
identity: null,
|
|
territory: 'arrakeen',
|
|
};
|
|
assert.equal(quoteBattleAftermath(g).next.kind, 'faceDance');
|
|
const hero = baseDeck().find((c) => c.kind === 'hero')!;
|
|
Object.assign(g.pending.faceDance as object, {
|
|
leader: hero.id,
|
|
identity: 'cheap-hero-traitor',
|
|
});
|
|
assert.equal(quoteBattleAftermath(g).next.kind, 'faceDance');
|
|
Object.assign(g.pending.faceDance as object, { identity: captured });
|
|
reject(g, /hero/);
|
|
});
|
|
void test('all quote outputs are detached and repeated evaluation has no RNG, UUID, state changes or private inspection', (t) => {
|
|
const g = withoutBefore(fixture(), 'tech');
|
|
delete g.pending.capture;
|
|
(g.pending.auditor as { usedCards: string[] }).usedCards =
|
|
g.players[0].hand.map((c) => c.id);
|
|
const before = structuredClone(g),
|
|
first = quoteBattleAftermath(g);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No random draw');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No event');
|
|
});
|
|
for (let i = 0; i < 20; i++) assert.deepEqual(quoteBattleAftermath(g), first);
|
|
first.steps.length = 0;
|
|
if (first.context.kind === 'modern')
|
|
first.context.receipt.combatants.reverse();
|
|
assert.deepEqual(g, before);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
|
|
// Observation-only private export; no production test seam or mutation bypass.
|
|
const observed = {} as {
|
|
applyActionInner: (
|
|
g: Game,
|
|
player: string,
|
|
action: Parameters<typeof applyAction>[2],
|
|
) => Game;
|
|
finishBattle: (g: Game) => void;
|
|
validateKaramaUse: (
|
|
g: Game,
|
|
player: Game['players'][number],
|
|
use: { kind: 'cancel'; response: NonNullable<Game['response']> },
|
|
card: Game['deck'][number],
|
|
) => void;
|
|
};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport { applyActionInner, finishBattle, validateKaramaUse };',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
function actualResolvedBattle() {
|
|
let g = createGame(
|
|
'AFTERMATH',
|
|
newPlayer('a', 'Harkonnen', 'harkonnen'),
|
|
true,
|
|
['choam'],
|
|
);
|
|
g.players.push(
|
|
newPlayer('d', 'CHOAM', 'choam'),
|
|
newPlayer('t', 'Tleilaxu', 'tleilaxu'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'a',
|
|
order: ['a', 'd', 't'],
|
|
deck: baseDeck(),
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
techTokens: createTechTokens(),
|
|
});
|
|
g.techTokens!.production.owner = 'd';
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.traitors = [];
|
|
p.spice = 20;
|
|
p.forces = p.id === 't' ? {} : { 'false_wall_south:4': 5 };
|
|
p.reserves = p.id === 't' ? 20 : 15;
|
|
for (const l of p.leaders) l.strength = 0;
|
|
}
|
|
g.players[1].leaders.push(createAuditorLeader());
|
|
function hold(index: number, kind: string) {
|
|
const at = g.deck.findIndex((c) => c.kind === kind);
|
|
assert.ok(at >= 0);
|
|
const card = g.deck.splice(at, 1)[0];
|
|
g.players[index].hand.push(card);
|
|
return card;
|
|
}
|
|
const defense = hold(0, 'shield');
|
|
hold(0, 'worthless');
|
|
const enemy = hold(1, 'worthless');
|
|
g = applyAction(g, 'a', {
|
|
type: 'chooseBattle',
|
|
territory: 'false_wall_south',
|
|
target: 'd',
|
|
});
|
|
for (let i = 0; g.response && i < 20; i++)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
g = applyAction(g, 'a', {
|
|
type: 'battlePlan',
|
|
dial: 3,
|
|
support: 3,
|
|
leader: g.players[0].leaders[0].id,
|
|
defense: defense.id,
|
|
});
|
|
g = applyAction(g, 'd', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: 'choam-auditor',
|
|
weapon: enemy.id,
|
|
});
|
|
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
|
|
g = applyAction(g, 'd', { type: 'traitorCall', call: false });
|
|
assert.equal(g.decision?.kind, 'battleCards');
|
|
assert.ok(
|
|
g.pendingCapture &&
|
|
g.pendingAuditor &&
|
|
g.pendingTech &&
|
|
g.pendingChoamBattleIncome &&
|
|
g.pendingFaceDance,
|
|
);
|
|
return g;
|
|
}
|
|
function normalize(g: Game): BattleAftermathInput {
|
|
return {
|
|
status: g.status,
|
|
phase: g.phase,
|
|
turn: g.turn,
|
|
advanced: g.advanced,
|
|
battlePresent: !!g.battle,
|
|
lastBattle: g.lastBattle,
|
|
territoryIds: ['false_wall_south'],
|
|
context: g.lastBattleContext,
|
|
players: g.players.map((p) => ({
|
|
id: p.id,
|
|
faction: p.faction,
|
|
hand: p.hand,
|
|
leaders: p.leaders,
|
|
})),
|
|
leaderIds: g.players.flatMap((p) => p.leaders.map((l) => l.id)),
|
|
physicalCards: [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
],
|
|
techTokens: g.techTokens,
|
|
pending: {
|
|
retention: g.moritaniRetention,
|
|
income: g.pendingChoamBattleIncome,
|
|
tech: g.pendingTech,
|
|
capture: g.pendingCapture,
|
|
auditor: g.pendingAuditor,
|
|
faceDance: g.pendingFaceDance,
|
|
},
|
|
};
|
|
}
|
|
void test('a real completed battle and winner cleanup produce the same CHOAM response; cancellation predicts the sole token transfer and next capture offer', () => {
|
|
const g = actualResolvedBattle(),
|
|
before = structuredClone(g),
|
|
q = quoteBattleAftermath(normalize(g));
|
|
assert.deepEqual(q.next, {
|
|
kind: 'choamBattleIncome',
|
|
owner: 'd',
|
|
amount: 1,
|
|
});
|
|
const after = observed.applyActionInner(g, 'a', {
|
|
type: 'decision',
|
|
discard: [],
|
|
});
|
|
assert.deepEqual(g, before);
|
|
assert.equal(after.response!.kind, q.next.kind);
|
|
assert.equal(after.pendingTech!.choices.length, 1);
|
|
const cancelInput = normalize(after);
|
|
cancelInput.cancel = { kind: 'choamBattleIncome', owner: 'd' };
|
|
const predicted = quoteBattleAftermath(cancelInput);
|
|
assert.deepEqual(predicted.steps, [
|
|
{ kind: 'cancel', slot: 'income' },
|
|
{ kind: 'transferTech', id: 'production', from: 'd', to: 'a' },
|
|
]);
|
|
assert.equal(predicted.next.kind, 'captureOffer');
|
|
// Commit only the selected canceled receipt for the observation; the quoted
|
|
// compulsory suffix is then executed by unchanged private finishBattle.
|
|
const continued = structuredClone(after);
|
|
continued.pendingChoamBattleIncome = null;
|
|
continued.response = null;
|
|
observed.finishBattle(continued);
|
|
assert.equal(continued.techTokens!.production.owner, 'a');
|
|
assert.equal(continued.pendingTech, null);
|
|
assert.deepEqual(
|
|
JSON.parse(JSON.stringify(continued.decision)),
|
|
predicted.next,
|
|
);
|
|
assert.equal(continued.players[1].spice, after.players[1].spice);
|
|
assert.ok(continued.pendingAuditor && continued.pendingFaceDance);
|
|
});
|
|
|
|
/** Real income response with BG's one unplayed Worthless card as the sole
|
|
* eligible Auditor candidate. Its retained Shield is a used-card exclusion. */
|
|
function lastAuditCandidateIncome(form: 'worthless' | 'printed' = 'worthless') {
|
|
let g = createGame('AUDITCOST', newPlayer('b', 'BG', 'beneGesserit'), true, [
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('c', 'CHOAM', 'choam'),
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'b',
|
|
order: ['b', 'c', 'e'],
|
|
deck: baseDeck(),
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 20;
|
|
p.traitors = [];
|
|
p.forces = p.id === 'e' ? {} : { 'false_wall_south:4': 5 };
|
|
p.reserves = p.id === 'e' ? 20 : 15;
|
|
for (const l of p.leaders) l.strength = 0;
|
|
}
|
|
g.players[1].leaders.push(createAuditorLeader());
|
|
function hold(index: number, kind: string, effect?: string) {
|
|
const at = g.deck.findIndex(
|
|
(c) => c.kind === kind && (!effect || c.effect === effect),
|
|
);
|
|
assert.ok(at >= 0);
|
|
const card = g.deck.splice(at, 1)[0];
|
|
g.players[index].hand.push(card);
|
|
return card;
|
|
}
|
|
const shield = hold(0, 'shield'),
|
|
cost =
|
|
form === 'printed' ? hold(0, 'special', 'karama') : hold(0, 'worthless'),
|
|
loserCard = hold(1, 'worthless');
|
|
hold(2, 'special', 'karama');
|
|
const allow = () => {
|
|
for (let i = 0; g.response && i < 30; i++)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
assert.equal(g.response, null);
|
|
};
|
|
g = applyAction(g, 'b', {
|
|
type: 'chooseBattle',
|
|
territory: 'false_wall_south',
|
|
target: 'c',
|
|
});
|
|
allow();
|
|
for (let i = 0; g.battle?.preparation && i < 20; i++) {
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
allow();
|
|
}
|
|
g = applyAction(g, 'b', {
|
|
type: 'battlePlan',
|
|
dial: 3,
|
|
support: 3,
|
|
leader: g.players[0].leaders[0].id,
|
|
defense: shield.id,
|
|
});
|
|
g = applyAction(g, 'c', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: 'choam-auditor',
|
|
weapon: loserCard.id,
|
|
});
|
|
g = applyAction(g, 'b', { type: 'traitorCall', call: false });
|
|
g = applyAction(g, 'c', { type: 'traitorCall', call: false });
|
|
assert.equal(g.decision?.kind, 'battleCards');
|
|
g = applyAction(g, 'b', { type: 'decision', discard: [] });
|
|
assert.equal(g.response?.kind, 'choamBattleIncome');
|
|
assert.deepEqual(g.pendingAuditor!.usedCards, [shield.id]);
|
|
assert.deepEqual(
|
|
g.players[0].hand.map((c) => c.id).sort(),
|
|
[shield.id, cost.id].sort(),
|
|
);
|
|
return { g, cost, shield };
|
|
}
|
|
for (const form of ['worthless', 'printed'] as const) {
|
|
void test(`${form} cancellation spending the final Auditor candidate validates the newly reachable board before its first cost`, (t) => {
|
|
const f = lastAuditCandidateIncome(form),
|
|
bad = structuredClone(f.g);
|
|
bad.players[2].forces = { 'red_chasm:999': 1 };
|
|
bad.players[2].reserves = 19;
|
|
const before = structuredClone(bad);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No random draw');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No event');
|
|
});
|
|
// Observe the actual unchanged pre-cost validator, separately from the
|
|
// public dispatcher's clone atomicity, for both printed and converted use.
|
|
assert.throws(
|
|
() =>
|
|
observed.validateKaramaUse(
|
|
bad,
|
|
bad.players[0],
|
|
{ kind: 'cancel', response: bad.response! },
|
|
f.cost,
|
|
),
|
|
/board|location/i,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(bad, 'b', {
|
|
type: 'card',
|
|
card: f.cost.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/board|location/i,
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
assert.equal(
|
|
bad.discard.some((c) => c.id === f.cost.id),
|
|
false,
|
|
);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
void test(`${form} cancellation spends once and skips an empty Auditor without inspecting the used retained Shield`, () => {
|
|
const f = lastAuditCandidateIncome(form),
|
|
before = structuredClone(f.g);
|
|
let g = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f.cost.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.deepEqual(f.g, before);
|
|
if (form === 'worthless') assert.equal(g.response?.kind, 'worthlessKarama');
|
|
g = JSON.parse(JSON.stringify(g)) as Game;
|
|
for (let i = 0; g.response && i < 20; i++) {
|
|
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
|
|
assert.ok(p);
|
|
g = applyAction(g, p.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.pendingAuditor, null);
|
|
assert.equal(g.auditorInsight ?? null, null);
|
|
assert.equal(g.discard.filter((c) => c.id === f.cost.id).length, 1);
|
|
assert.deepEqual(
|
|
g.players[0].hand.map((c) => c.id),
|
|
[f.shield.id],
|
|
);
|
|
assert.equal(
|
|
g.log.filter((e) => e.text.includes('no eligible opposing hand cards'))
|
|
.length,
|
|
1,
|
|
);
|
|
assert.deepEqual(
|
|
g.players.map((p) => p.spice),
|
|
[17, 20, 20],
|
|
);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
});
|
|
}
|
|
void test('cost projection removes only the held card, preserves global physical custody and validates its selected owner', () => {
|
|
const g = withoutBefore(fixture(), 'income');
|
|
delete g.pending.tech;
|
|
delete g.pending.capture;
|
|
delete g.pending.faceDance;
|
|
g.cancel = { kind: 'choamBattleIncome', owner: 'd' };
|
|
const card = g.players[0].hand[0].id,
|
|
spending = { player: 'a', card },
|
|
before = structuredClone(g);
|
|
assert.equal(quoteBattleAftermath(g).next.kind, 'choamAudit');
|
|
assert.deepEqual(quoteBattleAftermath(g, spending).steps, [
|
|
{ kind: 'cancel', slot: 'income' },
|
|
{ kind: 'emptyAuditor', event: 'resolved' },
|
|
]);
|
|
assert.equal(quoteBattleAftermath(g, spending).next.kind, 'board');
|
|
assert.deepEqual(g, before);
|
|
(g.pending.auditor as { usedCards: string[] }).usedCards = [card];
|
|
// A used card retains its physical identity after hand -> discard migration.
|
|
assert.equal(quoteBattleAftermath(g, spending).next.kind, 'board');
|
|
for (const invalid of [
|
|
{ player: 'missing', card },
|
|
{ player: 'd', card },
|
|
{ player: 'a', card: 'missing' },
|
|
])
|
|
assert.throws(() => quoteBattleAftermath(g, invalid), /custody/);
|
|
for (const duplicate of [false, true]) {
|
|
const bad = structuredClone(g);
|
|
bad.physicalCards = duplicate
|
|
? [...bad.physicalCards, g.players[0].hand[0]]
|
|
: bad.physicalCards.filter((c) => c.id !== card);
|
|
assert.throws(
|
|
() => quoteBattleAftermath(bad, spending),
|
|
BattleAftermathQuoteError,
|
|
);
|
|
}
|
|
});
|
|
void test('post-cost projection still stops at a genuine capture or Face Dance choice before later work', () => {
|
|
const g = withoutBefore(fixture(), 'income');
|
|
delete g.pending.tech;
|
|
g.cancel = { kind: 'choamBattleIncome', owner: 'd' };
|
|
const spending = { player: 'a', card: g.players[0].hand[0].id };
|
|
g.pending.auditor = { malformed: 'later choice' };
|
|
assert.equal(quoteBattleAftermath(g, spending).next.kind, 'captureOffer');
|
|
delete g.pending.capture;
|
|
g.pending.auditor = fixture().pending.auditor;
|
|
assert.equal(quoteBattleAftermath(g, spending).next.kind, 'faceDance');
|
|
});
|