117 lines
6.2 KiB
TypeScript
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'));
|
|
});
|