import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import type { FactionId } from '../game/catalog'; import { forceRevivalQuote, newRevivalRules } from '../game/revival'; const sum = (counts: Record) => Object.values(counts).reduce((a, b) => a + b, 0); const p = (g: Game, id: string) => g.players.find((player) => player.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); function setup(roster: FactionId[] = ['fremen', 'emperor'], advanced = false) { let g = createGame( 'HOMEREVIVALBENEFITS', newPlayer(roster[0], roster[0], roster[0]), advanced, roster.some((id) => id === 'tleilaxu' || id === 'ixians') ? ['ix'] : [], ); for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id)); g = applyAction(g, g.host, { type: 'homeworlds', enabled: true }); for (const id of roster) g = applyAction(g, id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); for (let i = 0; g.status === 'setup' && i < 30; i++) { let moved = false; for (const player of g.players) { const view = viewGame(g, player.id); view.players.find((v) => v.id === player.id)!.bot = 'Easy'; const action = botActions(view)[0]; if (!action) continue; g = applyAction(g, player.id, action); moved = true; break; } assert.ok(moved); } assert.equal(g.status, 'playing'); // Precisely stage a later Charity phase. Physical setup cards and counters // remain in their real zones; later positions below conserve every counter. Object.assign(g, { phase: 2, turn: 2, storm: 18, active: null, ready: [], order: roster, phaseOpening: null, }); for (const player of g.players) { g.deck.push(...player.hand); player.hand = []; } return g; } function position(g: Game, id: string, reserves: number, tanks: number) { const player = p(g, id); const board = 20 - reserves - tanks; assert.ok(board >= 0); Object.assign(player, { reserves, tanks, forces: board ? { 'polar_sink:0': board } : {}, }); if (player.elites) { const total = player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7; // These fixtures put only ordinary forces in Tanks. Remaining stars keep // their physical identities in native reserves or on the neutral board. const eliteReserves = Math.min(total, reserves); const eliteBoard = total - eliteReserves; assert.ok(eliteBoard <= board); player.elites = { reserves: eliteReserves, tanks: 0, forces: eliteBoard ? { 'polar_sink:0': eliteBoard } : {}, revived: 0, }; } } function inventory(g: Game) { for (const player of g.players) { assert.equal(player.reserves + player.tanks + sum(player.forces), 20); if (player.elites) assert.equal( player.elites.reserves + player.elites.tanks + sum(player.elites.forces), player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7, ); assert.ok(player.spice >= 0); } const cards = [ ...g.deck, ...g.discard, ...g.players.flatMap((player) => player.hand), ...(g.auction?.cards.slice(g.auction.index) ?? []), ]; assert.equal(new Set(cards.map((card) => card.id)).size, cards.length); const before = reload(g); assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), before); } /** Genuine Charity last-ready, real unsold auction lots, and public Amal passes. */ function enterRevival(state: Game) { let g = state; for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' }); for (let i = 0; (g.phase === 3 || g.phaseOpening) && i < 100; i++) { if (g.phaseOpening) { const id = g.players.find( (player) => !g.phaseOpening!.passed.includes(player.id), )!.id; g = applyAction(g, id, { type: 'ready' }); } else { assert.equal(g.phase, 3); assert.ok(g.auction); g = applyAction(g, g.auction.active!, { type: 'passBid' }); } } assert.equal(g.phase, 4); assert.equal(g.phaseOpening ?? null, null); inventory(g); return g; } function rejected(g: Game, id: string, action: Action) { const before = structuredClone(g); assert.throws(() => applyAction(g, id, action)); assert.deepEqual(g, before); } function lowFremen(withTleilaxu = false) { const g = setup(withTleilaxu ? ['fremen', 'emperor', 'tleilaxu'] : undefined); position(g, 'fremen', 2, 6); if (withTleilaxu) position(g, 'tleilaxu', 12, 0); return enterRevival(g); } function hold(g: Game, owner: string, effect: string) { const index = g.deck.findIndex((card) => card.effect === effect); assert.ok( index >= 0, `Unheld ${effect} must exist in the real shuffled deck.`, ); const [card] = g.deck.splice(index, 1); p(g, owner).hand.push(card); return card.id; } void test('one low Fremen group returns four free without a Tleilaxu expansion-of-limit response', () => { for (const tleilaxu of [false, true]) { const g = lowFremen(tleilaxu); const view = viewGame(g, 'fremen'); assert.equal(view.revival.freeRemaining, 4); assert.equal(view.revival.forcesRemaining, 4); assert.equal(view.revival.limit, 4); const quote = forceRevivalQuote(g, p(g, 'fremen'), 4, 0); assert.equal(quote.cost, 0); assert.equal(quote.free, 4); const beforeSpice = p(g, 'fremen').spice; const done = applyAction(g, 'fremen', { type: 'revive', amount: 4, elite: 0, }); assert.equal(done.pendingRevival ?? null, null); assert.equal(done.response, null); assert.equal(p(done, 'fremen').reserves, 6); assert.equal(p(done, 'fremen').tanks, 2); assert.equal(p(done, 'fremen').spice, beforeSpice); assert.equal(p(done, 'fremen').revived, 4); assert.equal(p(done, 'fremen').freeForcesRevived, 4); assert.equal(viewGame(done, 'fremen').revival.forcesRemaining, 0); assert.equal( done.log.some((entry) => /expanded.*revival|limit.*five/i.test(entry.text), ), false, ); inventory(done); } }); void test('later separate Fremen requests recompute a crossed native threshold instead of retaining a phase-wide fourth free return', () => { let g = lowFremen(); g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 }); assert.equal(p(g, 'fremen').reserves, 3); assert.equal(viewGame(g, 'fremen').revival.freeRemaining, 2); assert.equal(viewGame(g, 'fremen').revival.limit, 3); rejected(g, 'fremen', { type: 'revive', amount: 3, elite: 0 }); g = applyAction(reload(g), 'fremen', { type: 'revive', amount: 2, elite: 0 }); assert.equal(p(g, 'fremen').revived, 3); assert.equal(p(g, 'fremen').tanks, 3); rejected(g, 'fremen', { type: 'revive', amount: 1, elite: 0 }); inventory(g); }); void test('Fremen alliance supplies three, with a fourth coming only from the recipient own low Homeworld', () => { for (const recipientLow of [false, true]) for (const donorLow of [false, true]) { let g = setup(); position(g, 'fremen', donorLow ? 2 : 10, 0); position(g, 'emperor', recipientLow ? 4 : 10, 5); p(g, 'fremen').ally = 'emperor'; p(g, 'emperor').ally = 'fremen'; p(g, 'fremen').allySinceTurn = 1; p(g, 'emperor').allySinceTurn = 1; g = enterRevival(g); g = applyAction(g, 'fremen', { type: 'grantRevival' }); const amount = recipientLow ? 4 : 3; assert.equal(viewGame(g, 'emperor').revival.freeRemaining, amount); assert.equal(viewGame(g, 'emperor').revival.limit, amount); const before = p(g, 'emperor').spice; if (!recipientLow) rejected(g, 'emperor', { type: 'revive', amount: 4, elite: 0 }); g = applyAction(g, 'emperor', { type: 'revive', amount, elite: 0 }); assert.equal(p(g, 'emperor').spice, before); assert.equal(p(g, 'emperor').freeForcesRevived, amount); inventory(g); } }); void test('La La La saved free-block rule suppresses the whole low rate, with no claim of a live unsupported CHOAM deck path', () => { let g = setup(); position(g, 'fremen', 2, 5); position(g, 'emperor', 4, 5); g = enterRevival(g); // Explicit existing rule-context fixture: CHOAM's complete deck/setup remains // gated, so this does not fabricate a live Worthless declaration or receipt. g.revivalRules = { ...newRevivalRules(), freeBlocked: ['fremen', 'emperor'] }; assert.equal(viewGame(g, 'fremen').revival.freeRemaining, 0); assert.equal(viewGame(g, 'emperor').revival.freeRemaining, 0); rejected(g, 'fremen', { type: 'revive', amount: 1, elite: 0 }); const before = p(g, 'emperor').spice; const done = applyAction(g, 'emperor', { type: 'revive', amount: 2, elite: 0, }); assert.equal(p(done, 'emperor').spice, before - 4); assert.equal(p(done, 'emperor').freeForcesRevived, 0); inventory(done); }); void test('all four AI policies propose legal current low revival quantities without reading another player private cards', () => { const g = lowFremen(); for (const difficulty of DIFFICULTIES) { const view = viewGame(g, 'fremen'); view.players.find((player) => player.id === 'fremen')!.bot = difficulty; assert.ok( view.players .filter((player) => player.id !== 'fremen') .every((player) => player.hand === undefined), ); const before = structuredClone(view); const candidates = botActions(view); assert.deepEqual(view, before); const revivals = candidates.filter((action) => action.type === 'revive'); assert.ok(revivals.length, `${difficulty} must expose some revival policy`); for (const action of revivals) { assert.ok(Number(action.amount) <= 4); const quote = forceRevivalQuote( g, p(g, 'fremen'), Number(action.amount), Number(action.elite ?? 0), ); assert.equal(quote.cost, 0); assert.doesNotThrow(() => applyAction(g, 'fremen', action)); } } }); void test('actual Bidding exit snapshots low Tleilax; later crossing high suppresses others free income but preserves own, paid and Ghola income', () => { let g = setup(['tleilaxu', 'fremen', 'emperor']); position(g, 'tleilaxu', 7, 3); position(g, 'fremen', 2, 6); position(g, 'emperor', 12, 5); g = enterRevival(g); assert.deepEqual(g.homeworldRevival, { turn: 2, tleilaxu: { player: 'tleilaxu', low: true }, }); const initial = p(g, 'tleilaxu').spice; const before = structuredClone(g); // The crossing return now qualifies under the provisional post-return // population policy: it stages its own redeployment choice instead of // blocking, while the source state stays untouched. const crossing = applyAction(reload(g), 'tleilaxu', { type: 'revive', amount: 2, elite: 0 }); assert.equal(crossing.homeworldRevivalReturn!.stage, 'choice'); assert.equal(viewGame(crossing, 'tleilaxu').homeworldRevivalDeployment?.player, 'tleilaxu'); assert.deepEqual(g, before); g = applyAction(g, 'tleilaxu', { type: 'revive', amount: 1, elite: 0 }); assert.equal(p(g, 'tleilaxu').reserves, 8); assert.equal( p(g, 'tleilaxu').spice, initial + 1, 'The low card does not suppress its owner free-revival income.', ); // A later physical return from the board changes the current native side. // This narrow position seam preserves all twenty counters and the actual // phase-opening income receipt without inventing a threshold-crossing revival. position(g, 'tleilaxu', 9, p(g, 'tleilaxu').tanks); assert.equal(p(g, 'tleilaxu').reserves, 9); assert.equal( viewGame(g, 'tleilaxu').homeworlds!.worlds!.find( (world) => world.native === 'tleilaxu', )!.side, 'high', ); g = applyAction(reload(g), 'fremen', { type: 'revive', amount: 4, elite: 0 }); assert.equal(p(g, 'tleilaxu').spice, initial + 1); assert.equal(g.revivalFreeIncome!.fremen, 2); g = applyAction(g, 'emperor', { type: 'revive', amount: 3, elite: 0 }); assert.equal( p(g, 'tleilaxu').spice, initial + 5, 'Four paid spice still reach Tleilaxu; the free counter gives no bank bonus.', ); const card = hold(g, 'emperor', 'ghola'); g = applyAction(g, 'emperor', { type: 'card', card, amount: 2, elite: 0 }); assert.equal( p(g, 'tleilaxu').spice, initial + 6, 'Ghola remains its separately named income event.', ); assert.deepEqual(g.homeworldRevival, { turn: 2, tleilaxu: { player: 'tleilaxu', low: true }, }); inventory(g); }); void test('actual high Tleilax phase opening permits one ordinary free-income payment per other faction and does not recreate a consumed receipt', () => { let g = setup(['tleilaxu', 'emperor']); position(g, 'tleilaxu', 9, 0); position(g, 'emperor', 4, 5); g = enterRevival(g); assert.equal(g.homeworldRevival!.tleilaxu.low, false); const before = p(g, 'tleilaxu').spice; g = applyAction(g, 'emperor', { type: 'revive', amount: 1, elite: 0 }); assert.equal(p(g, 'tleilaxu').spice, before + 1); // Emperor crosses to high after the first return; its second return is now // paid. This tests both dynamic rate and the independent income receipt. g = applyAction(reload(g), 'emperor', { type: 'revive', amount: 1, elite: 0, }); assert.equal(p(g, 'tleilaxu').spice, before + 3); assert.equal(g.revivalFreeIncome!.emperor, 2); inventory(g); }); void test('controlled Low Homeworld keeps its rate while human and minimal policies can prevent the whole return', () => { let g = setup(['fremen', 'tleilaxu', 'atreides'], true); position(g, 'fremen', 2, 12); position(g, 'tleilaxu', 12, 0); // Controlled later army/card allocation; the separate native fixture earns // its actual Low population and Tanks through setup/shipment/Storm losses. g = enterRevival(g); const card = hold(g, 'tleilaxu', 'karama'); g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 }); assert.equal(g.decision?.kind, 'revivalStop'); const before = reload(g); const offer = viewGame(g, 'tleilaxu').tleilaxuSpecialKarama!; assert.equal(offer.blocked, null); assert.deepEqual(offer.targets, [{ player: 'fremen', blocked: null }]); assert.equal(viewGame(g, 'tleilaxu').revival.specialKaramaBlock, null); for (const difficulty of DIFFICULTIES) { const view = viewGame(reload(g), 'tleilaxu'); view.players.find((player) => player.id === 'tleilaxu')!.bot = difficulty; const action = botActions(view)[0]; assert.deepEqual(action, { type: 'card', card, mode: 'special', target: 'fremen' }); const stopped = applyAction(reload(g), 'tleilaxu', action); assert.equal(stopped.pendingRevival ?? null, null); assert.equal(p(stopped, 'fremen').reserves, 2); assert.equal(p(stopped, 'fremen').tanks, 12); assert.equal(p(stopped, 'fremen').revived, 0); assert.equal(p(stopped, 'fremen').freeForcesRevived, 0); assert.equal(p(stopped, 'tleilaxu').specialKaramaUsed, true); assert.equal(p(stopped, 'tleilaxu').spice, p(before, 'tleilaxu').spice); assert.ok(!p(stopped, 'tleilaxu').hand.some(held => held.id === card)); assert.equal(stopped.discard.filter(held => held.id === card).length, 1); const recipient = viewGame(stopped, 'fremen'); assert.equal(recipient.revival.prevented, true); assert.equal(recipient.revival.freeRemaining, 4); rejected(stopped, 'fremen', { type: 'revive', amount: 1, elite: 0 }); inventory(stopped); } const allowed = applyAction(reload(g), 'tleilaxu', { type: 'decision', decline: true }); assert.equal(p(allowed, 'fremen').reserves, 3); assert.equal(p(allowed, 'fremen').tanks, 11); assert.ok(p(allowed, 'tleilaxu').hand.some(held => held.id === card)); assert.deepEqual(g, before); });