Expand Basic Semuta and Truthtrance rules
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
7e6e8c2d12
commit
4f990db52e
13 files changed
+351
-12
No files matched your search
@@ -0,0 +1,135 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user