Files
Dune/tests/semuta-recruits-window-cards.test.ts
T
Quintine 4f990db52e
Verify and publish checkpoint container / container (push) Canceled after 0s
Expand Basic Semuta and Truthtrance rules
2026-10-29 05:57:46 +11:00

136 lines
7.3 KiB
TypeScript

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);
});
}