Files
Dune/tests/semuta-terror-atomics.test.ts
T
Quintine a9b67a89b3
Verify and publish checkpoint container / container (push) Successful in 11m15s
Allow Semuta after Basic Moritani Atomics
2026-10-29 06:02:39 +11:00

201 lines
11 KiB
TypeScript

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<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let draws = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(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);
});
}