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 { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!; function inventory(game: Game): string[] { const cards = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], ...game.players.map(seat => seat.hand)].flat().map(card => card.id).sort(); assert.equal(new Set(cards).size, cards.length); return cards; } /** A genuine Advanced public start followed by native setup. Only the later * card custody, board counters and battle phase are staged; Poison, readiness, * Semuta and battle resolution use the actual actions. */ function fixture() { let game = createGame('ADVSEMUTARESIDUAL', newPlayer('e', 'Emperor', 'emperor'), true, ['choam']); for (const [id, faction] of [['a', 'atreides'], ['r', 'richese'], ['g', 'guild']] as const) joinGame(game, newPlayer(id, faction, faction)); for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = act(game, 'e', {type:'start', advancedPreview:true}); assert.equal(game.semutaAdvancedOrdinaryPreview, true); assert.equal(game.semutaPreview, undefined); for (let count = 0; game.status === 'setup' && count < 150; count++) { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); game = act(game, next.actor, next.action); } assert.equal(game.status, 'playing'); const original = inventory(game); for (const seat of game.players) game.deck.push(...seat.hand.splice(0)); const take = (predicate: (card: Game['deck'][number]) => boolean) => { for (const zone of [game.deck, game.discard, game.richeseCache!]) { const index = zone.findIndex(predicate); if (index >= 0) return zone.splice(index, 1)[0]; } throw Error('Missing an original physical card.'); }; const poison = take(card => card.effect === 'residualPoison'); const semuta = take(card => card.id === SEMUTA_DRUG_ID); player(game, 'e').hand.push(poison); player(game, 'g').hand.push(semuta); for (const id of ['e', 'a']) { const seat = player(game, id); seat.reserves += Object.values(seat.forces).reduce((total, amount) => total + amount, 0); seat.forces = {}; if (seat.elites) { seat.elites.reserves += Object.values(seat.elites.forces).reduce((total, amount) => total + amount, 0); seat.elites.forces = {}; } seat.reserves -= 3; seat.forces['red_chasm:7'] = 3; } Object.assign(game, {turn:2, phase:6, storm:18, active:'e', order:['e','a','r','g'], ready:[], response:null, decision:null, phaseOpening:null, battle:null, movementRemaining:[]}); assert.deepEqual(inventory(game), original); game = act(game, 'e', {type:'chooseBattle', territory:'red_chasm', target:'a'}); for (let count = 0; count < 80; count++) { if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.response && !game.decision && !game.phaseOpening && !game.battle.preparation) break; if (game.response) { const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next); game = act(game, next.id, {type:'passResponse'}); } else if (game.decision?.kind === 'fullPlanOffer') { game = act(game, game.decision.player, {type:'decision', decline:true}); } else if (game.battle?.preparation) { game = act(game, game.battle.preparation.owner, {type:'declineBattlePower'}); } else { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); game = act(game, next.actor, next.action); } } assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed); const quote = viewGame(game, 'e').residualPoison; assert.ok(quote && !quote.blocked && quote.event && quote.target); return {game, poison:poison.id, event:game.battle.event!, original, action:{type:'card', card:poison.id, target:quote.target, event:quote.event} as Action}; } function finishBattle(state: Game): Game { let game = state; const battleEvent = game.battle!.event!; for (const id of ['e', 'a']) if (!game.battle!.preLeader!.ready.includes(id)) game = act(game, id, {type:'battlePreparationReady', event:battleEvent}); const emperor = player(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt) .sort((a, b) => b.strength - a.strength)[0]!; const atreides = player(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt) .sort((a, b) => a.strength - b.strength)[0]!; game = act(game, 'e', {type:'battlePlan', dial:3, support:3, leader:emperor.id, weapon:null, defense:null}); game = act(game, 'a', {type:'battlePlan', dial:0, support:0, leader:atreides.id, weapon:null, defense:null}); for (let count = 0; count < 400 && !(game.phase >= 7 && !game.response && !game.decision && !game.phaseOpening); count++) { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); game = act(game, next.actor, next.action); } assert.ok(game.phase >= 7 && !game.response && !game.decision && !game.phaseOpening); return game; } for (const ready of ['owner', 'opponent'] as const) for (const claim of [false, true]) void test(`Advanced public Residual Poison ${ready}-ready Semuta ${claim ? 'claim' : 'pass'} resumes battle`, () => { const f = fixture(), readyPlayer = ready === 'owner' ? 'e' : 'a'; let game = act(f.game, readyPlayer, {type:'battlePreparationReady', event:f.event}); const before = structuredClone(game); const dead = new Set(player(game, 'a').leaders.filter(leader => leader.dead).map(leader => leader.id)); game = act(game, 'e', f.action); const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.continuation.kind, 'residualPoisonDiscard'); assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]), [[f.poison, 'e', true]]); assert.deepEqual(game.battle!.preLeader!.ready, [readyPlayer]); const victims = player(game, 'a').leaders.filter(leader => leader.dead && !dead.has(leader.id)); assert.equal(victims.length, 1); for (const difficulty of DIFFICULTIES) { const botGame = structuredClone(game); player(botGame, 'g').bot = difficulty; const action = botActions(viewGame(botGame, 'g')).find(candidate => candidate.type === 'semutaCommit' || candidate.type === 'semutaPass'); assert.ok(action, difficulty); assert.doesNotThrow(() => act(botGame, 'g', action), difficulty); } const event = pending.batch.event; if (claim) { game = act(game, 'g', {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, 'g', {type:'semutaSelect', event, card:f.poison}); } else for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event}); assert.equal(game.pendingTreacheryDiscard, null); assert.deepEqual(game.battle!.preLeader!.ready, ready === 'owner' ? [] : ['a']); assert.deepEqual(game.battle!.plans, before.battle!.plans); assert.equal(player(game, 'a').leaders.find(leader => leader.id === victims[0].id)?.dead, true); assert.equal(player(game, 'g').hand.some(card => card.id === f.poison), claim); assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); assert.equal(game.discard.some(card => card.id === f.poison), !claim); assert.deepEqual(inventory(game), f.original); assert.throws(() => act(game, 'g', {type:'semutaCommit', event})); game = finishBattle(game); assert.equal(player(game, 'a').leaders.find(leader => leader.id === victims[0].id)?.dead, true); assert.deepEqual(inventory(game), f.original); });