Files
Dune/tests/semuta-advanced-specials.test.ts
T
Quintine b2bc3f2f01
Verify and publish checkpoint container / container (push) Successful in 10m54s
Extend Advanced Semuta discard continuations
2026-10-29 04:20:17 +11:00

117 lines
6.2 KiB
TypeScript

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