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'; type Kind = 'box' | 'distrans' | 'truth'; 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) { const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(seat => seat.hand)]; const ids = cards.map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length); return ids; } function take(game: Game, identity: string) { for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(seat => seat.hand)]) { const index = zone.findIndex(card => card.id === identity || card.effect === identity); if (index >= 0) return zone.splice(index, 1)[0]; } throw new Error(`Missing physical card ${identity}.`); } /** Genuine Advanced public start and normal setup. To reach the selected clean * special-card window, the phase and physical card custody are staged. No * unopened game or saved room is altered, and card identities are conserved. */ function fixture(kind: Kind) { let game = createGame('ADVSEMUTASPECIAL', newPlayer('a','Atreides','atreides'), true, ['choam']); joinGame(game, newPlayer('r','Richese','richese')); joinGame(game, newPlayer('e','Emperor','emperor')); for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = act(game, 'a', {type:'start',advancedPreview:true}); assert.equal(game.semutaAdvancedOrdinaryPreview, true); assert.equal(game.semutaPreview, undefined); for (let count = 0; game.status === 'setup' && count < 80; count++) { let next: Game | undefined; for (const seat of game.players) { const view = viewGame(game, seat.id); view.players.find(row => row.id === seat.id)!.bot = 'Medium'; const action = botActions(view)[0]; if (action) { next = act(game, seat.id, action); break; } } assert.ok(next, 'The genuine setup has a legal next step.'); game = next; } assert.equal(game.status, 'playing'); const before = inventory(game); const semuta = take(game, SEMUTA_DRUG_ID); const source = take(game, kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : 'distrans'); const extra = take(game, 'hajr'); player(game, 'r').hand.push(semuta); player(game, 'a').hand.push(source); if (kind === 'box') game.discard.push(extra); else if (kind === 'distrans') player(game, 'a').hand.push(extra); else game.deck.push(extra); assert.deepEqual(inventory(game), before); Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null, decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']}); const beforeSpice = player(game, 'a').spice; if (kind === 'box') { game = act(game, 'a', {type:'card',card:source.id}); // The native Box selects a sole eligible discard without another prompt. if (game.pendingNullentropy) game = act(game, 'a', {type:'decision',event:game.pendingNullentropy.event,card:extra.id}); assert.equal(player(game,'a').spice,beforeSpice - 2); assert.ok(player(game,'a').hand.some(card => card.id === extra.id)); } else if (kind === 'distrans') { game = act(game,'a',{type:'card',card:source.id,target:'e',give:extra.id}); assert.ok(player(game,'e').hand.some(card => card.id === extra.id)); } else { game = act(game,'a',{type:'card',card:source.id}); while (game.truthtrance?.stage === 'priority') { const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next); game = act(game,next.id,{type:'truthPass'}); } game = act(game,'a',{type:'truthAsk',question:{kind:'freeform',target:'e', scope:'fact',text:'Do you intend to protect Arrakeen?'}}); game = act(game,'e',{type:'truthAnswer',answer:'yes'}); assert.equal(game.truthHistory?.at(-1)?.answer, 'yes'); } return {game, source, extra, before, beforeSpice}; } for (const kind of ['box','distrans','truth'] as const) for (const claim of [false,true]) void test(`Advanced public ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the committed original effect`, () => { const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.continuation.kind, kind === 'box' ? 'nullentropyDiscard' : kind === 'truth' ? 'truthtranceDiscard' : 'distransDiscard'); assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.source.id]); assert.equal(viewGame(f.game, 'r').semutaReaction?.canCommit, true); for (const difficulty of DIFFICULTIES) { const view = viewGame(f.game, 'r'); view.players.find(seat => seat.id === 'r')!.bot = difficulty; assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty); } let game = f.game; if (claim) { game = act(game,'r',{type:'semutaCommit',event:pending.batch.event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game,'r',{type:'semutaSelect',event:pending.batch.event,card:f.source.id}); } else for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event}); assert.equal(game.pendingTreacheryDiscard, null); assert.equal(player(game,'r').hand.some(card => card.id === f.source.id),claim); assert.equal(game.discard.some(card => card.id === f.source.id),!claim); if (kind === 'box') { assert.equal(player(game,'a').spice,f.beforeSpice - 2); assert.ok(player(game,'a').hand.some(card => card.id === f.extra.id)); } else if (kind === 'distrans') assert.ok(player(game,'e').hand.some(card => card.id === f.extra.id)); else assert.equal(game.truthHistory?.at(-1)?.answer,'yes'); assert.deepEqual(inventory(game),f.before); game = act(game,'a',{type:'endMovement'}); assert.ok(!game.movementRemaining?.includes('a')); });