import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { battleChooserEvent, quoteBattleChoosers, reorderBattleChoosers, } from '../game/battle-chooser-order'; import { saphoFaceDanceGame, saphoFaceDanceCustody, } from './fixture-sapho-face-dance'; import { chooseSaphoBattleAction, nextSaphoBattleAction, resolveSaphoBattle, saphoBattleCustody, saphoBattleOrderAction, saphoBattleOrderGame, takeSaphoBattleCard, SAPHO_BATTLE_CARD, } from './fixture-sapho-battle-order'; function reject( g: Game, player: string, action: Action, reason = /Sapho|battle|chooser|opportunity|scope|scheduling/i, ) { const before = JSON.stringify(g); assert.throws(() => applyAction(g, player, action), reason); assert.equal(JSON.stringify(g), before); } function restore(g: Game) { const reloaded: Game = JSON.parse(JSON.stringify(g)); for (const p of g.players) assert.deepEqual(viewGame(reloaded, p.id), viewGame(g, p.id)); saphoBattleCustody(reloaded); return reloaded; } function choose(g: Game) { const next = chooseSaphoBattleAction(g); return applyAction(g, next.player, next.action); } const pairs = (g: Game) => viewGame(g, 'a') .battleChoices.map(({ territory, attacker, defender }) => JSON.stringify([territory, attacker, defender]), ) .sort(); void test('first and last reorder Basic/Advanced remaining choosers without changing public battle geometry or physical circles', () => { for (const advanced of [false, true]) for (const [owner, mode] of [ ['c', 'first'], ['a', 'last'], ] as const) { const g = saphoBattleOrderGame({ advanced, holder: owner }); const before = structuredClone(g), geometry = pairs(g); const next = applyAction( g, owner, saphoBattleOrderAction(g, owner, mode), ); assert.deepEqual(g, before); assert.deepEqual(next.order, g.order); assert.deepEqual(next.playerPositions, g.playerPositions); assert.deepEqual(pairs(next), geometry); assert.equal(next.active, mode === 'first' ? 'c' : 'b'); for (const p of next.players) { const old = g.players.find((other) => other.id === p.id)!; assert.deepEqual({ ...p, hand: [] }, { ...old, hand: [] }); assert.deepEqual( p.hand, old.hand.filter((c) => c.id !== SAPHO_BATTLE_CARD), ); } assert.equal( next.discard.filter((c) => c.id === SAPHO_BATTLE_CARD).length, 1, ); assert.equal(next.battleOrder!.uses.length, 1); assert.deepEqual( viewGame(next, 'r').battleOrder!.remaining, mode === 'first' ? ['c', 'a', 'b'] : ['b', 'c', 'a'], ); restore(next); } }); void test('clean between-battle Sapho discard offers Semuta before chooser acts', () => { for (const [owner, mode, nextChooser] of [ ['c', 'first', 'c'], ['a', 'last', 'b'], ] as const) { let g = saphoBattleOrderGame({ holder: owner, semutaPreview: true }); takeSaphoBattleCard(g, 'r', SEMUTA_DRUG_ID); const initialPairs = pairs(g); g = applyAction(g, owner, saphoBattleOrderAction(g, owner, mode)); const event = g.pendingTreacheryDiscard!.batch.event; assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(g.active, nextChooser); assert.equal(g.battleOrder?.uses.length, 1); assert.deepEqual(pairs(g), initialPairs); assert.equal(viewGame(g, 'r').semutaReaction?.canCommit, true); assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, false); assert.deepEqual(normalizeAutomaticGame(restore(g)), restore(g)); let declined = restore(g); for (const id of ['a', 'b', 'c', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event }); assert.equal(declined.pendingTreacheryDiscard, null); assert.equal(declined.battleOrder?.uses.length, 1); const claimed = applyAction(restore(g), 'r', { type: 'semutaCommit', event }); assert.equal(claimed.pendingTreacheryDiscard, null); assert.equal(claimed.players.find(p => p.id === 'r')!.hand.filter(c => c.id === SAPHO_BATTLE_CARD).length, 1); assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1); assert.deepEqual(claimed.battleOrder, declined.battleOrder); const chosen = choose(claimed); assert.ok(chosen.battle); assert.equal(chosen.battleOrder?.uses.length, 1); reject(claimed, 'r', { type: 'semutaCommit', event }, /fresh|available/i); } }); void test('a later physical defender chooses its opponent while original combat roles, powers and tie owner stay fixed', () => { for (const advanced of [false, true]) { let g = saphoBattleOrderGame({ advanced }); g = applyAction(g, 'c', saphoBattleOrderAction(g, 'c', 'first')); const request = chooseSaphoBattleAction(g); assert.equal(request.player, 'c'); assert.equal(request.action.target, 'a'); g = applyAction(g, request.player, request.action); assert.equal(g.battle!.chooser, 'c'); assert.equal(g.battle!.attacker, 'a'); assert.equal(g.battle!.defender, 'c'); assert.equal(viewGame(g, 'c').battle!.tieWinner, 'a'); assert.ok(g.log.at(-1)!.text.includes('chose the battle between')); assert.ok(g.log.at(-1)!.text.includes('Emperor remains the aggressor')); assert.deepEqual(viewGame(g, 'c').saphoOptions, []); assert.ok(g.battle!.preLeader && !g.battle!.preLeader.closed); // The original Atreides benefit still belongs to the actual Atreides seat. for ( let step = 0; g.battle?.preLeader && !g.battle.preLeader.closed && step < 10; step++ ) { const next = nextSaphoBattleAction(g)!; g = applyAction(g, next.player, next.action); } assert.equal(g.battle?.preparation?.owner, 'c'); assert.equal(g.battle?.preparation?.kind, 'prescience'); g = resolveSaphoBattle(restore(g)); const completed = JSON.stringify([ g.lastBattleContext!.territory, ...g.lastBattleContext!.combatants, ]); assert.ok(!pairs(g).includes(completed)); assert.equal(g.active, viewGame(g, 'a').battleOrder!.current); restore(g); } }); void test('accepted phase priority survives every remaining battle and expires only on leaving Battle', () => { for (const advanced of [false, true]) { let g = saphoBattleOrderGame({ advanced, holder: 'a' }); g = applyAction(g, 'a', saphoBattleOrderAction(g, 'a', 'last')); const phaseOrder = [...g.battleOrder!.priority], events: string[] = [], completed = new Set(); for (let count = 0; g.phase === 6 && count < 12; count++) { const before = viewGame(g, 'a').battleOrder!; assert.deepEqual(g.battleOrder!.priority, phaseOrder); assert.equal(g.active, before.current); assert.equal( before.remaining.includes('a') ? before.remaining.at(-1) : 'a', 'a', ); events.push(before.event); g = choose(g); const identity = JSON.stringify([ g.battle!.territory, g.battle!.attacker, g.battle!.defender, ]); assert.ok( !completed.has(identity), 'A completed pair cannot be reopened.', ); completed.add(identity); g = resolveSaphoBattle(restore(g)); restore(g); } assert.equal(g.phase, 7); assert.ok(completed.size >= 3); assert.equal(new Set(events).size, events.length); assert.equal(g.battleOrder, undefined); assert.equal(g.battleOrderUseEvents, undefined); assert.equal(viewGame(g, 'a').battleOrder, null); assert.deepEqual(g.order, ['a', 'b', 'c', 'r']); } }); void test('Nullentropy can recover the physical Sapho for a fresh opportunity without replaying the consumed event', () => { let g = saphoBattleOrderGame(); const box = g.richeseCache!.find((c) => c.effect === 'nullentropyBox')!; takeSaphoBattleCard(g, 'c', box.id); // A second conserved, staged discard makes the Box selection a real choice. g.discard.push(g.deck.pop()!); const old = saphoBattleOrderAction(g, 'c', 'first'); g = applyAction(g, 'c', old); const savedOrder = structuredClone(g.battleOrder); g = applyAction(g, 'c', { type: 'card', card: box.id }); assert.ok(g.pendingNullentropy); assert.deepEqual(viewGame(g, 'c').saphoOptions, []); g = applyAction(restore(g), 'c', { type: 'decision', event: g.pendingNullentropy.event, card: SAPHO_BATTLE_CARD, }); assert.deepEqual(g.battleOrder, savedOrder); assert.ok(g.players[2].hand.some((c) => c.id === SAPHO_BATTLE_CARD)); reject(g, 'c', old); const fresh = saphoBattleOrderAction(g, 'c', 'last'); assert.notEqual(fresh.event, old.event); g = applyAction(g, 'c', fresh); assert.equal(g.battleOrder!.uses.length, 2); assert.equal(g.battleOrder!.priority.at(-1), 'c'); assert.equal(g.players[2].spice, 28); assert.equal(g.discard.filter((c) => c.id === SAPHO_BATTLE_CARD).length, 1); restore(g); }); void test('a clean later boundary accepts remaining battles without reopening the already completed pair', () => { let g = saphoBattleOrderGame({ holder: 'b' }); const expired = saphoBattleOrderAction(g, 'b', 'first'); g = choose(g); const current = structuredClone(g.battle); reject(g, 'b', expired); assert.deepEqual(g.battle, current); // This human saves Sapho for the next boundary instead of taking the new AI aggressor option. g = applyAction(g, 'b', {type:'battlePreparationReady', event:g.battle!.event}); g = resolveSaphoBattle(g, true); assert.ok(g.players[1].hand.some((c) => c.id === SAPHO_BATTLE_CARD)); reject(g, 'b', expired); const completed = structuredClone(g.lastBattleContext); const mode = viewGame(g, 'b').saphoOptions.find( (o) => o.scope === 'battleOrder', )!.mode; assert.ok(mode === 'first' || mode === 'last'); g = applyAction(g, 'b', saphoBattleOrderAction(g, 'b', mode)); assert.deepEqual(g.lastBattleContext, completed); restore(g); }); void test('only a currently involved owner sees available order actions; private rival cards cannot affect eligibility', () => { const g = saphoBattleOrderGame(); const changed = structuredClone(g); takeSaphoBattleCard(changed, 'b', changed.deck[0].id); for (const id of ['a', 'b', 'c', 'r']) { assert.deepEqual( viewGame(g, id).saphoOptions, viewGame(changed, id).saphoOptions, ); assert.deepEqual( viewGame(g, id).battleOrder, viewGame(changed, id).battleOrder, ); assert.deepEqual( viewGame(g, id).battleChoices, viewGame(changed, id).battleChoices, ); } const ownerless = saphoBattleOrderGame({ holder: 'r' }); assert.deepEqual(viewGame(ownerless, 'r').saphoOptions, []); reject(ownerless, 'r', { type: 'card', card: SAPHO_BATTLE_CARD, scope: 'battleOrder', mode: 'first', event: viewGame(ownerless, 'r').battleOrder!.event, }); const action = saphoBattleOrderAction(g, 'c', 'first'); reject(g, 'a', action, /card|hand|Sapho|opportunity/i); reject(g, 'c', { ...action, extra: true }); reject(g, 'c', { ...action, mode: 'aggressor' }); }); void test('malformed order, markers, physical custody and selected chooser reject every read and continuation immutably', () => { let g = saphoBattleOrderGame(); g = applyAction(g, 'c', saphoBattleOrderAction(g, 'c', 'first')); const bad: Game[] = []; const mutate = (change: (state: Game) => void) => { const state = structuredClone(g); change(state); bad.push(state); }; mutate((s) => { s.battleOrder!.priority.reverse(); }); mutate((s) => { delete s.battleOrder; }); mutate((s) => { delete s.battleOrderUseEvents; }); mutate((s) => { s.battleOrder!.uses[0].eligible.push('foreign'); }); mutate((s) => { s.battleOrder!.uses[0].event = 'expired'; }); mutate((s) => { s.turn++; }); mutate((s) => { s.phase = 7; }); mutate((s) => { s.active = 'b'; }); mutate((s) => { s.discard.push({ ...s.discard.find((c) => c.id === SAPHO_BATTLE_CARD)! }); }); g = choose(g); mutate((s) => { delete s.battle!.chooser; }); mutate((s) => { s.battle!.chooser = 'a'; }); mutate((s) => { s.battle!.chooserEvent = 'expired'; }); for (const state of bad) { const before = JSON.stringify(state); for (const p of state.players) assert.throws(() => viewGame(state, p.id)); assert.throws(() => normalizeAutomaticGame(state)); reject( state, 'c', { type: 'setAutopilot', difficulty: 'Easy' }, /Sapho|battle|chooser|opportunity|scheduling|physical/i, ); reject( state, 'c', { type: 'advanceBots' }, /Sapho|battle|chooser|opportunity|scheduling|physical/i, ); assert.equal(JSON.stringify(state), before); } }); void test('full phase priority keeps a last holder behind a newly battle-relevant Face Dancer seat', () => { const physicalOrder = ['a', 'b', 't']; const state = reorderBattleChoosers({ physicalOrder, turn: 2, afterBattle: null, pairs: [{ territory: 'arrakeen', attacker: 'a', defender: 'b' }], event: battleChooserEvent(2, null, 0), player: 'a', mode: 'last', }); assert.deepEqual(state.priority, ['b', 't', 'a']); const afterFaceDance = quoteBattleChoosers( [{ territory: 'arrakeen', attacker: 'a', defender: 't' }], physicalOrder, state.priority, ); assert.equal(afterFaceDance.current, 't'); assert.deepEqual(afterFaceDance.choices[0], { territory: 'arrakeen', attacker: 'a', defender: 't', chooser: 't', }); assert.deepEqual(quoteBattleChoosers([], physicalOrder, state.priority), { choices: [], remaining: [], current: null, }); }); void test('actual Basic/Advanced Face Dance brings a new chooser ahead of the surviving Sapho-last bystander', () => { for (const advanced of [false, true]) { const fixture = saphoFaceDanceGame(advanced); let g = fixture.game; g = applyAction(g, 'a', saphoBattleOrderAction(g, 'a', 'last')); assert.equal(g.active, 'b'); assert.ok(!viewGame(g, 'a').battleOrder!.remaining.includes('t')); g = applyAction(g, 'b', { type: 'chooseBattle', territory: 'arrakeen', target: 'c', }); for (let step = 0; g.decision?.kind !== 'faceDance' && step < 60; step++) { const b = g.battle; if ( b && !g.response && !g.decision && !b.preparation && (!b.preLeader || b.preLeader.closed) ) { const unsealed = [b.attacker, b.defender].find((id) => !b.plans[id]); if (unsealed) { g = applyAction(g, unsealed, { type: 'battlePlan', dial: 0, support: 0, leader: unsealed === 'b' ? fixture.winnerLeader : fixture.loserLeader, }); continue; } const voter = [b.attacker, b.defender].find( (id) => b.traitorCalls[id] === undefined, ); if (voter) { g = applyAction(g, voter, { type: 'traitorCall', call: false }); continue; } } const next = nextSaphoBattleAction(g); assert.ok(next); g = applyAction(g, next.player, next.action); } assert.equal(g.decision?.kind, 'faceDance'); assert.equal(g.lastBattleContext!.winner, 'b'); assert.equal(viewGame(g, 'a').battleOrder!.current, null); assert.deepEqual(viewGame(g, 'a').saphoOptions, []); const event = g.lastBattleContext!.event, priority = [...g.battleOrder!.priority]; for (const p of g.players) assert.deepEqual( viewGame(JSON.parse(JSON.stringify(g)), p.id), viewGame(g, p.id), ); g = applyAction(JSON.parse(JSON.stringify(g)), 't', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 10, }); assert.equal(g.players[0].forces['arrakeen:10'], 3); assert.equal(g.players[1].reserves, 20); assert.equal(g.players[4].forces['arrakeen:10'], 1); assert.equal(g.players[4].reserves, 19); assert.equal(g.active, 't'); assert.deepEqual(g.battleOrder!.priority, priority); assert.equal(g.lastBattleContext!.event, event); assert.deepEqual(viewGame(g, 'a').battleOrder!.remaining, ['t', 'a']); const next = viewGame(g, 't').battleChoices.find( (choice) => choice.chooser === 't', )!; assert.deepEqual(next, { territory: 'arrakeen', attacker: 'a', defender: 't', chooser: 't', }); saphoFaceDanceCustody(g); for (const p of g.players) assert.deepEqual( viewGame(JSON.parse(JSON.stringify(g)), p.id), viewGame(g, p.id), ); g = applyAction(g, 't', { type: 'chooseBattle', territory: 'arrakeen', target: 'a', }); assert.equal(g.battle!.chooser, 't'); assert.equal(g.battle!.attacker, 'a'); assert.equal(g.battle!.defender, 't'); } });