Preserve World occupation capabilities while Sandtrout waits

This commit is contained in:
Quintine committed 2026-10-26 12:18:06 +11:00
1 parent 09eb857583
commit 4ab20a2838
12 files changed
+403 -15

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { spiceDeck } from '../game/cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
actMixedPhaseModules, createMixedPhaseModulesFixture, mixedPhaseModulesClean,
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
import { beforeIxSpiceDraw, orderIxSpiceCards, type IxSpicePick } from './fixture-ix-spice-cards';
export type WorldSandtroutStep = MixedPhaseModulesStep;
export type WorldSandtroutTransition = MixedPhaseModulesTransition;
export const WORLD_SANDTROUT_TRAINING = { atreides: 'warmaster', emperor: 'spice-banker', ixians: 'mentat' } as const;
export const actWorldSandtrout = actMixedPhaseModules;
export const nextWorldSandtrout = nextMixedPhaseModules;
export const worldSandtroutClean = mixedPhaseModulesClean;
export const worldSandtroutPlayer = mixedPhaseModulesPlayer;
export const worldSandtroutInventory = mixedPhaseModulesInventory;
export function advanceWorldSandtrout(game: Game, until: (state: Game) => boolean,
actions: WorldSandtroutStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original World Sandtrout source ended before its native control');
const next = nextWorldSandtrout(game); assert.ok(next);
game = actWorldSandtrout(game, next.actor, next.action, actions);
}
throw Error('The original World Sandtrout source did not reach its finite boundary');
}
export function finishWorldSandtrout(game: Game, actions: WorldSandtroutStep[] = []): Game {
const turn = game.turn;
return advanceWorldSandtrout(game, state => state.turn === turn && state.phase === 2 && worldSandtroutClean(state), actions);
}
export function worldSandtroutDeferred(game: Game, owner = 'emperor') {
return viewGame(game, owner).spiceBankerIncome?.deferred.find(row => row.owner === owner)?.amount ?? 0;
}
/** Sandtrout is set aside while active: count its live boolean once, then its
* physical discard once after suppression. Suppression annotates an original
* worm; it does not mint another Spice Card. */
export function worldSandtroutSpice(game: Game) {
const cards = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceSequence?.skipped ?? [])]
.filter(card => !('worm' in card && card.thumper));
const inventory = cards.map(card => JSON.stringify('worm' in card ? { worm: true } : card));
if (game.sandtrout) inventory.push(JSON.stringify({ sandtrout: true }));
assert.equal(cards.filter(card => 'worm' in card).length, 6);
assert.equal(cards.filter(card => 'territory' in card).length, 15);
assert.equal(cards.filter(card => 'sandtrout' in card).length + Number(!!game.sandtrout), 1);
assert.deepEqual(inventory.sort(), spiceDeck(true).map(card => JSON.stringify(card)).sort());
return inventory.sort();
}
export function worldSandtroutSkills(game: Game) {
const skills = game.leaderSkills ? [...game.leaderSkills.deck,
...game.leaderSkills.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : [];
if (game.leaderSkills) assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
return skills;
}
export function worldSandtroutStocks(game: Game) {
return game.players.map(player => ({ player: player.id, reserves: player.reserves, tanks: player.tanks,
forces: structuredClone(player.forces), eliteReserves: player.elites?.reserves ?? 0,
eliteTanks: player.elites?.tanks ?? 0, eliteForces: structuredClone(player.elites?.forces ?? {}) }));
}
/** Genuine public A/E/Ix +Ix World/Tech setup. Advanced additionally trains
* three original all14 offers and enables original Strongholds. Only the
* original scalar/Skill lottery and unopened Spice prefix are selected. No
* played Sandtrout flag, card, force, wallet, occupation or clock is installed.
* Stop at the actual first blow with Sandtrout physically waiting. */
export function createWorldSandtroutFixture(options: { advanced?: boolean; firstTurnWorm?: boolean } = {}) {
const advanced = options.advanced ?? false, firstTurnWorm = options.firstTurnWorm ?? false;
const source = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'ixians'], expansions: ['ix'],
advanced, homeworlds: true, tech: true, strongholds: advanced, skills: advanced,
training: advanced ? WORLD_SANDTROUT_TRAINING : undefined });
let game = structuredClone(source.game);
const actions = [...source.actions];
assert.ok(beforeIxSpiceDraw(game));
const picks: IxSpicePick[] = ['sandtrout', ...(firstTurnWorm ? ['worm' as const] : []),
{ territory: 'broken_land' }, ...(advanced ? [{ territory: 'funeral_plain' }] : [])];
const spiceOrder = orderIxSpiceCards(game, picks);
const beforeDraw = structuredClone(game), from = actions.length;
game = advanceWorldSandtrout(game, state => !!state.spiceWindow && worldSandtroutClean(state), actions);
const draw: WorldSandtroutTransition = { before: beforeDraw, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(game.sandtrout, true); assert.equal(game.spiceWindow!.territory, 'broken_land');
assert.equal(game.nexus, false);
const originalSpice = worldSandtroutSpice(source.setup), originalSkills = worldSandtroutSkills(source.setup);
assert.deepEqual(worldSandtroutSpice(game), originalSpice);
assert.deepEqual(worldSandtroutInventory(game), source.originalInventory);
homeworldGameIntegrity(game);
return { ...source, game, sourceGame: structuredClone(game), advanced, firstTurnWorm,
actor: 'atreides', owner: 'atreides', action: { type: 'ready' } as Action, banker: advanced ? 'emperor' : null,
origin: 'homeworld:atreides', target: game.spiceWindow!.territory, targetSector: game.spiceWindow!.sector,
targetLocation: `${game.spiceWindow!.territory}:${game.spiceWindow!.sector}`,
spiceOrder, beforeDraw, draw, originalSpice, originalSkills,
training: advanced ? WORLD_SANDTROUT_TRAINING : null,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Original public A/E/Ix Worlds/Tech and optional Advanced all14/Strongholds use actual controls. Only original scalar/Skill lotteries and unopened Sandtrout/territory/worm positions are selected; no played flags, hands, armies, wallets, occupation state or turn is installed. First-turn ignoring and immediate-only doubling retain the provisional source policy in docs/IX_PROTOTYPE.md.' };
}
export type WorldSandtroutFixture = ReturnType<typeof createWorldSandtroutFixture>;
/** An actual paid native A2 arrival becomes the later worm's physical source.
* The active original Sandtrout survives real Collection, Mentat and Storm;
* the next drawn worm is suppressed and only its immediate territory doubles. */
export function completeWorldSandtrout(f: WorldSandtroutFixture, state = f.game) {
let game = state;
const actions: WorldSandtroutStep[] = [];
const act = (actor: string, action: Action) => { game = actWorldSandtrout(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceWorldSandtrout(game, until, actions); };
const transition = (run: () => void): WorldSandtroutTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const firstSpice = transition(() => { game = finishWorldSandtrout(game, actions); });
reach(state => state.turn === 1 && state.phase === 5 && state.active === f.actor && worldSandtroutClean(state));
const shipmentSources = nativeShipmentSources(viewGame(game, f.actor), 2, 0); assert.ok(shipmentSources);
const shipmentAction: Action = { type: 'ship', territory: f.target, sector: f.targetSector, amount: 2, elite: 0,
allyPayment: 0, homeworldSources: shipmentSources };
const shipment = transition(() => { act(f.actor, shipmentAction); reach(worldSandtroutClean); });
assert.equal(worldSandtroutPlayer(shipment.before, f.actor).spice - worldSandtroutPlayer(game, f.actor).spice, 4);
assert.equal(worldSandtroutPlayer(game, f.actor).forces[f.targetLocation], 2);
reach(state => state.phase === 5 && state.active === state.order.at(-1) && worldSandtroutClean(state));
const techOwner = game.techTokens!.heighliners.owner; assert.ok(techOwner);
const techAmount = game.techTokens!.heighliners.spice;
const collection = transition(() => {
act(game.active!, { type: 'endMovement' });
reach(state => state.phase === 7 && worldSandtroutClean(state));
});
assert.equal(game.sandtrout, true);
const mentat = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && worldSandtroutClean(state));
});
assert.equal(game.sandtrout, true);
const storm = transition(() => { reach(state => state.turn === 2 && beforeIxSpiceDraw(state)); });
assert.equal(game.sandtrout, true);
assert.equal(worldSandtroutPlayer(game, f.actor).forces[f.targetLocation], 2);
const secondSpiceOrder = orderIxSpiceCards(game, ['worm', { territory: 'hagga_basin' },
...(f.advanced ? [{ territory: 'rock_outcroppings' }] : [])]);
const beforeSuppression = structuredClone(game);
const suppression = transition(() => { reach(state => !!state.spiceWindow && worldSandtroutClean(state)); });
const revealed = structuredClone(game);
assert.equal(game.sandtrout, false); assert.equal(game.nexus, false);
assert.equal(game.spiceWindow!.territory, 'hagga_basin');
let secondWindow: Game | null = null;
if (f.advanced) {
reach(state => !!state.spiceWindow && state.spiceSequence?.pile === 1 && worldSandtroutClean(state));
secondWindow = structuredClone(game);
}
const finishedSpice = transition(() => { game = finishWorldSandtrout(game, actions); });
homeworldGameIntegrity(game);
assert.deepEqual(worldSandtroutSpice(game), f.originalSpice);
assert.deepEqual(worldSandtroutInventory(game), f.originalInventory);
assert.deepEqual(worldSandtroutSkills(game), f.originalSkills);
const bankerSource = shipment.after.spiceBankerIncome?.sources.find(receipt => receipt.source.kind === 'shipment' &&
receipt.source.bankLegs.some(leg => leg.payer === f.actor && leg.amount === 4)) ?? null;
const bankerCollection = bankerSource ? game.spiceBankerIncome?.collections.find(receipt =>
receipt.turn === 1 && receipt.grants.includes(bankerSource.cursor)) ?? null : null;
return { game, firstSpice, shipmentSources, shipmentAction, shipment, techOwner, techAmount,
collection, mentat, storm, secondSpiceOrder, beforeSuppression, suppression, revealed,
secondWindow, finishedSpice, bankerSource: structuredClone(bankerSource),
bankerCollection: structuredClone(bankerCollection), actions, actionCount: actions.length };
}
+10 -2
View File
@@ -61,15 +61,23 @@ void test('independent-card selection is an exact optional boolean rather than a
for (const ecazTreachery of [null, 0, 1, 'true', {}]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), false);
});
void test('Skills, Nexus, Discovery, active Sandtrout and unrelated reaction selectors remain outside the clean World scope', () => {
void test('Skills, Nexus, Discovery and unrelated reaction selectors remain outside the clean World scope', () => {
for (const field of [
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'sandtrout',
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
});
void test('a pending original Sandtrout preserves the live World context without becoming a module selection', () => {
const game = profile(['ixians', 'emperor', 'atreides'], ['ix'], {
homeworldOccupationPreview: true, techTokens: createTechTokens(),
});
for (const sandtrout of [undefined, false, true]) assert.equal(supported({ ...game, sandtrout }), true);
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
});
void test('canonical occupation and native benefit stamps retain their actual seated source and required parent', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['choam', 'ecaz'], { advanced: true,
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
@@ -135,7 +135,7 @@ void test('unrelated previews and mismatched native, parent, reaction or Banker
assert.equal(supported({ ...game, [field]: 'forged' }), false, field);
});
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral without changing clean helpers', () => {
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral in original module contexts', () => {
const custody = { visitors: { 'homeworld:ixians': { emperor: { normal: 1, elite: 1 } } } };
const discoveries = { turn: 2, tokens: [], encounters: [] };
const game = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus(), discoveryEnabled: true,
@@ -148,7 +148,8 @@ void test('live native Discovery, active printed Sandtrout and occupied custody
const plain = profile(); const trained = { ...plain, leaderSkills: true };
assert.equal(clean(plain), true); assert.equal(cleanSkills(trained), true);
assert.equal(supported(plain), false); assert.equal(supported(trained), false);
assert.equal(clean({ ...plain, sandtrout: true }), false); assert.equal(cleanSkills({ ...trained, sandtrout: true }), false);
assert.equal(clean({ ...plain, sandtrout: true }), true); assert.equal(cleanSkills({ ...trained, sandtrout: true }), true);
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
for (const value of [null, {}, { ...game, expansions: 'ix' }, { ...game, players: [{ faction: 'ixians' }, null] }])
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
});
@@ -57,9 +57,9 @@ void test('required modules, physical seat bounds and existing phase limits do n
]) assert.equal(supported(game), false);
});
void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and unrelated reaction selectors without changing library admission', () => {
void test('occupation benefit context excludes Nexus, Discovery and unrelated reaction selectors without changing library admission', () => {
for (const field of ['nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'sandtrout', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
const nexus = profile(undefined, undefined, { nexusCards: { cards: null, phase: null } });
@@ -67,6 +67,19 @@ void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and u
assert.equal(supported(nexus), false, 'This clean benefit slice does not claim that separate combination');
});
void test('pending printed Sandtrout retains occupation and all14 roles under the same original World admission', () => {
const game = profile(['ixians', 'emperor', 'atreides'], ['ix'], { advanced: true,
homeworldOccupationPreview: true, spiceBankerIncomePreview: true,
techTokens: createTechTokens(), strongholdCards: createStrongholdCards(),
});
for (const sandtrout of [undefined, false, true]) {
const active = { ...game, sandtrout };
assert.equal(admitted(active), true);
assert.equal(supported(active), true);
}
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
});
void test('canonical Banker and native occupation stamps require their actual parent factions and retain the Advanced assassination band', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true,
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true,
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
completeWorldSandtrout, createWorldSandtroutFixture, worldSandtroutDeferred as deferred,
worldSandtroutInventory as inventory, worldSandtroutPlayer as player,
worldSandtroutSkills as skills, worldSandtroutSpice as spice, worldSandtroutStocks as stocks,
} from './fixture-world-sandtrout';
for (const advanced of [false, true]) {
void test(`native ${advanced ? 'Advanced Worlds Skills Tech Strongholds' : 'Basic Worlds Tech'} Sandtrout waits through real phases and suppresses only the later worm`, () => {
const f = createWorldSandtroutFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'emperor', 'ixians']);
assert.deepEqual(f.game.expansions, ['ix']); assert.ok(f.game.homeworlds && f.game.techTokens);
assert.ok(f.game.homeworldOccupationPreview && f.game.homeworldOccupationHistory);
assert.equal(!!f.game.leaderSkills, advanced); assert.equal(!!f.game.strongholdCards, advanced);
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 47); assert.equal(f.originalSkills.length, advanced ? 14 : 0);
assert.equal(f.originalSpice.length, 22); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.beforeDraw.sandtrout, false); assert.equal(f.game.sandtrout, true);
assert.equal(viewGame(f.game, f.actor).sandtrout, true);
assert.deepEqual(stocks(f.game), stocks(f.beforeDraw));
assert.deepEqual(f.game.players.map(seat => seat.spice), f.beforeDraw.players.map(seat => seat.spice));
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(spice(f.game), f.originalSpice);
if (advanced) for (const [owner, skill] of Object.entries(f.training!)) {
const assignment = f.afterSetup.leaderSkills!.assignments.find(row => row.owner === owner); assert.ok(assignment);
assert.equal(assignment.skill, skill);
assert.ok(f.sourceActions.some(step => step.actor === owner && step.action.type === 'leaderSkill' &&
step.action.skill === skill && step.action.leader === assignment.leader));
}
const before = structuredClone(f.game), result = completeWorldSandtrout(f);
assert.deepEqual(f.game, before);
assert.equal(result.firstSpice.after.sandtrout, true); assert.equal(result.firstSpice.after.phase, 2);
assert.deepEqual(result.shipmentSources, { 'homeworld:atreides': { normal: 2, elite: 0 } });
assert.equal(player(result.shipment.after, f.actor).reserves, player(result.shipment.before, f.actor).reserves - 2);
assert.equal(player(result.shipment.after, f.actor).forces[f.targetLocation], 2);
assert.equal(player(result.shipment.before, f.actor).spice - player(result.shipment.after, f.actor).spice, 4);
const ordinary = quoteSpiceCollection(result.collection.before).receipts;
for (const seat of result.collection.before.players) {
const receipt = ordinary.find(row => row.player === seat.id);
assert.equal(player(result.collection.after, seat.id).spice - seat.spice,
(receipt ? receipt.strongholds + receipt.collected : 0) + (seat.id === result.techOwner ? result.techAmount : 0));
}
assert.equal(result.collection.after.sandtrout, true);
assert.ok(result.techAmount > 0);
assert.equal(result.collection.after.techTokens!.heighliners.spice, 0);
assert.equal(result.mentat.after.turn, 2); assert.equal(result.mentat.after.phase, 0);
assert.equal(result.mentat.after.sandtrout, true); assert.equal(result.storm.after.sandtrout, true);
assert.equal(player(result.mentat.after, 'emperor').spice - player(result.mentat.before, 'emperor').spice, advanced ? 1 : 0);
if (advanced) {
assert.deepEqual(result.bankerSource!.source.bankLegs, [{ payer: f.actor, amount: 4 }]);
assert.equal(result.bankerSource!.grant!.owner, f.banker);
assert.equal(deferred(result.collection.after), 1);
assert.deepEqual(result.bankerCollection!.credits, [{ owner: f.banker, amount: 1 }]);
assert.equal(result.mentat.after.strongholdCards!.claimedTurn, 1);
assert.equal(result.mentat.after.strongholdCards!.owners.arrakeen, f.actor);
assert.equal(result.mentat.after.strongholdCards!.owners.hidden_mobile_stronghold, 'ixians');
} else assert.equal(result.bankerSource, null);
assert.equal(deferred(result.mentat.after), 0);
const land = result.secondSpiceOrder[1].card; assert.ok('territory' in land);
assert.equal(result.revealed.spiceWindow!.amount, land.amount * 2);
assert.equal(result.revealed.spice[`${land.territory}:${land.sector}`] -
(result.beforeSuppression.spice[`${land.territory}:${land.sector}`] ?? 0), land.amount * 2);
assert.equal(result.revealed.sandtrout, false); assert.equal(result.revealed.nexus, false);
assert.deepEqual(stocks(result.revealed), stocks(result.beforeSuppression), 'The suppressed worm destroys no physical force');
assert.equal(result.revealed.spice[f.targetLocation], result.beforeSuppression.spice[f.targetLocation],
'The suppressed worm leaves its actual previous pile intact');
assert.deepEqual(result.revealed.players.map(seat => seat.spice), result.beforeSuppression.players.map(seat => seat.spice));
assert.equal(result.revealed.decision, null); assert.deepEqual(result.revealed.wormRides, []);
assert.equal(result.revealed.spiceDiscard.flat().filter(card => 'worm' in card && card.suppressed).length, 1);
assert.equal(result.revealed.spiceDiscard.flat().filter(card => 'sandtrout' in card).length, 1);
if (advanced) {
const next = result.secondSpiceOrder[2].card; assert.ok('territory' in next);
assert.equal(result.secondWindow!.spiceWindow!.territory, next.territory);
assert.equal(result.secondWindow!.spiceWindow!.amount, next.amount, 'Doubling is limited to the immediate replacement');
}
assert.equal(result.game.phase, 2); assert.equal(result.game.turn, 2); assert.equal(result.game.sandtrout, false);
for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, result.beforeSuppression.techTokens![id].owner);
assert.deepEqual(inventory(result.game), f.originalInventory); assert.deepEqual(skills(result.game), f.originalSkills);
assert.deepEqual(spice(result.game), f.originalSpice);
});
}
void test('native Basic World Sandtrout keeps the original ignored first-turn worm and waits for its later encounter', () => {
const f = createWorldSandtroutFixture({ firstTurnWorm: true });
assert.equal(f.game.turn, 1); assert.equal(f.game.sandtrout, true); assert.equal(f.game.nexus, false);
const land = f.spiceOrder[2].card; assert.ok('territory' in land);
assert.equal(f.game.spiceWindow!.amount, land.amount);
assert.equal(f.game.spiceSequence!.skipped.filter(card => 'worm' in card).length, 1);
assert.ok(f.game.log.some(entry => /first-turn worm rule leaves Sandtrout waiting/.test(entry.text)));
assert.deepEqual(spice(f.game), f.originalSpice);
const result = completeWorldSandtrout(f);
assert.equal(result.beforeSuppression.sandtrout, true); assert.equal(result.beforeSuppression.turn, 2);
assert.equal(result.revealed.nexus, false); assert.equal(result.revealed.sandtrout, false);
assert.equal(result.game.phase, 2); assert.deepEqual(spice(result.game), f.originalSpice);
});