import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, handLimit, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; import { createSemutaHomeworldBattleSaphoFixture, disposeSemutaHomeworldBattleSapho, finishSemutaHomeworldBattleSapho, } from './fixture-semuta-homeworld-battle-sapho'; const combos = [ { scope: 'aggressor', mode: 'first' }, { scope: 'order', mode: 'first' }, { scope: 'order', mode: 'last' }, ] as const; /** Only original force, wallet, leader and tank custody — never the committed * Sapho card, its discard or the scheduling receipt, which the action changes. */ function custody(game: Game) { return game.players.map(seat => ({ id: seat.id, spice: seat.spice, reserves: seat.reserves, tanks: seat.tanks, forces: structuredClone(seat.forces), elites: structuredClone(seat.elites), leaders: structuredClone(seat.leaders) })); } function offerEvent(game: Game): string { const pending = game.pendingTreacheryDiscard; assert.ok(pending, 'A fresh public disposal offer must be open'); assert.equal(pending.reaction?.stage, 'offer'); return pending.batch.event; } function passOffer(state: Game): Game { let game = state; if (!game.pendingTreacheryDiscard) return game; const event = offerEvent(game); for (const seat of state.players) { if (!game.pendingTreacheryDiscard) break; const reaction = game.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer'); if (!reaction.passed.includes(seat.id)) game = applyAction(game, seat.id, { type: 'semutaPass', event }); } return game; } for (const advanced of [false, true]) for (const combo of combos) { const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${combo.scope} ${combo.mode}`; void test(`${label}: actual paid prefix disposes the exact card and commits its real effect`, () => { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); for (const shipment of f.shipments) { const amount = shipment.actor === f.choam ? 4 : 1; const price = shipment.actor === f.holder ? 1 : 2 * amount; const destination = shipment.actor === f.holder ? 'arrakeen:10' : f.location; assert.equal(player(shipment.after, shipment.actor).spice, player(shipment.before, shipment.actor).spice - price); assert.equal(player(shipment.after, shipment.actor).reserves, player(shipment.before, shipment.actor).reserves - amount); assert.equal(player(shipment.after, shipment.actor).forces[destination], amount); assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites); } const pending = disposeSemutaHomeworldBattleSapho(f); assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), false); assert.equal(pending.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(pending.order, f.game.order, 'Physical storm order is never rewritten'); assert.deepEqual(custody(pending), custody(f.game), 'No paid prefix, leader or casualty moves'); if (f.scope === 'aggressor') { assert.equal(pending.battle!.saphoAggressor!.uses.at(-1)!.player, f.owner); assert.deepEqual(pending.battleOrder, f.game.battleOrder, 'Early aggression preserves original remaining chooser state'); } else { assert.ok(!pending.battle, 'A completed prefix does not create the next battle'); assert.equal(pending.battleOrder!.uses.length, 1); assert.equal(pending.battleOrder!.uses[0].mode, f.mode); assert.equal(pending.battleOrder!.uses[0].player, f.owner); assert.deepEqual([...pending.battleOrder!.priority].sort(), [...pending.order].sort()); assert.equal(player(pending, f.holder).tanks, 1, 'The original losing prefix force stays destroyed'); assert.equal(player(pending, f.holder).forces['arrakeen:10'] ?? 0, 0); assert.equal(player(pending, f.atreides).forces['arrakeen:10'], 10, 'Atreides keeps its untouched ten'); assert.equal(player(pending, f.atreides).reserves, player(f.completedPrefix!.before, f.atreides).reserves); assert.equal(pending.lastBattleContext?.event, f.completedPrefix!.event); assert.equal(pending.lastBattleContext?.winner, f.atreides); assert.equal(pending.lastBattleContext?.territory, 'arrakeen'); } assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [f.card]); assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace, true); }); void test(`${label}: the open offer exchanges exactly the physical card or stays with Semuta`, () => { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); const pending = disposeSemutaHomeworldBattleSapho(f), event = offerEvent(pending); assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); for (const seat of pending.players.filter(seat => seat.id !== f.holder)) assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false); assert.throws(() => applyAction(pending, f.actor, f.action), 'The accepted disposal cannot replay'); assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` }), 'A stale offer event is rejected'); assert.throws(() => applyAction(pending, f.choam, { type: 'ready' })); const claimed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, true); const passed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false); for (const done of [claimed, passed]) { assert.equal(done.pendingTreacheryDiscard, null); assert.deepEqual(custody(done), custody(pending), 'Semuta never repeats the paid prefix or casualties'); assert.deepEqual(done.lastBattleContext, pending.lastBattleContext); assert.deepEqual(done.order, pending.order); } assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1); assert.equal(claimed.discard.filter(card => card.id === f.card).length, 0); assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); assert.equal(player(passed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true); assert.equal(player(passed, f.holder).hand.some(card => card.id === f.card), false); assert.equal(passed.discard.filter(card => card.id === f.card).length, 1); for (const done of [claimed, passed]) assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event })); }); void test(`${label}: a full native hand exchanges atomically and entitlement stays private`, () => { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced, true); assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); const pending = disposeSemutaHomeworldBattleSapho(f); const neutral = disposeSemutaHomeworldBattleSapho(f, absent); assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); assert.throws(() => applyAction(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event })); for (const seat of pending.players.filter(seat => seat.id !== f.holder)) assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))), JSON.parse(JSON.stringify(viewGame(neutral, seat.id))), 'Nonholders cannot infer the private entitlement from consumer views'); const claimed = reactSemutaHomeworldChoam(pending, f.holder, true); assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true); assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false); assert.deepEqual(custody(claimed), custody(pending)); }); void test(`${label}: the real next chooser finishes one battle and one native Collection`, () => { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); const done = passOffer(disposeSemutaHomeworldBattleSapho(f)); assert.deepEqual(done.order, f.game.order); const result = finishSemutaHomeworldBattleSapho(done, f); assert.equal(result.game.turn, 1); assert.equal(result.game.phase, 7); assert.ok(!result.game.battle && !result.game.decision && !result.game.response && !result.game.pendingTreacheryDiscard); assert.deepEqual(result.game.order, f.game.order, 'The finished phase keeps its physical circle'); assert.equal(result.game.lastBattleContext?.result, 'normal'); assert.equal(result.game.lastBattleContext?.territory, 'red_chasm'); if (f.scope === 'aggressor') { assert.equal(result.attacker, f.emperor); assert.equal(result.defender, f.choam); assert.equal(result.game.lastBattleContext?.winner, f.owner, 'The recorded aggressor takes the tie'); } else { assert.equal(result.attacker, f.emperor); assert.equal(result.defender, f.choam); assert.equal(result.target, result.attacker, 'The chooser names the actual opposing physical slot'); assert.equal(result.game.lastBattleContext?.winner, f.emperor); if (combo.mode === 'first') assert.equal(result.chooser, f.owner, 'The first position makes the physical defender C the chooser'); else { assert.equal(result.chooser, f.choam, 'A last position immunizes nobody from the current chooser'); assert.equal(done.battleOrder!.priority.at(-1), f.owner, 'The Emperor really holds the accepted last chooser position'); } } const victor = f.scope === 'aggressor' ? f.owner : f.emperor; const loser = f.scope === 'aggressor' ? f.emperor : f.choam; const committed = player(done, loser).forces['red_chasm:7'] ?? 0; const survivors = player(result.game, victor).forces['red_chasm:7'] ?? 0; assert.equal(survivors, player(done, victor).forces['red_chasm:7'], 'The winner keeps exactly its untouched committed force'); assert.equal(player(result.game, loser).forces['red_chasm:7'] ?? 0, 0); assert.equal(player(result.game, loser).tanks, player(done, loser).tanks + committed, 'Battle casualties destroy the loser force once'); assert.equal(player(result.game, victor).tanks, player(done, victor).tanks); assert.equal(result.game.spice['red_chasm:7'], 8 - 2 * survivors, 'The original eight-spice blow is drained exactly once by its surviving force'); assert.equal(player(result.game, victor).spice, player(done, victor).spice + 2 * survivors, 'Only the survivor collection credits the winner; no phase or receipt repeats'); for (const seat of result.game.players.filter(seat => seat.id !== victor)) assert.equal(seat.forces['red_chasm:7'] ?? 0, 0); assert.throws(() => applyAction(result.game, f.choam, { type: 'endMovement' })); }); void test(`${label}: stale and spent controls reject while fresh readiness preserves late timing`, () => { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); const scope = f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder'; assert.equal(viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === scope)?.mode, f.scope === 'aggressor' ? 'aggressor' : f.mode); const spent = applyAction(f.game, f.actor, f.action); assert.deepEqual(viewGame(spent, f.owner).saphoOptions, [], 'One card cannot be spent twice'); assert.throws(() => applyAction(spent, f.actor, f.action)); assert.throws(() => applyAction(f.game, f.actor, { ...f.action, event: 'expired' })); const wrong = f.scope === 'aggressor' ? 'last' : f.mode === 'first' ? 'last' : 'first'; assert.throws(() => applyAction(f.game, f.owner, { ...f.action, mode: wrong }), 'Only the one legal position at this boundary is accepted'); if (f.scope === 'aggressor') { assert.ok(viewGame(applyAction(f.game, f.owner, { type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions .some(option => option.scope === 'battleAggressor'), 'Fresh battles retain late intervention after pre-plan readiness'); const legacy = structuredClone(f.game); delete legacy.battle!.saphoTimingVersion; assert.deepEqual(viewGame(applyAction(legacy, f.owner, { type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions, [], 'Unstamped battles retain readiness-closed early timing'); } const other = f.game.players.find(seat => ![f.owner, f.holder].includes(seat.id))!.id; assert.throws(() => applyAction(f.game, other, f.action)); }); void test(`${label}: every minimal bot profile plays the real producer then the native battle`, () => { for (const difficulty of DIFFICULTIES) { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); const own = viewGame(structuredClone(f.game), f.owner); own.players.find(seat => seat.id === f.owner)!.bot = difficulty; const action = botActions(own).find(proposal => proposal.card === f.card); assert.ok(action, `${difficulty}: the owner keeps a real Sapho action`); assert.equal(action.scope, f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder'); let game = applyAction(f.game, f.owner, action); game = passOffer(game); assert.ok(!game.pendingTreacheryDiscard); const result = finishSemutaHomeworldBattleSapho(game, f); assert.equal(result.game.phase, 7, `${difficulty}: the real continuation reaches Collection`); assert.equal(result.game.turn, 1); } }); } const orders = combos.filter(combo => combo.scope === 'order'); for (const advanced of [false, true]) for (const combo of orders) { const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho order ${combo.mode}`; void test(`${label}: finite remaining queue and the exact accepted position, no extra battle`, () => { const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); const pending = disposeSemutaHomeworldBattleSapho(f); const order = pending.battleOrder!; const priority = order.priority; assert.equal(new Set(priority).size, pending.order.length); assert.deepEqual([...priority].sort(), [...pending.order].sort()); if (combo.mode === 'first') assert.equal(priority.indexOf(f.owner), 0); else assert.equal(priority.indexOf(f.owner), priority.length - 1); const view = viewGame(pending, f.owner).battleOrder; assert.ok(view); assert.deepEqual(view.remaining, [f.choam, f.emperor], 'Only the two remaining physical combatants retain one chooser opportunity'); assert.equal(view.uses.length, 1); assert.ok(!pending.battle); assert.deepEqual(pending.order, f.game.order); }); } for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} early aggressor retains the opponent's completed readiness through Semuta`, () => { const f = createSemutaHomeworldBattleSaphoFixture('aggressor', 'first', advanced, true); const event = f.game.battle!.event!; const ready = applyAction(f.game, f.emperor, { type: 'battlePreparationReady', event }); const pending = disposeSemutaHomeworldBattleSapho(f, ready); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoAggressorDiscard'); const done = reactSemutaHomeworldChoam(pending, f.holder, true); assert.deepEqual(done.battle!.preLeader!.ready, [f.emperor]); assert.deepEqual(custody(done), custody(ready)); assert.equal(done.battle!.attacker, f.emperor); assert.equal(done.battle!.defender, f.choam); assert.equal(done.battle!.chooser, f.emperor); assert.equal(done.battle!.saphoAggressor!.uses.at(-1)!.player, f.choam); const prepared = applyAction(done, f.choam, { type: 'battlePreparationReady', event }); assert.equal(prepared.battle!.preLeader!.closed, true); const result = finishSemutaHomeworldBattleSapho(prepared, f); assert.equal(result.game.lastBattleContext!.winner, f.choam); assert.equal(player(result.game, f.choam).forces[f.location], 4); assert.equal(player(result.game, f.emperor).tanks, 1); assert.equal(result.game.phase, 7); });