import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, 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 { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); const seat = (game: Game, id: string) => { const found = game.players.find(player => player.id === id); assert.ok(found); return found; }; /** Public Basic start with the Ix deck and independent Ecaz cards. After real * setup, stage original card custody and the specific phase boundary below. */ function fixture(effect: 'thumper' | 'amal') { let game = createGame('SEMUTAWINDOW', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']); joinGame(game, newPlayer('a', 'Atreides', 'atreides')); joinGame(game, newPlayer('e', 'Emperor', 'emperor')); game = act(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true}); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'r', {type:'start'}); assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true); assert.equal(game.semutaPreview, undefined); for (let step = 0; game.status === 'setup' && step < 100; step++) { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'Native setup must offer a legal action'); game = act(game, next.actor, next.action); } assert.equal(game.status, 'playing'); const original = inventory(game); for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; } const take = (id: string) => { for (const zone of [game.deck, game.discard, game.richeseCache!]) { const index = zone.findIndex(card => card.id === id || card.effect === id); if (index >= 0) return zone.splice(index, 1)[0]; } throw new Error(`Missing physical ${id}`); }; seat(game, 'r').hand.push(take(SEMUTA_DRUG_ID)); const played = take(effect); seat(game, 'a').hand.push(played); assert.deepEqual(inventory(game), original); return {game, played, original}; } function finishOffer(game: Game, claim: boolean) { const frame = game.pendingTreacheryDiscard; assert.ok(frame?.reaction); const event = frame.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:frame.batch.entries[0].card.id}); } else { for (const player of game.players) game = act(game, player.id, {type:'semutaPass', event}); } assert.equal(game.pendingTreacheryDiscard, null); assert.throws(() => act(game, 'r', {type:'semutaCommit', event})); return game; } function checkBots(pending: Game) { for (const difficulty of DIFFICULTIES) { const game = structuredClone(pending); seat(game, 'r').bot = difficulty; const action = botActions(viewGame(game, 'r')).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass'); assert.ok(action); assert.doesNotThrow(() => act(game, 'r', action)); } } for (const claim of [false, true]) { void test(`Basic independent Ix Thumper offers Semuta ${claim ? 'claim' : 'pass'} before one Spice Blow`, () => { const f = fixture('thumper'), game = f.game; const lands = game.spiceDeck.filter(card => 'territory' in card && !card.discovery && card.sector !== 18); const prior = lands[0], next = lands[1]; assert.ok(prior && next && 'territory' in prior && 'territory' in next); const priorKey = `${prior.territory}:${prior.sector}`; const remaining = game.spiceDeck.filter(card => card !== prior && card !== next); game.spiceDeck = [next, ...remaining]; game.spiceDiscard = [[prior], []]; seat(game, 'e').forces = {[priorKey]:3}; seat(game, 'e').reserves = 17; game.spice = {[priorKey]:8}; Object.assign(game, {turn:2, phase:1, active:null, ready:[], storm:18, decision:null, response:null, phaseOpening:null, stormPending:null, spiceWindow:null, spiceResolution:null, spiceSequence:null, nexus:false}); const beforeDeck = structuredClone(game.spiceDeck); const pending = act(game, 'a', {type:'card', card:f.played.id}); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'thumperDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.deepEqual(pending.spiceDeck, beforeDeck); assert.equal(pending.spice[priorKey], 8); assert.equal(seat(pending, 'e').tanks, 0); assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); checkBots(pending); const resumed = finishOffer(pending, claim); assert.equal(seat(resumed, 'e').tanks, 3); assert.equal(resumed.spice[priorKey], undefined); assert.equal(resumed.spiceWindow?.territory, next.territory); assert.equal(seat(resumed, 'r').hand.some(card => card.id === f.played.id), claim); assert.equal(resumed.discard.filter(card => card.id === f.played.id).length, Number(!claim)); assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim)); assert.deepEqual(inventory(resumed), f.original); }); void test(`Basic independent Ix Amal offers Semuta ${claim ? 'claim' : 'pass'} after one halving`, () => { const f = fixture('amal'); let game = f.game; seat(game, 'r').spice = 11; seat(game, 'a').spice = 9; seat(game, 'e').spice = 7; Object.assign(game, {turn:2, phase:2, active:null, ready:[], decision:null, response:null, phaseOpening:null, auction:null, stormPending:null}); for (const id of ['a', 'e', 'r']) game = act(game, id, {type:'ready'}); assert.equal(game.phase, 3); assert.ok(game.phaseOpening); game = act(game, 'e', {type:'ready'}); assert.deepEqual(game.phaseOpening?.passed, ['e']); const deck = structuredClone(game.deck); const pending = act(game, 'a', {type:'card', card:f.played.id}); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'amalDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(pending.phaseOpening, null); assert.deepEqual(pending.deck, deck); assert.deepEqual(pending.players.map(player => player.spice), [5, 4, 3]); assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); checkBots(pending); let resumed = finishOffer(pending, claim); assert.deepEqual(resumed.phaseOpening?.passed, []); assert.deepEqual(resumed.players.map(player => player.spice), [5, 4, 3]); assert.equal(seat(resumed, 'r').hand.some(card => card.id === f.played.id), claim); assert.equal(resumed.discard.filter(card => card.id === f.played.id).length, Number(!claim)); assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim)); for (const id of ['a', 'e', 'r']) resumed = act(resumed, id, {type:'ready'}); assert.equal(resumed.phaseOpening, null); assert.deepEqual(resumed.players.map(player => player.spice), [5, 4, 3]); assert.deepEqual(inventory(resumed), f.original); }); }