import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { placeTerror } from '../game/moritani-terror'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import type { Card } from '../game/cards'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; const inventory = (game: Game) => { const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length); return ids; }; const take = (game: Game, predicate: (card: Card) => boolean): Card => { 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 new Error('An original physical card is unavailable.'); }; const ordinaryCard = (game: Game) => take(game, card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind)); /** Control only Atomics' two scalar draws; UUID and all other entropy remain * native. The original random choice always takes each staged hand's first card. */ function withAtomicsRolls(run: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const original = crypto.getRandomValues.bind(crypto); let draws = 0; crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1) { array[0] = 0; draws++; } else Reflect.apply(original, crypto, [array]); return array; }; try { const result = run(); assert.equal(draws, 2); return result; } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Genuine Basic selected-faction start. The alliance, phase, card custody, * force positions and original Atomics token placement are staged with * conserved physical components; shipment and detonation use actual actions. */ function fixture(holder: 'ally' | 'third' = 'ally') { let game = createGame('SEMUTAATOMICS', newPlayer('m', 'Moritani', 'moritani'), false, ['choam', 'ecaz']); joinGame(game, newPlayer('r', 'Richese', 'richese')); joinGame(game, newPlayer('a', 'Atreides', 'atreides')); game = act(game, 'm', {type:'moduleOption', module:'ecazTreachery', enabled:true}); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'm', {type:'start'}); assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true); assert.equal(game.semutaPreview, undefined); for (let count = 0; game.status === 'setup' && count < 160; count++) { let next: {id:string; action:Action} | undefined; for (const player of game.players) { const view = viewGame(game, player.id); view.players.find(row => row.id === player.id)!.bot = 'Medium'; const action = botActions(view)[0]; if (action) { next = {id:player.id, action}; break; } } assert.ok(next, 'Native setup must have a legal action.'); game = act(game, next.id, next.action); } assert.equal(game.status, 'playing'); const original = inventory(game); game.deck.push(...game.players.flatMap(player => player.hand)); for (const player of game.players) { player.hand = []; player.reserves += player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {}; player.tanks = 0; player.spice = 20; player.shipped = false; player.moved = 0; assert.equal(player.reserves, 20); } const owner = seat(game, 'm'), ally = seat(game, 'r'); owner.ally = ally.id; ally.ally = owner.id; owner.forces['carthag:11'] = 2; owner.reserves -= 2; for (let i = 0; i < 4; i++) owner.hand.push(ordinaryCard(game)); for (let i = 0; i < 3; i++) ally.hand.push(ordinaryCard(game)); const semuta = take(game, card => card.id === SEMUTA_DRUG_ID); if (holder === 'ally') ally.hand.push(semuta); else { ally.hand.push(ordinaryCard(game)); seat(game, 'a').hand.push(semuta); } const otherDiscard = owner.hand[0].id, ownDiscard = ally.hand[0].id; const token = game.moritaniTerror!.tokens.find(row => row.kind === 'atomics'); assert.ok(token); game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'carthag', 1); Object.assign(game, {turn:2, phase:5, storm:18, active:'a', order:['a','m','r'], movementRemaining:['a','m','r'], ready:[], response:null, decision:null, phaseOpening:null}); assert.deepEqual(inventory(game), original); game = act(game, 'a', {type:'ship', territory:'carthag', sector:11, amount:2, elite:0, allyPayment:0}); assert.equal(game.pendingTerrorEntry?.stage, 'offer'); game = withAtomicsRolls(() => act(game, 'm', {type:'decision', reveal:true})); return {game, original, token:token.id, otherDiscard, ownDiscard}; } for (const chosenOwner of ['m', 'r'] as const) { void test(`A third-party Semuta holder privately chooses the ${chosenOwner === 'm' ? 'Moritani' : 'allied Richese'} Atomics discard`, () => { const f = fixture('third'), frame = f.game.pendingTreacheryDiscard; assert.ok(frame?.reaction); assert.deepEqual(frame.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]), [[f.otherDiscard, 'm', false], [f.ownDiscard, 'r', false]]); for (const player of f.game.players) assert.deepEqual(viewGame(f.game, player.id).semutaReaction?.candidates, []); const event = frame.batch.event; let game = act(f.game, 'a', {type:'semutaCommit', event}); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select'); assert.deepEqual(viewGame(game, 'a').semutaReaction?.candidates.map(card => card.id), [f.otherDiscard, f.ownDiscard]); for (const id of ['m', 'r']) assert.deepEqual(viewGame(game, id).semutaReaction?.candidates, []); const chosen = chosenOwner === 'm' ? f.otherDiscard : f.ownDiscard; const other = chosenOwner === 'm' ? f.ownDiscard : f.otherDiscard; game = act(game, 'a', {type:'semutaSelect', event, card:chosen}); assert.equal(game.pendingTreacheryDiscard, null); assert.equal(seat(game, 'a').hand.some(card => card.id === chosen), true); assert.equal(game.discard.some(card => card.id === chosen), false); assert.equal(game.discard.some(card => card.id === other), true); assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), true); assert.equal(seat(game, 'm').tanks, 2); assert.equal(seat(game, 'a').tanks, 2); assert.equal(game.moritaniAtomics?.territory, 'carthag'); assert.deepEqual(inventory(game), f.original); }); } void test('a saved Atomics frame without a Semuta reaction finishes without adding a new offer', () => { const f = fixture(), legacy = structuredClone(f.game); assert.ok(legacy.pendingTreacheryDiscard?.reaction); delete legacy.pendingTreacheryDiscard.reaction; const resumed = normalizeAutomaticGame(legacy); assert.equal(resumed.pendingTreacheryDiscard, null); assert.equal(resumed.pendingTerrorEntry ?? null, null); assert.equal(seat(resumed, 'm').tanks, 2); assert.equal(seat(resumed, 'a').tanks, 2); assert.equal(resumed.discard.some(card => card.id === f.otherDiscard), true); assert.equal(resumed.discard.some(card => card.id === f.ownDiscard), true); assert.equal(seat(resumed, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID), true); assert.deepEqual(inventory(resumed), f.original); }); void test('a saved Atomics continuation cannot replace its authoritative Aftermath', () => { const f = fixture(), damaged = structuredClone(f.game); const continuation = damaged.pendingTreacheryDiscard?.continuation; assert.equal(continuation?.kind, 'terrorAtomics'); if (continuation?.kind !== 'terrorAtomics') throw new Error('Expected original Atomics continuation.'); continuation.aftermath = {...continuation.aftermath, turn: continuation.aftermath.turn + 1}; assert.throws(() => normalizeAutomaticGame(damaged)); }); for (const claimed of [false, true]) { void test(`Basic Moritani Atomics random private discards offer Semuta ${claimed ? 'claim' : 'pass'}`, () => { const f = fixture(), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.continuation.kind, 'terrorAtomics'); assert.equal(pending.batch.cause, 'terror:atomics'); assert.deepEqual(pending.batch.entries.map(row => [row.card.id, row.discardedBy, row.publicFace]), [[f.otherDiscard, 'm', false], [f.ownDiscard, 'r', false]]); assert.equal(seat(f.game, 'm').tanks, 2); assert.equal(seat(f.game, 'a').tanks, 2); assert.equal(f.game.moritaniAtomics?.territory, 'carthag'); assert.equal(f.game.moritaniAtomics?.allyAtActivation, 'r'); assert.equal(f.game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status, 'removed'); for (const player of f.game.players) assert.deepEqual(viewGame(f.game, player.id).semutaReaction?.candidates, []); assert.equal(viewGame(f.game, 'r').semutaReaction?.canCommit, true); for (const difficulty of DIFFICULTIES) { const view = viewGame(f.game, 'r'); view.players.find(row => row.id === 'r')!.bot = difficulty; const action = botActions(view).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass'); assert.ok(action); assert.doesNotThrow(() => act(f.game, 'r', action)); } const event = pending.batch.event; let game = f.game; if (claimed) { game = act(game, 'r', {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { assert.deepEqual(viewGame(game, 'r').semutaReaction?.candidates.map(card => card.id), [f.otherDiscard]); assert.deepEqual(viewGame(game, 'm').semutaReaction?.candidates, []); assert.throws(() => act(game, 'r', {type:'semutaSelect', event, card:f.ownDiscard})); game = act(game, 'r', {type:'semutaSelect', event, card:f.otherDiscard}); } } else { for (const player of game.players) game = act(game, player.id, {type:'semutaPass', event}); } assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingTerrorEntry ?? null, null); assert.equal(seat(game, 'm').tanks, 2); assert.equal(seat(game, 'a').tanks, 2); assert.equal(seat(game, 'a').forces['carthag:11'], undefined); assert.equal(game.moritaniAtomics?.territory, 'carthag'); assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status, 'removed'); assert.equal(seat(game, 'r').hand.some(card => card.id === f.otherDiscard), claimed); assert.equal(game.discard.some(card => card.id === f.otherDiscard), !claimed); assert.equal(game.discard.some(card => card.id === f.ownDiscard), true); assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claimed); assert.equal(seat(game, 'm').hand.length, 3); assert.equal(seat(game, 'r').hand.length, 3); assert.throws(() => act(game, 'r', {type:'semutaCommit', event})); assert.deepEqual(inventory(game), f.original); }); }