Offer Semuta on native Storm and worm cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 02:18:55 +11:00
1 parent 4d09b6bffa
commit b3ea9f9c97
10 files changed
+1120 -6

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { validateStormCancellation } from '../game/disaster-preflight';
import { stormSectorAfter } from '../game/disaster-rules';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaFremenGuildMovement, nextSemutaFremenGuildMovement,
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
semutaFremenGuildMovementClean, semutaFremenGuildMovementInventory, semutaFremenGuildMovementPlayer,
} from './fixture-semuta-fremen-guild-movement';
export type SemutaDisasterStormKind = 'traversal' | 'shipment';
export type SemutaDisasterStormStep = { actor: string; action: Action };
export const actSemutaDisasterStorm = actSemutaFremenGuildMovement;
export const passSemutaDisasterStorm = passSemutaFremenGuildMovement;
export const claimSemutaDisasterStorm = claimSemutaFremenGuildMovement;
export const semutaDisasterStormPlayer = semutaFremenGuildMovementPlayer;
export const semutaDisasterStormInventory = semutaFremenGuildMovementInventory;
export const semutaDisasterStormClean = semutaFremenGuildMovementClean;
/** First-Storm dials are real controls. Every later distance remains the actual
* positive native Storm Card. Protection/fresh disposal stays caller-owned. */
export function nextSemutaDisasterStorm(game: Game,
kind: SemutaDisasterStormKind = 'traversal'): SemutaDisasterStormStep | null {
if (game.response?.kind === 'stormProtection' || game.pendingTreacheryDiscard || game.truthtrance) return null;
const step = nextSemutaFremenGuildMovement(game);
if (step?.action.type === 'stormDial' && game.turn === 1)
return { actor: step.actor, action: { ...step.action,
amount: step.actor === game.stormDialers[0] ? kind === 'shipment' ? 14 : 13 : 0 } };
return step;
}
export function advanceSemutaDisasterStorm(game: Game, until: (state: Game) => boolean,
actions: SemutaDisasterStormStep[] = [], kind: SemutaDisasterStormKind = 'traversal'): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaDisasterStorm(game, kind);
assert.ok(step, 'Resolve the actual protection or fresh-cost choice before advancing');
game = actSemutaDisasterStorm(game, step.actor, step.action, actions);
}
throw Error('The native Storm-protection source did not reach its finite boundary');
}
/** Allow only the original protection. A real mixed casualty chooser is left
* explicit, and later forecast/phase-opening controls are never answered here. */
export function allowSemutaDisasterStorm(game: Game, actions: SemutaDisasterStormStep[] = []): Game {
assert.equal(game.response?.kind, 'stormProtection');
for (let count = 0; count < game.players.length && game.response?.kind === 'stormProtection'; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actSemutaDisasterStorm(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, 'stormProtection'); return game;
}
/** Finish the actual casualty choice and original Storm's post-traversal market
* only. Stop at phase1, retaining its new forecast/opening; shipment stays on
* the already paid/free committed combined turn. No second arrival is issued. */
export function finishSemutaDisasterStorm(game: Game, kind: SemutaDisasterStormKind,
actions: SemutaDisasterStormStep[] = [], elite = 0): Game {
for (let count = 0; count < 20; count++) {
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original cost batch first');
if (game.decision?.kind === 'stormLosses') {
game = actSemutaDisasterStorm(game, game.decision.player, { type: 'decision', elite }, actions);
} else if (kind === 'traversal' && game.phase === 0 && game.decision?.kind === 'choamMarket') {
game = actSemutaDisasterStorm(game, game.decision.player, { type: 'decision', done: true }, actions);
} else {
assert.equal(game.stormResolution ?? null, null);
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
assert.equal(game.phase, kind === 'shipment' ? 5 : 1);
return game;
}
}
throw Error('The original Storm-protection aftermath did not settle');
}
/** Actual Advanced R/C/F/E preview: original deal/cache, native Fremen setup,
* first-Storm dials, free reserve entry, and (for traversal) the next real Storm.
* No played force, typed custody, clock, wallet or pending control is installed. */
export function createSemutaDisasterStormFixture(options: { kind: SemutaDisasterStormKind }) {
const kind = options.kind, richese = 'richese', holder = 'choam', owner = 'fremen', spender = 'emperor';
const key = 'the_great_flat:15', amount = 3, elite = 1, firstDistance = kind === 'shipment' ? 14 : 13;
let game = createGame('SEMUTADISASTERSTORM', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
for (const faction of [holder, owner, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaDisasterStormStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaDisasterStorm(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaDisasterStorm(game, until, actions, kind); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: 'original-advanced-semuta-initializer-on-ready-undealt-lobby' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaDisasterStormInventory(game); assert.equal(originalInventory.length, 45);
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
assert.equal(ordinary.length, 3);
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, from, position };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
assert.equal(semutaDisasterStormPlayer(game, owner).forces['sietch_tabr:14'], 10);
assert.equal(semutaDisasterStormPlayer(game, owner).elites?.reserves, 3);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < 4; position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ position, from, card: structuredClone(card) });
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bid: SemutaDisasterStormStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaDisasterStormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaDisasterStorm(game, kind); assert.ok(step);
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.equal(semutaDisasterStormPlayer(beforeBid, holder).spice - semutaDisasterStormPlayer(game, holder).spice, 1);
assert.equal(semutaDisasterStormPlayer(game, richese).spice - semutaDisasterStormPlayer(beforeBid, richese).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaDisasterStormClean(state));
assert.equal(game.storm, stormSectorAfter(1, firstDistance));
const nativeAction: Action = { type: 'ship', territory: 'the_great_flat', sector: 15, amount, elite, allyPayment: 0 };
const beforeDeclaration = structuredClone(game);
const shipment = transition(() => { act(owner, nativeAction); });
assert.equal(semutaDisasterStormPlayer(game, owner).forces[key], amount);
assert.equal(semutaDisasterStormPlayer(game, owner).elites?.forces[key], elite);
assert.equal(semutaDisasterStormPlayer(game, owner).reserves, semutaDisasterStormPlayer(beforeDeclaration, owner).reserves - amount);
assert.equal(semutaDisasterStormPlayer(game, owner).elites?.reserves, 2);
assert.equal(semutaDisasterStormPlayer(game, owner).spice, semutaDisasterStormPlayer(beforeDeclaration, owner).spice);
let beforeStorm: Game | null = null, traversal: ReturnType<typeof transition> | null = null;
if (kind === 'traversal') {
assert.equal(game.response ?? null, null); assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
act(owner, { type: 'endMovement' });
reach(state => state.turn === 2 && state.phase === 0 && !state.stormResolution && semutaDisasterStormClean(state));
beforeStorm = structuredClone(game);
traversal = transition(() => { reach(state => state.response?.kind === 'stormProtection'); });
assert.equal(game.stormResolution?.from, 14); assert.equal(game.stormResolution?.traversed, 0);
assert.ok(game.stormResolution && game.stormResolution.distance >= 1 && game.stormResolution.distance <= 6);
assert.deepEqual(game.stormResolution.pending, []);
} else {
assert.equal(game.pendingArrivalReaction?.length, 1);
assert.deepEqual(game.pendingArrivalReaction![0], {
entrant: owner, territory: 'the_great_flat', sector: 15, amount, elite,
cause: 'shipment', resume: 'none', turn: 1, phase: 5,
});
}
assert.equal(game.response?.kind, 'stormProtection'); assert.equal(game.response?.owner, owner);
assert.equal(game.response?.resume, kind === 'shipment' ? 'shipment' : 'storm');
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
validateStormCancellation(game, game.response!);
assert.equal(semutaDisasterStormPlayer(game, owner).tanks, 0);
assert.equal(semutaDisasterStormPlayer(game, owner).elites?.tanks, 0);
assert.ok(semutaDisasterStormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaDisasterStormPlayer(game, spender).hand.some(card => card.id === cost));
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
const originalResolution = structuredClone(game.stormResolution ?? null);
const originalArrival = structuredClone(game.pendingArrivalReaction ?? null);
const originalStormMovementSource = structuredClone(game.stormMovementSource ?? null);
const expectedStorm = kind === 'traversal' ? stormSectorAfter(originalResolution!.from, originalResolution!.distance) : game.storm;
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
assert.deepEqual(semutaDisasterStormInventory(game), originalInventory);
return { game, source, beforeCost: source, kind, advanced: true, richese, holder, spender, owner,
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, key, location: key, target: 'the_great_flat',
amount, elite, expectedStorm, firstDistance,
originalResponse, originalResolution, originalArrival, originalStormMovementSource,
beforeDeclaration, shipment, beforeStorm, traversal, initial, lobby, setup, initialization,
beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder, acquired, acquisition,
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Original Advanced Semuta initializer on its genuinely ready undealt R/C/F/E lobby; original main35/cache10 remain physical. Only unopened deal/Spice positions and scalar lotteries are selected. Emperor receives printed Karama; CHOAM really pays1 for original cache Semuta.',
'Fremen really places ten ordinary setup counters at protected Tabr14, retaining ten reserves including three native elite counters. Actual first Storm dials13+0 (traversal) or14+0 (shipment) end14 or15; Tabr remains safe.',
'Fremen actually ships three reserves, including one native elite, free to The Great Flat15. No played force, typed custody, wallet, clock or pending receipt is installed.',
kind === 'shipment' ? 'The physical shipment already committed and its original nine-field arrival is queued behind native stormProtection. Preserve that exact queue through the used-cost child; the native arrival drains after casualties without shipping again.' :
'The safe first-turn entry finishes; the next actual positive native Storm Card necessarily crosses Great Flat15 from14. Stop before traversed0 protection, with the physical mixed cohort and original full traversal inputs unchanged.',
'Native allowance loses ceil(3/2)=2 and offers the actual 0..1 elite casualty chooser; prevention loses all3 including the one elite. Finish only original casualties/Storm market, retaining later forecast/opening controls.',
] };
}
export type SemutaDisasterStormFixture = ReturnType<typeof createSemutaDisasterStormFixture>;
+253
View File
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { validateWormCancellation } from '../game/disaster-preflight';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { actTruthMovement } from './fixture-truth-movement';
import {
nextSemutaRicheseCacheKarama, semutaRicheseCacheKaramaInventory,
semutaRicheseCacheKaramaPlayer, semutaRicheseCacheKaramaClean,
} from './fixture-semuta-richese-cache-karama';
export type SemutaDisasterWormKind = 'wormSurvival' | 'wormAllyProtection' | 'wormPlacement';
export type SemutaDisasterWormStep = { actor: string; action: Action };
export const actSemutaDisasterWorm = actTruthMovement;
export const semutaDisasterWormPlayer = semutaRicheseCacheKaramaPlayer;
export const semutaDisasterWormInventory = semutaRicheseCacheKaramaInventory;
export const semutaDisasterWormClean = semutaRicheseCacheKaramaClean;
export const semutaDisasterWormPhysical = (game: Game) => ({
players: game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites, reserves: p.reserves,
tanks: p.tanks, spice: p.spice, shipped: p.shipped, moved: p.moved, ally: p.ally })),
spice: game.spice,
});
/** Original controls only. Fresh disposal is caller-owned; no new worm or
* optional replacement is requested by this continuation driver. */
export function nextSemutaDisasterWorm(game: Game): SemutaDisasterWormStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.phaseOpening && !game.response) {
const fremen = game.players.find(p => p.faction === 'fremen');
if (game.status === 'setup' && game.setupStage === 'forces' && fremen?.reserves === 20 &&
viewGame(game, fremen.id).setupPending.includes(fremen.id))
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection')
return { actor: decision.player, action: { type: 'decision', accept: true } };
}
return nextSemutaRicheseCacheKarama(game);
}
export const nextSemutaDisasterWormStep = nextSemutaDisasterWorm;
export function advanceSemutaDisasterWorm(game: Game, until: (state: Game) => boolean,
actions: SemutaDisasterWormStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaDisasterWorm(game);
assert.ok(step, `Choose the original fresh reaction at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaDisasterWorm(game, step.actor, step.action, actions);
}
throw Error('The native worm source did not reach its finite boundary');
}
export function passSemutaDisasterWorm(game: Game, actions: SemutaDisasterWormStep[] = []): Game {
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
game = actSemutaDisasterWorm(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaDisasterWorm(game: Game, holder: string,
actions: SemutaDisasterWormStep[] = []): Game {
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaDisasterWorm(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaDisasterWorm(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
/** Pass only this original response. A subsequent independent survival
* response, additional placement or ride remains a real later control. */
export function allowSemutaDisasterWorm(game: Game, actions: SemutaDisasterWormStep[] = []): Game {
const kind = game.response?.kind;
assert.ok(kind === 'wormSurvival' || kind === 'wormAllyProtection' || kind === 'wormPlacement');
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaDisasterWorm(game, actor.id, { type: 'passResponse' }, actions);
if (game.response?.kind !== kind || game.response.passed.length === 0) break;
}
return game;
}
/** Resolve actual remaining survival/ride/Nexus controls to quiet Charity.
* Never bypass a new fresh batch or request another producer. */
export function finishSemutaDisasterWorm(game: Game, actions: SemutaDisasterWormStep[] = []): Game {
const turn = game.turn;
return advanceSemutaDisasterWorm(game, state => state.turn === turn && state.phase === 2 &&
semutaDisasterWormClean(state), actions);
}
/** Genuine paid Semuta, original printed costs and natural Spice sequence.
* Unopened positions are selected before the corresponding native draws.
* No played hand, wallet, force, alliance, phase or pending frame is installed. */
export function createSemutaDisasterWormFixture(options: {
kind?: SemutaDisasterWormKind; advanced?: boolean; summoned?: boolean;
} = {}) {
const kind = options.kind ?? 'wormSurvival', advanced = options.advanced ?? kind === 'wormPlacement';
const summoned = options.summoned ?? false;
assert.ok(kind !== 'wormPlacement' || advanced, 'Additional worm placement is Advanced only');
assert.ok(!summoned || advanced && kind === 'wormSurvival', 'The bounded summoned source is Advanced survival only');
const richese = 'richese', holder = 'choam', owner = 'fremen', guild = 'guild', spender = 'emperor';
const location = 'funeral_plain:15', allyLocation = 'the_great_flat:15';
let game = createGame('SEMUTADISASTERWORM', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['choam', 'fremen', 'guild', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaDisasterWormStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaDisasterWorm(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaDisasterWorm(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaDisasterWormInventory(game); assert.equal(originalInventory.length, 45);
const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2);
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
const selected = [ordinary[0], ordinary[1], karamas[0], ordinary[2], karamas[1]];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), cost = karamas[1].id, summonCost = karamas[0].id;
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const orderSpice = (faces: string[]) => {
for (let position = 0; position < faces.length; position++) {
const face = faces[position];
const from = game.spiceDeck.findIndex((card, index) => index >= position &&
(face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker
: 'territory' in card && card.territory === face && !card.discovery));
assert.ok(from >= position, `The unopened native Spice stock needs ${face}`);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
}
};
orderSpice(advanced ? ['funeral_plain', 'hagga_basin'] : ['funeral_plain']);
let beforeBid: Game | undefined, bid: SemutaDisasterWormStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaDisasterWormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaDisasterWorm(game); assert.ok(step);
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.equal(semutaDisasterWormPlayer(beforeBid, holder).spice - semutaDisasterWormPlayer(game, holder).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && semutaDisasterWormClean(state));
assert.equal(game.storm, 1);
const shipments: { actor: string; location: string; cost: number; before: Game; steps: SemutaDisasterWormStep[]; after: Game }[] = [];
for (let count = 0; game.turn === 1 && game.phase === 5 && count < 160; count++) {
if (semutaDisasterWormClean(game) && (game.active === holder || game.active === owner) &&
!semutaDisasterWormPlayer(game, game.active!).shipped) {
const actor = game.active!, destination = actor === holder ? 'the_great_flat' : 'funeral_plain';
const paid = actor === holder ? 2 : 0;
const shipment = transition(() => {
act(actor, { type: 'ship', territory: destination, sector: 15, amount: 1, elite: 0, allyPayment: 0 });
reach(state => semutaDisasterWormClean(state));
});
assert.equal(semutaDisasterWormPlayer(shipment.before, actor).spice - semutaDisasterWormPlayer(game, actor).spice, paid);
shipments.push({ actor, location: `${destination}:15`, cost: paid, ...shipment });
} else {
const step = nextSemutaDisasterWorm(game); assert.ok(step); act(step.actor, step.action);
}
}
assert.equal(shipments.length, 2); assert.equal(semutaDisasterWormPlayer(game, owner).forces[location], 1);
assert.equal(semutaDisasterWormPlayer(game, holder).forces[allyLocation], 1);
const beforeBlow = (state: Game, turn: number) => state.turn === turn && state.phase === 1 &&
semutaDisasterWormClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0;
reach(state => beforeBlow(state, 2));
const beforeSecondBlow = structuredClone(game);
orderSpice(summoned ? ['the_great_flat', 'broken_land']
: [...(kind === 'wormPlacement' ? ['worm', 'worm'] : ['worm']), 'the_great_flat', ...(advanced ? ['broken_land'] : [])]);
const priorSurvivals: { before: Game; steps: SemutaDisasterWormStep[]; after: Game }[] = [];
let nativeAction: Action | null = null, beforeDeclaration: Game | null = null;
let summonDiscard: { before: Game; steps: SemutaDisasterWormStep[]; after: Game } | null = null;
let alliance: { before: Game; steps: SemutaDisasterWormStep[]; after: Game } | null = null;
if (summoned) {
beforeDeclaration = structuredClone(game);
nativeAction = { type: 'card', mode: 'special', card: summonCost, territory: 'funeral_plain' };
act(owner, nativeAction);
if (game.pendingTreacheryDiscard) summonDiscard = transition(() => { game = passSemutaDisasterWorm(game, actions); });
reach(state => state.response?.kind === 'wormSurvival');
assert.ok(game.summonedWorm); assert.ok(game.summonedWorm.resume);
} else {
reach(state => state.response?.kind === 'wormSurvival');
if (kind !== 'wormSurvival') priorSurvivals.push(transition(() => { game = allowSemutaDisasterWorm(game, actions); }));
if (kind === 'wormPlacement') {
reach(state => state.decision?.kind === 'wormPlacement' && !state.response && !state.phaseOpening);
beforeDeclaration = structuredClone(game); nativeAction = { type: 'decision', accept: true, territory: 'funeral_plain' };
act(owner, nativeAction);
} else if (kind === 'wormAllyProtection') {
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
!state.spiceResolution && semutaDisasterWormClean(state));
alliance = transition(() => {
act(holder, { type: 'alliance', target: owner }); act(owner, { type: 'alliance', target: holder });
});
assert.equal(semutaDisasterWormPlayer(game, holder).ally, owner);
assert.equal(semutaDisasterWormPlayer(game, owner).ally, holder);
reach(state => beforeBlow(state, 3));
orderSpice(['worm', 'rock_outcroppings', ...(advanced ? ['oh_gap'] : [])]);
reach(state => state.decision?.kind === 'wormProtection' && !state.response && !state.phaseOpening);
beforeDeclaration = structuredClone(game); nativeAction = { type: 'decision', accept: true };
act(owner, nativeAction);
}
}
assert.ok(game.response); assert.equal(game.response.kind, kind); assert.equal(game.response.owner, owner);
assert.ok(!game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.battle);
validateWormCancellation(game, game.response);
assert.ok(semutaDisasterWormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaDisasterWormPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaDisasterWormInventory(game), originalInventory);
const source = structuredClone(game), originalResponse = structuredClone(game.response), territory = game.response.location!;
const recipient = game.response.recipient ?? null, targetLocation = `${territory}:15`;
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source, beforeCost: source, kind, advanced, summoned, holder, spender, owner, recipient, richese, guild,
cost, karama: cost, semuta: SEMUTA_DRUG_ID, summonCost, action, nativeAction, beforeDeclaration,
territory, target: territory, location: targetLocation, fremenLocation: location, allyLocation,
originalResponse, originalPhysical: structuredClone(semutaDisasterWormPhysical(game)),
originalSequence: structuredClone(game.spiceSequence ?? null), originalResolution: structuredClone(game.spiceResolution ?? null),
originalRides: [...game.wormRides], originalSummoned: structuredClone(game.summonedWorm ?? null),
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
originalInventory, originalSpice, spiceOrder, acquired, acquisition, shipments, beforeSecondBlow,
priorSurvivals, alliance, summonDiscard, actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
advanced ? 'Original Advanced Semuta preview initializer runs on the ready undealt R/C/F/G/E lobby, preserving main35/cache10. No Basic-to-Advanced conversion.' : 'Actual public Basic R/C/F/G/E ready/start preserves original main35/cache10.',
'Only original unopened starting-card/Spice positions and scalar lotteries are selected. Fremen and Emperor each receive a distinct original printed Karama. Fremen actually places ten starting normals at Sietch Tabr; CHOAM actually purchases cache Semuta for one.',
'CHOAM actually pays two to ship one normal to Great Flat15; Fremen actually freely ships one normal to Funeral Plain15. First native Storm dials are zero. Real Collection and subsequent Storm controls occur; neither cohort is installed or protected artificially.',
kind === 'wormAllyProtection' ? 'Turn2 natural worm protects the separate Funeral cohort through real passes, then CHOAM and Fremen actually propose/accept their Nexus alliance. Turn3 natural worm hits the real Great Flat pile and CHOAM cohort. Fremen is absent there: this proves native ally protection without claiming a co-present independent survival branch.' : kind === 'wormPlacement' ? 'Two consecutive original turn2 worms create the actual Advanced additional-placement chooser. Fremen really chooses Funeral Plain, after the earlier survival was allowed; the additional native response is unpaid and unresolved.' : summoned ? 'Advanced Fremen actually spends its separately dealt original Karama to summon at Funeral before the turn2 blow. Every earlier fresh-discard participant really passes, retaining CHOAM Semuta. The full original summoned resume remains native.' : 'The original turn2 natural worm hits the real first-turn Funeral pile and Fremen cohort; stop at its native survival response.',
'Stop before Emperor spends its originally dealt printed Karama. No played custody, wallet, forces, ally, Spice, clock, queued ride or pending response is assigned.',
],
};
}
export type SemutaDisasterWormFixture = ReturnType<typeof createSemutaDisasterWormFixture>;
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { validateStormCancellation } from '../game/disaster-preflight';
import {
createSemutaDisasterStormFixture as fixture, actSemutaDisasterStorm as act,
passSemutaDisasterStorm as pass, claimSemutaDisasterStorm as claim,
allowSemutaDisasterStorm as allow, finishSemutaDisasterStorm as finish,
semutaDisasterStormPlayer as player, semutaDisasterStormInventory as inventory,
type SemutaDisasterStormFixture,
} from './fixture-semuta-disaster-storm';
const kinds = ['traversal', 'shipment'] as const;
const physical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready,
stormResolution: game.stormResolution, stormMovementSource: game.stormMovementSource,
stormDials: game.stormDials, stormPending: game.stormPending, stormDialers: game.stormDialers,
stormCard: game.stormCard, stormCardKnown: game.stormCardKnown,
pendingArrivalReaction: game.pendingArrivalReaction, basicAlliedShipment: game.basicAlliedShipment,
pendingAmbassador: game.pendingAmbassador, pendingTerrorEntry: game.pendingTerrorEntry,
pendingArrivalOverlap: game.pendingArrivalOverlap, movementRemaining: game.movementRemaining,
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
});
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaDisasterStormFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'fremen', 'emperor']);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.owner).forces['sietch_tabr:14'], 10);
assert.equal(player(f.afterSetup, f.owner).elites?.reserves, 3);
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'fremenSetup'));
assert.equal(f.acquisition.price, 1);
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial')
.map(step => Number(step.action.amount)).sort((a, b) => a - b), [0, f.firstDistance]);
assert.equal(f.shipment.steps.length, 1); assert.deepEqual(f.shipment.steps[0], { actor: f.owner, action: f.nativeAction });
assert.equal(player(f.shipment.before, f.owner).spice, player(f.shipment.after, f.owner).spice);
assert.equal(player(f.shipment.after, f.owner).reserves, player(f.shipment.before, f.owner).reserves - 3);
assert.equal(player(f.shipment.after, f.owner).elites?.reserves, 2);
assert.equal(player(f.shipment.after, f.owner).forces[f.key], 3);
assert.equal(player(f.shipment.after, f.owner).elites?.forces[f.key], 1);
assert.equal(player(f.shipment.after, f.owner).shipped, true);
assert.equal(player(f.game, f.owner).forces['sietch_tabr:14'], 10, 'The native protected setup group survives');
assert.equal(player(f.game, f.owner).forces[f.key], 3); assert.equal(player(f.game, f.owner).elites?.forces[f.key], 1);
assert.equal(player(f.game, f.owner).tanks, 0); assert.equal(player(f.game, f.owner).elites?.tanks, 0);
assert.equal(f.game.response?.kind, 'stormProtection'); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
validateStormCancellation(f.game, f.game.response!);
if (f.kind === 'shipment') {
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.storm, 15);
assert.equal(f.game.active, f.owner); assert.equal(f.game.response?.resume, 'shipment');
assert.equal(f.game.response?.location, f.key); assert.equal(f.game.response?.amount, 3); assert.equal(f.game.response?.elite, 1);
assert.equal(f.originalResolution, null); assert.equal(f.beforeStorm, null); assert.equal(f.traversal, null);
assert.deepEqual(f.originalArrival, [{ entrant: f.owner, territory: f.target, sector: 15,
amount: 3, elite: 1, cause: 'shipment', resume: 'none', turn: 1, phase: 5 }]);
} else {
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); assert.equal(f.game.storm, 14);
assert.equal(f.game.response?.resume, 'storm'); assert.ok(f.beforeStorm && f.traversal && f.originalResolution);
assert.equal(f.originalResolution.from, 14); assert.equal(f.originalResolution.traversed, 0);
assert.ok(f.originalResolution.distance >= 1 && f.originalResolution.distance <= 6);
assert.deepEqual(f.originalResolution.pending, []); assert.equal(f.originalArrival?.length ?? 0, 0);
assert.deepEqual(player(f.game, f.owner).forces, player(f.shipment.after, f.owner).forces);
assert.deepEqual(player(f.game, f.owner).elites?.forces, player(f.shipment.after, f.owner).elites?.forces);
}
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaDisasterStormFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaDisasterDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:stormProtection');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
if (pending.continuation.kind !== 'karamaDisasterDiscard') throw Error('Use the exact native disaster cost');
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.deepEqual(physical(game), physical(f.game));
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function physicalOutcome(game: Game, f: SemutaDisasterStormFixture, losses: number, eliteLosses: number) {
assert.equal(player(game, f.owner).tanks, player(f.game, f.owner).tanks + losses);
assert.equal(player(game, f.owner).elites?.tanks, (player(f.game, f.owner).elites?.tanks ?? 0) + eliteLosses);
assert.equal(player(game, f.owner).forces[f.key] ?? 0, f.amount - losses);
assert.equal(player(game, f.owner).elites?.forces[f.key] ?? 0, f.elite - eliteLosses);
assert.equal(player(game, f.owner).forces['sietch_tabr:14'], 10);
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(game, f.owner).elites?.reserves, player(f.game, f.owner).elites?.reserves);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(game, f.owner).shipped, player(f.game, f.owner).shipped);
assert.equal(player(game, f.owner).moved, player(f.game, f.owner).moved);
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
assert.equal(game.stormResolution ?? null, null); assert.equal(game.storm, f.expectedStorm);
assert.equal(game.phase, f.kind === 'shipment' ? 5 : 1); assert.equal(game.turn, f.game.turn);
assert.deepEqual(game.spiceDeck, f.game.spiceDeck); assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
if (f.kind === 'shipment') {
assert.equal(game.active, f.owner); assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
assert.deepEqual(game.ready, f.game.ready); assert.deepEqual(game.stormMovementSource ?? null, f.originalStormMovementSource);
} else {
assert.equal(game.stormMovementSource ?? null, null);
assert.notEqual(game.response?.kind, 'stormProtection');
}
assert.deepEqual(inventory(game), f.originalInventory);
}
function prevented(game: Game, f: SemutaDisasterStormFixture, claimed: boolean) {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.notEqual(game.decision?.kind, 'stormLosses'); assert.notEqual(game.response?.kind, 'stormProtection');
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
const steps: { actor: string; action: Action }[] = [];
game = finish(game, f.kind, steps);
assert.ok(steps.every(step => step.action.type === 'decision' && step.action.done === true));
physicalOutcome(game, f, 3, 1);
const snapshot = structuredClone(game); assert.deepEqual(finish(game, f.kind), snapshot);
return game;
}
for (const kind of kinds) for (const claimed of [false, true]) void test(
`native Advanced ${kind} Storm protection cost ${claimed ? 'claim' : 'pass'} precedes full casualties and exact original continuation`, () => {
const f = fixture({ kind }); nativeSource(f);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
reject(game, f.spender, f.action);
});
for (const kind of kinds) for (const elite of [0, 1]) void test(
`native Advanced ${kind} allowance keeps its real half-casualty chooser and ${elite} elite loss`, () => {
const f = fixture({ kind }); nativeSource(f);
let game = allow(structuredClone(f.game));
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.decision?.kind, 'stormLosses');
if (game.decision?.kind !== 'stormLosses') throw Error('Use the actual typed casualty decision');
assert.equal(game.decision.player, f.owner); assert.equal(game.decision.key, f.key);
assert.equal(game.decision.amount, 2); assert.equal(game.decision.minElite, 0); assert.equal(game.decision.maxElite, 1);
assert.equal(game.decision.resume, kind === 'traversal' ? 'storm' : 'shipment');
assert.equal(player(game, f.owner).forces[f.key], 3); assert.equal(player(game, f.owner).tanks, 0);
assert.equal(player(game, f.owner).elites?.forces[f.key], 1);
if (kind === 'traversal') {
assert.equal(game.stormResolution?.traversed, 1); assert.deepEqual(game.stormResolution?.pending, []);
} else assert.deepEqual(game.pendingArrivalReaction, f.originalArrival);
game = act(game, f.owner, { type: 'decision', elite });
game = finish(game, kind);
physicalOutcome(game, f, 2, elite);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
reject(game, f.owner, { type: 'decision', elite });
});
void test('fresh disaster costs reject stale and underlying replies without consuming the original traversal or arrival', () => {
for (const kind of kinds) {
const f = fixture({ kind }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event });
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.spender, f.action);
reject(game, f.owner, { type: 'decision', elite: 0 });
reject(game, f.owner, kind === 'shipment' ? f.nativeAction : { type: 'ready' });
assert.deepEqual(physical(game), physical(f.game));
prevented(pass(game), f, false);
}
});
void test('the offered Fremen Storm cost rejects a mismatched queued arrival before any Semuta reply', () => {
const f = fixture({ kind: 'shipment' }); nativeSource(f);
// Negative receipt control after actual cost admission: the earned journey
// and physical cohort stay intact; this does not widen native pre-cost rules.
const game = structuredClone(open(f)), event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingArrivalReaction?.length, 1);
game.pendingArrivalReaction![0].amount++;
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(game.pendingTreacheryDiscard?.batch.event, event);
});
void test('all four minimal policies answer both actual disaster-cost sources legally', () => {
for (const kind of kinds) {
const f = fixture({ kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
assert.deepEqual(own, before);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
let game = act(structuredClone(offered), f.holder, action);
const claimed = action.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
const selected = botActions(choosing)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
prevented(game, f, claimed);
}
}
});
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { validateWormCancellation } from '../game/disaster-preflight';
import {
createSemutaDisasterWormFixture as fixture, actSemutaDisasterWorm as act,
passSemutaDisasterWorm as pass, claimSemutaDisasterWorm as claim,
allowSemutaDisasterWorm as allow, finishSemutaDisasterWorm as finish,
semutaDisasterWormPlayer as player, semutaDisasterWormInventory as inventory,
semutaDisasterWormPhysical as physical, type SemutaDisasterWormFixture,
} from './fixture-semuta-disaster-worm';
const variants = [
{ kind: 'wormSurvival' as const, advanced: false },
{ kind: 'wormSurvival' as const, advanced: true },
{ kind: 'wormAllyProtection' as const, advanced: false },
{ kind: 'wormAllyProtection' as const, advanced: true },
{ kind: 'wormPlacement' as const, advanced: true },
{ kind: 'wormSurvival' as const, advanced: true, summoned: true },
];
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, ready: game.ready, active: game.active,
storm: game.storm, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
spiceSequence: game.spiceSequence, spiceResolution: game.spiceResolution, spiceWindow: game.spiceWindow,
wormRides: game.wormRides, summonedWorm: game.summonedWorm, summonedBeforeBlow: game.summonedBeforeBlow,
nexus: game.nexus, wormPlacementCanceledTurn: game.wormPlacementCanceledTurn });
const appearances = (game: Game, territory: string) => {
const name = territory === 'funeral_plain' ? 'Funeral Plain' : 'The Great Flat';
return game.log.filter(row => row.text === `Shai-Hulud appeared in ${name}.`).length;
};
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaDisasterWormFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'fremen', 'guild', 'emperor']);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.ok(player(f.afterSetup, f.owner).hand.some(card => card.id === f.summonCost && card.effect === 'karama'));
assert.notEqual(f.cost, f.summonCost);
assert.equal(player(f.afterSetup, f.owner).forces['sietch_tabr:14'], 10);
if (f.advanced) assert.equal(player(f.afterSetup, f.owner).elites?.reserves, 3);
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
assert.equal(player(f.acquired, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.equal(f.shipments.length, 2);
for (const shipment of f.shipments) {
assert.ok(shipment.steps.some(step => step.actor === shipment.actor && step.action.type === 'ship' &&
step.action.amount === 1 && step.action.elite === 0));
assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice, shipment.cost);
assert.equal(player(shipment.after, shipment.actor).forces[shipment.location], 1);
assert.equal(player(shipment.before, shipment.actor).reserves - player(shipment.after, shipment.actor).reserves, 1);
}
assert.equal(player(f.game, f.owner).forces[f.fremenLocation], 1);
assert.equal(player(f.game, f.holder).forces[f.allyLocation], 1);
assert.equal(f.game.turn, f.kind === 'wormAllyProtection' ? 3 : 2); assert.equal(f.game.phase, 1);
assert.equal(f.game.response?.kind, f.kind); assert.deepEqual(f.game.response, f.originalResponse);
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); validateWormCancellation(f.game, f.game.response!);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
if (f.kind === 'wormAllyProtection') {
assert.ok(f.alliance); assert.equal(f.alliance.steps.length, 2);
assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
assert.equal(player(f.game, f.owner).ally, f.holder); assert.equal(player(f.game, f.holder).ally, f.owner);
assert.equal(player(f.game, f.owner).forces[f.location] ?? 0, 0, 'The protector is genuinely remote');
assert.equal(f.recipient, f.holder); assert.equal(f.priorSurvivals.length, 1);
} else {
assert.equal(f.alliance, null); assert.equal(f.recipient, null);
assert.equal(player(f.game, f.owner).ally ?? null, null);
}
if (f.kind === 'wormPlacement') {
assert.equal(f.advanced, true); assert.equal(f.beforeDeclaration?.decision?.kind, 'wormPlacement');
assert.deepEqual(f.nativeAction, { type: 'decision', accept: true, territory: 'funeral_plain' });
assert.equal(f.priorSurvivals.length, 1); assert.equal(appearances(f.game, f.territory), 1);
assert.deepEqual(f.originalRides, ['funeral_plain']);
}
for (const previous of f.priorSurvivals) {
assert.equal(previous.before.response?.kind, 'wormSurvival');
assert.ok(previous.steps.every(step => step.action.type === 'passResponse'));
}
if (f.summoned) {
assert.ok(f.originalSummoned); assert.deepEqual(f.originalSummoned.resume, f.game.summonedWorm!.resume);
assert.ok(f.summonDiscard, 'The original summon cost really passed its earlier fresh batch');
assert.ok(f.summonDiscard.steps.every(step => step.action.type === 'semutaPass'));
assert.equal(player(f.game, f.owner).specialKaramaUsed, true);
assert.equal(f.game.discard.filter(card => card.id === f.summonCost).length, 1);
assert.equal(f.game.spiceSequence ?? null, null); assert.equal(f.game.spiceResolution ?? null, null);
} else assert.equal(f.originalSummoned, null);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaDisasterWormFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaDisasterDiscard');
if (pending.continuation.kind !== 'karamaDisasterDiscard') throw Error('Expected the original worm cancellation cost');
assert.equal(pending.batch.cause, `karama:${f.kind}`); assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.equal(appearances(game, f.territory), appearances(f.game, f.territory));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function outcome(state: Game, f: SemutaDisasterWormFixture, canceled: boolean) {
const beforeFinish = structuredClone(state), finishSteps: { actor: string; action: Action }[] = [];
const game = finish(state, finishSteps), victim = f.kind === 'wormAllyProtection' ? f.holder : f.owner;
const loss = Number(canceled && f.kind !== 'wormPlacement');
assert.equal(game.turn, f.game.turn); assert.equal(game.phase, 2); assert.equal(game.response ?? null, null);
assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.spiceSequence ?? null, null); assert.equal(game.spiceResolution ?? null, null);
assert.equal(game.spiceWindow ?? null, null); assert.equal(game.nexus, false); assert.deepEqual(game.wormRides, []);
assert.equal(game.summonedWorm ?? null, null);
assert.equal(player(game, victim).forces[f.location] ?? 0, 1 - loss);
assert.equal(player(game, victim).tanks, player(f.game, victim).tanks + loss);
assert.equal(player(game, victim).elites?.tanks ?? 0, player(f.game, victim).elites?.tanks ?? 0);
assert.equal(player(game, f.owner).forces['sietch_tabr:14'], 10);
for (const p of f.game.players) {
assert.equal(player(game, p.id).reserves, p.reserves);
assert.equal(player(game, p.id).elites?.reserves, p.elites?.reserves);
if (p.id !== victim) {
assert.deepEqual(player(game, p.id).forces, p.forces); assert.equal(player(game, p.id).tanks, p.tanks);
}
}
assert.equal(appearances(game, f.territory) - appearances(f.game, f.territory),
Number(!(canceled && f.kind === 'wormPlacement')));
if (f.kind === 'wormPlacement') {
assert.equal(game.wormPlacementCanceledTurn ?? null, canceled ? game.turn : f.game.wormPlacementCanceledTurn ?? null);
if (!canceled) assert.ok(beforeFinish.response?.kind === 'wormSurvival' ||
finishSteps.some(step => step.action.type === 'passResponse'), 'The later real survival window receives actual passes');
}
if (f.kind === 'wormAllyProtection') {
assert.equal(player(game, f.owner).ally, f.holder); assert.equal(player(game, f.holder).ally, f.owner);
assert.equal(player(game, f.owner).forces[f.fremenLocation], 1);
}
if (f.summoned) {
assert.equal(player(game, f.owner).specialKaramaUsed, true);
assert.equal(game.discard.filter(card => card.id === f.summonCost).length, 1);
}
assert.ok(finishSteps.every(step => step.action.type !== 'ship' && step.action.type !== 'card'));
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function denied(state: Game, f: SemutaDisasterWormFixture, claimed: boolean) {
const game = outcome(state, f, true);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
reject(game, f.spender, f.action); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.summoned ? 'summoned ' : ''}${variant.kind} cost Semuta ${claimed ? 'claim' : 'pass'} resumes the original worm once`, () => {
const f = fixture(variant); nativeSource(f); const offered = open(f);
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native worm allowances retain protection and resolve actual additional survival and summoned parents', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f); const game = outcome(allow(structuredClone(f.game)), f, false);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
}
});
void test('native worm cost reactions reject stale foreign and underlying replies before any devouring or draw', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
if (f.nativeAction) reject(game, f.owner, f.nativeAction);
if (f.summonDiscard) reject(game, f.holder, { type: 'semutaCommit', event: f.summonDiscard.before.pendingTreacheryDiscard!.batch.event });
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
}
});
void test('all four minimal profiles legally claim or pass native survival ally placement and summoned worm costs', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
let game = act(structuredClone(offered), f.holder, proposed);
const claimed = proposed.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
denied(game, f, claimed);
}
}
});