199 lines
15 KiB
TypeScript
199 lines
15 KiB
TypeScript
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>;
|