import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { recruitsPlayAction } from '../game/recruits'; import { normalRevivalAllowance, newRevivalRules } from '../game/revival'; import { botActions } from '../game/bots'; import { recruitsGame } from './fixture-recruits'; const play = { type: 'card', card: 'ecaz-recruits' } as const; function rejectUnchanged(g: Game, player: string, action: Action = play) { const before = structuredClone(g); assert.throws(() => applyAction(g, player, action)); assert.deepEqual(g, before); } function physical(g: Game) { return [ ...g.deck, ...g.discard, ...g.players.flatMap((player) => player.hand), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.auction?.cards.slice(g.auction.index) ?? []), ]; } function settleRevival(state: Game) { let g = state; for (let step = 0; g.pendingRevival && step < 20; step++) { let next: Game | undefined; for (const player of g.players) { const view = viewGame(g, player.id); view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium'; const action = botActions(view)[0]; if (action) { next = applyAction(g, player.id, action); break; } } assert.ok(next, 'Pending revival must retain a legal continuation.'); g = next; } assert.equal(g.pendingRevival, null); return g; } void test('genuine Basic and Advanced setup owns exactly one of all three independent cards and projects only the holder play', () => { for (const advanced of [false, true]) { const g = recruitsGame(advanced); const cards = physical(g).filter((card) => card.id.startsWith('ecaz-')); assert.deepEqual(cards.map((card) => card.id).sort(), [ 'ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements', ]); const owner = g.players.find((player) => player.hand.some((card) => card.id === play.card))!; for (const player of g.players) { const preview = viewGame(g, player.id).recruitsPreview; assert.ok(preview); assert.equal(preview.active, false); assert.deepEqual( recruitsPlayAction(preview), player.id === owner.id ? play : null, ); assert.equal(preview.play === null, player.id !== owner.id); } } }); void test('playing Recruits discards its exact card and publishes effective rates and independent limits', () => { for (const advanced of [false, true]) { const before = recruitsGame(advanced); const g = applyAction(before, 'ch', play); assert.deepEqual(g.recruits, { turn: 2, player: 'ch', card: play.card }); assert.equal(g.players.find((player) => player.id === 'ch')!.hand.some((card) => card.id === play.card), false); assert.equal(g.discard.filter((card) => card.id === play.card).length, 1); const preview = viewGame(JSON.parse(JSON.stringify(g)), 'at').recruitsPreview!; assert.deepEqual( Object.fromEntries(preview.rates.map((row) => [row.player, [row.freeRate, row.limit]])), { at: [4, 7], fr: [6, 7], tl: [4, 20], ch: [0, 20] }, ); assert.equal(preview.active, true); assert.equal(preview.playedBy, 'ch'); assert.equal(preview.grantRevivalBlocked, null); } }); void test('the accepted card changes the authoritative normal revival quote and free ledger', () => { let g = applyAction(recruitsGame(true), 'ch', play); const player = g.players.find((candidate) => candidate.id === 'at')!; const before = { reserves: player.reserves, tanks: player.tanks, spice: player.spice }; g = settleRevival(applyAction(g, player.id, { type: 'revive', amount: 4, elite: 0 })); const after = g.players.find((candidate) => candidate.id === player.id)!; assert.equal(after.reserves, before.reserves + 4); assert.equal(after.tanks, before.tanks - 4); assert.equal(after.spice, before.spice); assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 4); assert.equal(viewGame(g, player.id).revival.forcesRemaining, 3); rejectUnchanged(g, player.id, { type: 'revive', amount: 4, elite: 0 }); }); void test('provisional late-paid Recruits preserves payment and permits only the remaining recorded free allowance', () => { for (const advanced of [false, true]) { let g = settleRevival(applyAction(recruitsGame(advanced), 'at', { type: 'revive', amount: 3, elite: 0 })); const before = g.players.find(player => player.id === 'at')!; assert.equal(before.revived, 3); assert.equal(before.freeForcesRevived, 2); assert.equal(before.spice, 18); const reserves = before.reserves, tanks = before.tanks; g = applyAction(g, 'ch', play); assert.equal(viewGame(g, 'at').revival.freeRemaining, 2); assert.equal(viewGame(g, 'at').revival.forcesRemaining, 4); assert.equal(g.players.find(player => player.id === 'at')!.spice, 18); g = settleRevival(applyAction(g, 'at', { type: 'revive', amount: 2, elite: 0 })); const after = g.players.find(player => player.id === 'at')!; assert.equal(after.spice, 18); assert.equal(after.revived, 5); assert.equal(after.freeForcesRevived, 4); assert.equal(after.reserves, reserves + 2); assert.equal(after.tanks, tanks - 2); assert.equal(viewGame(g, 'at').revival.freeRemaining, 0); assert.equal(g.discard.filter(card => card.id === play.card).length, 1); } }); void test('free prevention remains zero and canceled unlimited faction caps compose to seven', () => { const g = recruitsGame(); g.revivalRules = { ...newRevivalRules(), freeBlocked: ['at'], choamBlocked: true, limitBlocked: true, }; const active = applyAction(g, 'ch', play); const rows = Object.fromEntries( viewGame(active, 'at').recruitsPreview!.rates.map((row) => [row.player, row]), ); assert.equal(rows.at.freeRate, 0); assert.equal(rows.at.limit, 7); assert.equal(rows.tl.limit, 7); assert.equal(rows.ch.limit, 7); }); void test('Fremen ally grant uses the same current doubled rate before or after Recruits', () => { let g = recruitsGame(); const fremen = g.players.find((player) => player.id === 'fr')!; const atreides = g.players.find((player) => player.id === 'at')!; fremen.ally = atreides.id; atreides.ally = fremen.id; g = applyAction(g, fremen.id, { type: 'grantRevival' }); while (g.response) { const actor = g.players.find(p => !g.response!.passed.includes(p.id))!; g = applyAction(g, actor.id, { type: 'passResponse' }); } g = applyAction(g, 'ch', play); assert.deepEqual( viewGame(g, 'at').recruitsPreview!.rates.find((row) => row.player === 'at'), { player: 'at', freeRate: 6, limit: 7 }, ); const late = recruitsGame(); late.players.find((player) => player.id === 'fr')!.ally = 'at'; late.players.find((player) => player.id === 'at')!.ally = 'fr'; let active = applyAction(late, 'ch', play); active = applyAction(active, 'fr', { type: 'grantRevival' }); while (active.response) { const actor = active.players.find(p => !active.response!.passed.includes(p.id))!; active = applyAction(active, actor.id, { type: 'passResponse' }); } assert.deepEqual(viewGame(active, 'at').recruitsPreview!.rates.find(row => row.player === 'at'), { player: 'at', freeRate: 6, limit: 7 }); }); void test('activation rejects pending, unknown and inconsistent revival histories without consuming the card', () => { const cases = [ (g: Game) => { g.decision = { kind: 'revivalStop', player: 'at', recipient: 'at', revival: 'forces' }; }, (g: Game) => { const p = g.players[0]; p.revived = 1; delete p.freeForcesRevived; }, (g: Game) => { const p = g.players[0]; p.revived = 1; p.freeForcesRevived = 2; }, ]; for (const arrange of cases) { const g = recruitsGame(); arrange(g); assert.ok(viewGame(g, 'ch').recruitsPreview!.play?.blocked); rejectUnchanged(g, 'ch'); } }); void test('same-turn Box recovery cannot replay or consume Recruits', () => { const g = applyAction(recruitsGame(), 'ch', play); const card = g.discard.find((candidate) => candidate.id === play.card)!; g.discard = g.discard.filter((candidate) => candidate.id !== play.card); g.players.find((player) => player.id === 'ch')!.hand.push(card); assert.equal(viewGame(g, 'ch').recruitsPreview!.play?.blocked?.includes('already active'), true); rejectUnchanged(g, 'ch'); }); void test('a ready owner may still play before Revival closes, and the receipt expires on the next turn', () => { let g = recruitsGame(); for (const player of g.players) { player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0); player.elites.forces = {}; } if (player.advisors) player.advisors = {}; } g.ready = ['ch']; g = applyAction(g, 'ch', play); assert.equal(g.recruits?.turn, 2); for (let step = 0; g.status === 'playing' && g.turn === 2 && step < 1000; step++) { let next: Game | undefined; for (const player of g.players) { const view = viewGame(g, player.id); view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium'; const action = botActions(view)[0]; if (action) { next = applyAction(g, player.id, action); break; } } assert.ok(next, 'The active effect must retain a legal path to turn end.'); g = next; } assert.equal(g.turn, 3); assert.equal(g.recruits, null); assert.equal(viewGame(g, 'at').recruitsPreview?.active, false); }); void test('legacy known-zero ledgers are initialized atomically when Recruits is accepted', () => { const g = recruitsGame(); for (const player of g.players) delete player.freeForcesRevived; const active = applyAction(g, 'ch', play); assert.ok(active.players.every((player) => player.freeForcesRevived === 0)); assert.doesNotThrow(() => viewGame(JSON.parse(JSON.stringify(active)), 'at')); }); void test('a sold Recruits card is counted once while its normal auction continuation is open', () => { const g = recruitsGame(); const card = g.players.find((player) => player.id === 'ch')!.hand.find((candidate) => candidate.id === play.card)!; g.phase = 3; g.auction = { cards: [card], index: 0, bid: 1, bidder: 'ch', active: 'ch', passed: [], opener: 0, peekKnown: false }; g.currentAuctionSale = { winner: 'ch', amount: 1, free: false, origin: 'normal', seller: null }; g.response = { kind: 'harkonnenBonus', owner: 'ch', passed: [] }; assert.doesNotThrow(() => viewGame(g, 'at')); }); void test('ordinary audit setup cannot silently activate a forged Ecaz variant marker', () => { const lobby = createGame('NOECAZV', newPlayer('at', 'Atreides', 'atreides')); joinGame(lobby, newPlayer('fr', 'Fremen', 'fremen')); for (const player of lobby.players) player.ready = true; lobby.ecazTreachery = true; rejectUnchanged(lobby, 'at', { type: 'start' }); assert.throws(() => initializeBaseGameForAudit(lobby), /audit initializer supports/); }); void test('ordinary revival math remains unchanged while Recruits is inactive', () => { const g = recruitsGame(); const player = g.players.find((candidate) => candidate.id === 'at')!; g.revivalRules = newRevivalRules(); g.freeRevival = [player.id]; player.revived = 2; player.freeForcesRevived = 0; assert.equal(normalRevivalAllowance(g, player).freeRemaining, 1); g.recruits = { turn: g.turn, player: 'ch', card: 'ecaz-recruits' }; assert.equal(normalRevivalAllowance(g, player).freeRemaining, 5); }); void test('preview rejects missing, duplicate and forged physical variant cards', () => { for (const corrupt of [ (g: Game) => { g.deck = g.deck.filter((card) => card.id !== 'ecaz-reinforcements'); }, (g: Game) => { g.deck.push({ ...physical(g).find((card) => card.id === 'ecaz-harass-withdraw')! }); }, (g: Game) => { const card = physical(g).find((candidate) => candidate.id === 'ecaz-reinforcements')!; card.effect = 'recruits'; }, ]) { const g = recruitsGame(); corrupt(g); assert.throws(() => viewGame(g, 'at'), /inventory/); } }); void test('seat-control changes cannot persist a malformed variant save', () => { const g = recruitsGame(); g.deck = g.deck.filter((card) => card.id !== 'ecaz-reinforcements'); rejectUnchanged(g, 'at', { type: 'setAutopilot', difficulty: 'Medium' }); });