136 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
}
|