Offer Semuta on native Storm and worm cancellation costs
This commit is contained in:
1 parent
4d09b6bffa
commit
b3ea9f9c97
10 files changed
+1120
-6
No files matched your search
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user