import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Game, type GameView, } from '../game/engine'; import { richeseCards } from '../game/richese-cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; function fixture(held = true) { const g = createGame('RESIDUALBOT', newPlayer('p', 'Pilot', 'emperor')); g.players.push( newPlayer('q', 'Opponent', 'fremen'), newPlayer('r', 'Richese', 'richese'), ); g.status = 'playing'; g.phase = 6; g.turn = 2; g.active = 'p'; g.order = ['p', 'q', 'r']; g.storm = 18; for (const p of g.players) { p.hand = []; p.forces = {}; p.spice = 10; } g.players[0].forces = { 'arrakeen:10': 4 }; g.players[0].reserves = 16; g.players[1].forces = { 'arrakeen:10': 4 }; g.players[1].reserves = 16; g.richeseCache = richeseCards(); if (held) { const card = g.richeseCache.find((c) => c.effect === 'residualPoison')!; g.richeseCache = g.richeseCache.filter((c) => c.id !== card.id); g.players[0].hand = [card]; } return applyAction(g, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'q', }); } function projection(g: Game, difficulty: Difficulty, id = 'p') { const view = viewGame(g, id); view.players.find((p) => p.id === id)!.bot = difficulty; return view; } const poison = (view: GameView) => botActions(view).find( (a) => a.type === 'card' && a.card === 'richese-residual-poison', ); const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); void test('all profiles play eligible Residual against the actual opponent before readiness, then choose leaders without a death acknowledgement', () => { for (const difficulty of DIFFICULTIES) { const g = fixture(), v = projection(g, difficulty), snapshot = structuredClone(v); assert.ok(v.battle!.preLeader); assert.equal(v.battle!.preLeader!.closed, false); const action = botActions(v)[0]; assert.equal(action.card, 'richese-residual-poison'); assert.equal(action.target, 'q'); assert.equal(action.event, v.battle!.event); assert.deepEqual(v, snapshot); const deadBefore = g.players[1].leaders.filter((l) => l.dead).length; let next = applyAction(g, 'p', action); assert.equal( next.players[1].leaders.filter((l) => l.dead).length, deadBefore + 1, ); assert.deepEqual( next.players.map((p) => p.spice), g.players.map((p) => p.spice), ); assert.equal(next.discard.filter((c) => c.id === action.card).length, 1); assert.equal(poison(projection(reload(next), difficulty)), undefined); assert.throws(() => applyAction(next, 'p', action)); const ready = botActions(projection(next, difficulty))[0]; assert.equal(ready.type, 'battlePreparationReady'); next = applyAction(next, 'p', ready); assert.deepEqual(botActions(projection(next, difficulty)), []); const otherReady = botActions(projection(next, difficulty, 'q'))[0]; assert.equal(otherReady.type, 'battlePreparationReady'); next = applyAction(reload(next), 'q', otherReady); assert.equal(viewGame(next, 'p').battle!.preLeader!.closed, true); const plan = botActions(projection(next, difficulty))[0]; assert.equal(plan.type, 'battlePlan'); assert.doesNotThrow(() => applyAction(next, 'p', plan)); } }); void test('the public readiness stage and nonholder bot behavior are independent of Residual possession', () => { for (const difficulty of DIFFICULTIES) { const held = fixture(), absent = fixture(false); const a = viewGame(held, 'q').battle!.preLeader!, b = viewGame(absent, 'q').battle!.preLeader!; assert.deepEqual( { ...a, event: 'battle-event' }, { ...b, event: 'battle-event' }, ); assert.equal(viewGame(held, 'q').residualPoison, null); assert.equal( botActions(projection(held, difficulty, 'q'))[0].type, 'battlePreparationReady', ); assert.equal( botActions(projection(absent, difficulty))[0].type, 'battlePreparationReady', ); assert.deepEqual(botActions(projection(held, difficulty, 'r')), []); } }); void test('eligibility and exact battle targeting are authoritative, with no victim-pool or hidden-hand inference', () => { for (const difficulty of DIFFICULTIES) { const g = fixture(), v = projection(g, difficulty), action = poison(v); assert.ok(action); v.players[1].hand = richeseCards(); v.players[1].leaders = []; v.players[1].spice = 999; assert.deepEqual(poison(v), action); for (const change of [ (view: GameView) => { view.residualPoison!.blocked = 'This pool is unavailable.'; }, (view: GameView) => { view.residualPoison!.target = 'r'; }, (view: GameView) => { view.residualPoison!.event = 'old-battle'; }, (view: GameView) => { view.residualPoison = null; }, ]) { const blocked = projection(g, difficulty); change(blocked); assert.equal(poison(blocked), undefined); assert.equal(botActions(blocked)[0].type, 'battlePreparationReady'); } } }); void test('real interaction priority is preserved and an open readiness stage precedes compliant plan preparation', () => { for (const difficulty of DIFFICULTIES) { const g = fixture(false), v = projection(g, difficulty); v.battle!.compliantPreparation = { actions: [{ type: 'battlePlan', dial: 0 }], blocked: null, } as unknown as NonNullable['compliantPreparation']; assert.equal(botActions(v)[0].type, 'battlePreparationReady'); const truth = projection(g, difficulty); truth.truthtrance = { stage: 'priority', queue: [], passed: [], } as unknown as NonNullable; assert.equal(botActions(truth)[0].type, 'truthPass'); const response = projection(g, difficulty); response.response = { kind: 'emperorIncome', owner: 'q', passed: [] }; assert.ok( botActions(response).every( (a) => a.type !== 'battlePreparationReady' && a.card !== 'richese-residual-poison', ), ); const decision = projection(g, difficulty); decision.decision = { kind: 'richeseDeclaration', player: 'q' }; assert.deepEqual(botActions(decision), []); } });