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 { baseDeck } from '../game/cards'; import { homeworldContext, homeworldGameIntegrity, } from '../game/homeworld-game'; import { homeworldForceGroups, quoteHomeworldCustody, type HomeworldCustodyChange, } from '../game/homeworld-custody'; const seat = (g: Game, id: string) => g.players.find((player) => player.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); const sum = (counts: Record) => Object.values(counts).reduce((a, b) => a + b, 0); function inventory(g: Game, supplementalCards = 0) { homeworldGameIntegrity(g); const groups = homeworldForceGroups( homeworldContext(g), g.homeworlds!.custody!, ); for (const player of g.players) { const visitors = groups .filter((home) => home.native !== player.id) .map((home) => home.forces[player.id] ?? { normal: 0, elite: 0 }); assert.equal( player.reserves + player.tanks + sum(player.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20, ); if (player.elites) assert.equal( player.elites.reserves + player.elites.tanks + sum(player.elites.forces) + visitors.reduce((n, pool) => n + pool.elite, 0), player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7, ); } const cards = [ ...g.deck, ...g.discard, ...g.players.flatMap((player) => player.hand), ]; assert.equal(cards.length, baseDeck().length + supplementalCards); assert.equal(new Set(cards.map((card) => card.id)).size, cards.length); } function setup(roster: FactionId[], advanced = false) { let g = createGame( 'HOMEWORLDBATTLEENGINE', newPlayer(roster[0], roster[0], roster[0]), advanced, ); for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); g = applyAction(g, g.host, { type: 'homeworlds', enabled: true }); for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); for (let step = 0; g.status === 'setup' && step < 30; step++) { let next: Game | undefined; for (const player of g.players) { const view = viewGame(g, player.id); view.players.find((owner) => owner.id === player.id)!.bot = 'Easy'; const action = botActions(view)[0]; if (action) { next = applyAction(g, player.id, action); break; } } assert.ok(next, 'Genuine audit setup must have a legal decision.'); g = next; } assert.equal(g.status, 'playing'); for (const player of g.players) g.deck.push(...player.hand.splice(0)); Object.assign(g, { phase: 6, phaseOpening: null, response: null, decision: null, active: roster[0], order: roster, ready: [], storm: 18, }); inventory(g); return g; } function custody(g: Game, changes: HomeworldCustodyChange[]) { const quote = quoteHomeworldCustody( homeworldContext(g), g.homeworlds!.custody!, changes, ); g.homeworlds!.custody = quote.state; for (const update of quote.players) { const player = seat(g, update.id); player.reserves = update.reserves; if (player.elites) player.elites.reserves = update.eliteReserves; } inventory(g); } function invade( g: Game, player: string, destination: string, normal: number, elite = 0, ) { // Explicit conserved invasion position, not a claim that invasion shipment // actions are open: withdraw the exact native pieces before depositing them. custody(g, [ { homeworld: `homeworld:${seat(g, player).faction}`, player, withdraw: { normal, elite }, deposit: { normal: 0, elite: 0 }, }, { homeworld: destination, player, withdraw: { normal: 0, elite: 0 }, deposit: { normal, elite }, }, ]); } function hold(g: Game, player: string, kind: string) { const index = g.deck.findIndex((card) => card.kind === kind); assert.ok(index >= 0); const [card] = g.deck.splice(index, 1); seat(g, player).hand.push(card); return card.id; } function prepared(state: Game, actor: string, target: string, home: string) { let g = applyAction(state, actor, { type: 'chooseBattle', territory: home, target, }); for ( let step = 0; (g.response || g.battle?.preparation || g.decision) && step < 30; step++ ) { if (g.response) { const owner = g.players.find( (player) => !g.response!.passed.includes(player.id), )!; g = applyAction(g, owner.id, { type: 'passResponse' }); } else if (g.battle?.preparation) g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower', }); else { assert.equal(g.decision!.kind, 'fullPlanOffer'); g = applyAction(g, g.decision!.player, { type: 'decision', decline: true, }); } } assert.equal(g.response, null); assert.equal(g.decision, null); assert.equal(g.battle!.territory, home); assert.equal(g.battle!.preparation ?? null, null); inventory(g); return g; } function commit( g: Game, player: string, dial = 0, extras: Partial = {}, ) { const leader = seat(g, player).leaders.find((leader) => leader.strength === 1) ?? seat(g, player).leaders[0]; return applyAction(g, player, { type: 'battlePlan', dial, support: 0, leader: leader.id, ...extras, }); } function reject(g: Game, player: string, action: Action) { const before = structuredClone(g); assert.throws(() => applyAction(g, player, action)); assert.deepEqual(g, before); } function passAll(state: Game) { let g = state; for (let n = 0; g.response && n < 20; n++) { const player = g.players.find((p) => !g.response!.passed.includes(p.id))!; g = applyAction(reload(g), player.id, { type: 'passResponse' }); } assert.equal(g.response, null); return g; } function strongest(g: Game, id: string) { return seat(g, id) .leaders.slice() .sort((a, b) => b.strength - a.strength)[0].id; } function actualIx(native: boolean, advanced: boolean) { let g = setup(['ixians', 'atreides'], advanced); const home = native ? 'homeworld:ixians' : 'homeworld:atreides'; if (native) invade(g, 'atreides', home, 2); else invade(g, 'ixians', home, 6, 2); const shield = hold(g, 'ixians', 'shield'); const index = g.deck.findIndex((card) => card.effect === 'karama'); assert.ok(index >= 0); const [card] = g.deck.splice(index, 1); seat(g, 'atreides').hand.push(card); seat(g, 'ixians').spice = 10; g = prepared(g, 'ixians', 'atreides', home); g = commit(g, 'ixians', 6, { leader: strongest(g, 'ixians'), support: advanced ? 2 : 0, defense: shield, }); g = commit(reload(g), 'atreides'); const voter = native ? 'ixians' : 'atreides'; g = applyAction(g, voter, { type: 'traitorCall', call: false }); assert.equal(g.lastBattleContext?.winner, 'ixians'); if (g.decision?.kind === 'battleLosses') { const choice = g.decision.options.findIndex( (loss) => loss.normal === 4 && loss.elite === 2, ); assert.ok( choice >= 0, 'Actual dial six must allow four Suboids plus two Cyborgs.', ); g = applyAction(reload(g), 'ixians', { type: 'decision', choice }); } assert.equal(g.decision?.kind, 'ixSubstitution'); assert.equal(g.pendingIxSubstitution!.homeworld!.cyborgsLost, 2); inventory(g); return { g, home, shield, karama: card.id }; } for (const native of [true, false]) for (const advanced of [true, false]) { void test(`actual ${advanced ? 'Advanced' : 'Basic'} ${native ? 'native' : 'visitor'} Homeworld Ixian substitution survives declaration and cleanup reload`, () => { const f = actualIx(native, advanced); const original = reload(f.g); const before = seat(original, 'ixians'); for (const level of DIFFICULTIES) { const view = viewGame(reload(original), 'ixians'); view.players.find((p) => p.id === 'ixians')!.bot = level; const actions = botActions(view); assert.ok( actions.length, `${level} must supply an Ixian substitution choice.`, ); for (const action of actions) inventory(applyAction(reload(original), 'ixians', action)); } reject(original, 'ixians', { type: 'decision', sources: { [`${f.home}:0`]: 1 }, recover: { [f.home]: 1 }, }); let g = applyAction(reload(original), 'ixians', { type: 'decision', sources: { [f.home]: 2 }, recover: { [f.home]: 2 }, }); assert.equal(g.response?.kind, 'ixSubstitution'); g = passAll(reload(g)); assert.equal(g.pendingIxSubstitution, null); const after = seat(g, 'ixians'); assert.equal(after.tanks, before.tanks); assert.equal(after.battleLosses, before.battleLosses); assert.equal(after.elites!.tanks, before.elites!.tanks - 2); assert.equal( after.elites!.reserves, before.elites!.reserves + (native ? 2 : 0), ); assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.elites!.forces, before.elites!.forces); if (!native) assert.deepEqual(g.homeworlds!.custody!.visitors[f.home].ixians, { normal: 0, elite: 2, }); assert.equal(g.decision?.kind, 'battleCards'); g = applyAction(reload(g), 'ixians', { type: 'decision', discard: [] }); assert.equal( seat(g, 'ixians').hand.filter((card) => card.id === f.shield).length, 1, ); inventory(g); assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g)); }); } for (const native of [true, false]) { void test(`actual ${native ? 'native' : 'visitor'} Homeworld Karama denies Ixian substitution without repeating casualties or payment`, () => { const f = actualIx(native, true); let g = applyAction(reload(f.g), 'ixians', { type: 'decision', sources: { [f.home]: 1 }, recover: { [f.home]: 1 }, }); const before = g.players.map((p) => ({ reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites), losses: p.battleLosses, })); const custodyBefore = structuredClone(g.homeworlds!.custody); g = applyAction(reload(g), 'atreides', { type: 'card', card: f.karama, mode: 'cancel', }); g = passAll(reload(g)); assert.deepEqual(g.homeworlds!.custody, custodyBefore); assert.deepEqual( g.players.map((p) => ({ reserves: p.reserves, tanks: p.tanks, elites: p.elites, losses: p.battleLosses, })), before, ); assert.equal(g.pendingIxSubstitution, null); assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1); assert.equal(g.decision?.kind, 'battleCards'); reject(g, 'atreides', { type: 'card', card: f.karama, mode: 'cancel' }); inventory(g); }); } function actualFaceDance() { let g = setup(['tleilaxu', 'atreides', 'guild']); g.order = ['atreides', 'guild', 'tleilaxu']; g.active = 'atreides'; const home = 'homeworld:tleilaxu'; invade(g, 'atreides', home, 4); invade(g, 'guild', home, 2); const native = seat(g, 'tleilaxu'); // A conserved earlier shipment supplies real external replacement counters. native.reserves -= 3; native.forces['polar_sink:0'] = 3; const leader = strongest(g, 'atreides'); const dancer = native.faceDancers![0]; if (!native.faceDancers!.some((card) => card.leader === leader)) { const reserve = g.traitorReserve!.indexOf(leader); if (reserve >= 0) g.traitorReserve![reserve] = dancer.leader; else { const owner = g.players.find((p) => p.traitors.includes(leader))!; assert.ok( owner, 'The matching physical traitor card must exist in a setup zone.', ); owner.traitors[owner.traitors.indexOf(leader)] = dancer.leader; } dancer.leader = leader; } inventory(g); g = prepared(g, 'atreides', 'guild', home); assert.deepEqual(viewGame(g, 'tleilaxu').battle!.traitorVoters, []); g = commit(g, 'atreides', 1, { leader }); g = commit(reload(g), 'guild'); assert.equal(g.lastBattleContext!.winner, 'atreides'); // This fixture tests Face Dance after the winner explicitly declines its // Caladan option, keeping the original replacement-only baseline. assert.equal(g.decision?.kind, 'caladanReinforcement'); assert.equal(viewGame(g, 'atreides').caladanReinforcement!.blocked, null); const beforeReinforcement = structuredClone(g.players); g = applyAction(reload(g), 'atreides', { type: 'decision', event: g.homeworldVictoryReinforcement!.event, decline: true, }); assert.deepEqual(g.players, beforeReinforcement); assert.equal(g.decision?.kind, 'faceDance'); assert.equal(g.decision!.player, 'tleilaxu'); inventory(g); return { g, home, leader }; } void test('native noncombatant Tleilaxu Face Dance returns a foreign winner and adds only selected Arrakis replacements', () => { const f = actualFaceDance(); const before = reload(f.g); for (const level of DIFFICULTIES) { const view = viewGame(reload(before), 'tleilaxu'); view.players.find((p) => p.id === 'tleilaxu')!.bot = level; const snapshot = structuredClone(view); const actions = botActions(view); assert.ok( actions.length, `${level} must supply the native Face Dance choice.`, ); for (const action of actions) inventory(applyAction(reload(before), 'tleilaxu', action)); assert.deepEqual(view, snapshot); } assert.equal( viewGame(before, 'atreides').players.find((p) => p.id === 'tleilaxu')! .faceDancers, undefined, ); const nativeReserves = seat(before, 'tleilaxu').reserves; const winnerReserves = seat(before, 'atreides').reserves; const winnerSpice = seat(before, 'atreides').spice; reject(before, 'guild', { type: 'decision', reveal: true, sources: {} }); reject(before, 'tleilaxu', { type: 'decision', reveal: true, sources: { [f.home]: 1 }, }); reject(before, 'tleilaxu', { type: 'decision', reveal: true, sources: { 'polar_sink:0': 4 }, }); const g = applyAction(reload(before), 'tleilaxu', { type: 'decision', reveal: true, sources: { 'polar_sink:0': 2, reserves: 1 }, }); assert.equal(seat(g, 'tleilaxu').reserves, nativeReserves + 2); assert.equal(seat(g, 'tleilaxu').forces['polar_sink:0'], 1); assert.equal(seat(g, 'atreides').reserves, winnerReserves + 3); assert.equal(seat(g, 'atreides').spice, winnerSpice); assert.equal(g.homeworlds!.custody!.visitors[f.home], undefined); assert.equal(g.lastBattleContext!.winner, 'atreides'); assert.equal( seat(g, 'atreides').leaders.find((leader) => leader.id === f.leader)!.dead, true, ); assert.equal( seat(g, 'tleilaxu').faceDancers!.find((card) => card.leader === f.leader)! .revealed, true, ); assert.deepEqual(seat(g, 'guild').forces, seat(before, 'guild').forces); inventory(g); assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g)); }); void test('native Face Dance permits zero replacement and does not manufacture forces from its already present reserves', () => { const f = actualFaceDance(); for (const sources of [{}, { reserves: 3 }]) { const g = applyAction(reload(f.g), 'tleilaxu', { type: 'decision', reveal: true, sources, }); assert.equal(seat(g, 'tleilaxu').reserves, seat(f.g, 'tleilaxu').reserves); assert.deepEqual(seat(g, 'tleilaxu').forces, seat(f.g, 'tleilaxu').forces); assert.equal( seat(g, 'atreides').reserves, seat(f.g, 'atreides').reserves + 3, ); inventory(g); } }); void test('saved Homeworld substitution rejects changed survivors, Tanks and battle identity before a Karama discard', () => { const f = actualIx(false, true); const declared = applyAction(reload(f.g), 'ixians', { type: 'decision', sources: { [f.home]: 1 }, recover: { [f.home]: 1 }, }); const mutations: ((g: Game) => void)[] = [ (g) => { g.pendingIxSubstitution!.homeworld!.pool.normal++; }, (g) => { g.pendingIxSubstitution!.homeworld!.eliteTanks--; }, (g) => { g.pendingIxSubstitution!.homeworld!.normalTanks++; }, (g) => { g.pendingIxSubstitution!.homeworld!.battleLosses++; }, (g) => { g.lastBattleContext!.winner = 'atreides'; }, (g) => { g.pendingIxSubstitution!.recover = { [`${f.home}:0`]: 1 }; }, ]; for (const mutate of mutations) { const g = reload(declared); mutate(g); reject(g, 'atreides', { type: 'card', card: f.karama, mode: 'cancel' }); assert.equal( seat(g, 'atreides').hand.filter((card) => card.id === f.karama).length, 1, ); } });