import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; function inventory(game: Game): string[] { const ids = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], ...game.players.map(player => player.hand)].flat().map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length); return ids; } function take(game: Game, predicate: (card: Game['deck'][number]) => boolean) { for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]) { const index = zone.findIndex(predicate); if (index >= 0) return zone.splice(index, 1)[0]; } throw new Error('Missing original physical card.'); } function setup(kind: 'loser' | 'mandatory' | 'selected') { let game = createGame('ADVSEMUTABATTLE', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']); joinGame(game, newPlayer('e', 'Emperor', 'emperor')); joinGame(game, newPlayer('r', 'Richese', 'richese')); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'a', {type:'start', advancedPreview:true}); assert.equal(game.semutaAdvancedOrdinaryPreview, true); assert.equal(game.semutaPreview, undefined); for (let n = 0; game.status === 'setup' && n < 100; n++) { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'Native setup must advance.'); game = act(game, next.actor, next.action); } assert.equal(game.status, 'playing'); const cards = inventory(game); // Preserve physical inventory while positioning the exact battle cards and // keep the discard free from unrelated earlier setup choices. for (const player of game.players) game.deck.push(...player.hand.splice(0)); const semuta = take(game, card => card.id === SEMUTA_DRUG_ID); const winnerWeapon = take(game, card => card.kind === 'projectile'); const loserDefense = take(game, card => card.kind === 'shield'); const hero = kind === 'mandatory' ? take(game, card => card.kind === 'hero') : null; seat(game, 'r').hand.push(semuta); seat(game, 'a').hand.push(winnerWeapon, ...(hero ? [hero] : [])); seat(game, 'e').hand.push(loserDefense); assert.deepEqual(inventory(game), cards); // Put conserved physical force groups in a clear shared territory. Emperor's // existing Advanced elite counters stay in reserves; both armies are normal. for (const id of ['a', 'e']) { const player = seat(game, id); player.reserves += Object.values(player.forces).reduce((total, amount) => total + amount, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((total, amount) => total + amount, 0); player.elites.forces = {}; } const amount = id === 'a' ? 6 : 2; player.reserves -= amount; player.forces['red_chasm:7'] = amount; } Object.assign(game, {phase:6, active:'a', order:['a','e','r'], storm:18, ready:[], response:null, decision:null, phaseOpening:null, battle:null}); game = act(game, 'a', {type:'chooseBattle', territory:'red_chasm', target:'e'}); for (let n = 0; n < 80; n++) { if (game.battle && !game.response && !game.decision && !game.phaseOpening && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed)) break; const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'The current native battle interaction must finish before preparation.'); game = act(game, next.actor, next.action); } assert.ok(game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed)); const winnerLeader = hero?.id ?? seat(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt) .sort((left, right) => right.strength - left.strength)[0]!.id; const loserLeader = seat(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt) .sort((left, right) => left.strength - right.strength)[0]!.id; game = act(game, 'a', {type:'battlePlan', dial:hero ? 5 : 1, support:hero ? 5 : 1, leader:winnerLeader, weapon:winnerWeapon.id, defense:null}); game = act(game, 'e', {type:'battlePlan', dial:0, support:0, leader:loserLeader, weapon:null, defense:loserDefense.id}); const voters = viewGame(game, 'a').battle!.traitorVoters; assert.equal(voters.length, 2); game = act(game, voters[0], {type:'traitorCall', call:false}); return {game, finalActor:voters[1], loserDefense, winnerWeapon, hero, cards}; } function react(game: Game, claim: boolean, selected: string): Game { const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); const event = pending.batch.event; if (claim) { game = act(game, 'r', {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, 'r', {type:'semutaSelect', event, card:selected}); } else { for (const player of game.players) { if (!game.pendingTreacheryDiscard) break; game = act(game, player.id, {type:'semutaPass', event}); } } return game; } function advance(game: Game, until: (state: Game) => boolean): Game { for (let n = 0; n < 400 && !until(game); n++) { if (game.pendingTreacheryDiscard?.reaction) game = react(game, false, ''); else { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Native continuation needed at ${game.phase}/${game.decision?.kind}`); game = act(game, next.actor, next.action); } } assert.ok(until(game)); return game; } for (const kind of ['loser', 'mandatory', 'selected'] as const) for (const claim of [false, true]) void test(`public Advanced Semuta ${kind} battle discard: ${claim ? 'claim' : 'pass'}`, () => { const f = setup(kind); let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, {type:'traitorCall',call:false})); if (kind !== 'loser') { assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory'); game = react(game, false, f.loserDefense.id); if (kind === 'selected') { game = advance(game, state => state.decision?.kind === 'battleCards'); game = act(game, 'a', {type:'decision', discard:[f.winnerWeapon.id]}); } } const card = kind === 'loser' ? f.loserDefense : kind === 'mandatory' ? f.hero! : f.winnerWeapon; const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.batch.cause, kind === 'loser' ? 'battle:mandatory' : kind === 'mandatory' ? 'battle:winnerMandatory' : 'battle:winner'); assert.ok(pending.batch.entries.some(entry => entry.card.id === card.id)); const original = structuredClone(game), event = pending.batch.event; for (const difficulty of DIFFICULTIES) { const botGame = structuredClone(game); seat(botGame, 'r').bot = difficulty; const action = botActions(viewGame(botGame, 'r')).find(candidate => candidate.type === 'semutaCommit' || candidate.type === 'semutaPass'); assert.ok(action, difficulty); assert.doesNotThrow(() => act(botGame, 'r', action), difficulty); } game = react(game, claim, card.id); assert.equal(game.pendingTreacheryDiscard, null); assert.equal(seat(game, 'r').hand.some(held => held.id === card.id), claim); assert.equal(game.discard.some(discarded => discarded.id === card.id), !claim); assert.equal(seat(game, 'e').tanks, seat(original, 'e').tanks); for (const player of game.players) assert.equal(player.spice, seat(original, player.id).spice); assert.deepEqual(inventory(game), f.cards); assert.throws(() => act(game, 'r', {type:'semutaCommit', event})); assert.equal(game.lastBattleContext?.event, original.lastBattleContext?.event); game = advance(game, state => state.phase >= 7 && clean(state)); assert.deepEqual(inventory(game), f.cards); });