Files
Dune/tests/fixture-semuta-ixian-movement.ts
T

168 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { MOBILE_LOCATION, TERRITORIES, location, mobileRoutes, mobileRouteDistance, splitLocation } from '../game/board';
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { stormCardDistance } from '../game/storm-cards';
import {
createSemutaIxianAuctionFixture, actSemutaIxianAuction, nextSemutaIxianAuctionStep,
semutaIxianAuctionPlayer, semutaIxianAuctionInventory, type SemutaIxianAuctionStep,
} from './fixture-semuta-ixian-auction';
export type SemutaIxianMovementStep = SemutaIxianAuctionStep;
export type SemutaIxianMovementKind = 'ixMovement' | 'mobileStronghold';
export const actSemutaIxianMovement = actSemutaIxianAuction;
export const semutaIxianMovementPlayer = semutaIxianAuctionPlayer;
export const semutaIxianMovementInventory = semutaIxianAuctionInventory;
export const semutaIxianMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingIxMove &&
!game.pendingMobileMove && !game.pendingChoamMove;
export function semutaIxianMovementStorm(game: Game) {
return { turn: game.turn, phase: game.phase, storm: game.storm, pending: game.stormPending,
source: game.stormMovementSource, dialers: game.stormDialers, dials: game.stormDials,
card: game.stormCard, known: game.stormCardKnown,
revealedCards: game.log.flatMap(entry => { const distance = stormCardDistance(entry.component); return distance === null ? [] : [distance]; }),
turnBeginnings: game.log.filter(entry => entry.text === `Turn ${game.turn} begins.`).length };
}
/** Never silently answer the tested movement counter, fresh disposal or native
* next-turn HMS route chooser. Other original optional controls are declined. */
export function nextSemutaIxianMovementStep(game: Game): SemutaIxianMovementStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance ||
game.response?.kind === 'ixMovement' || game.response?.kind === 'mobileStronghold') return null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'mobileStronghold' && !game.decision.placement)
return null;
return nextSemutaIxianAuctionStep(game);
}
export function advanceSemutaIxianMovement(game: Game, until: (state: Game) => boolean,
actions: SemutaIxianMovementStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaIxianMovementStep(game);
assert.ok(next, 'Choose the original movement/route or fresh reaction before continuing');
game = actSemutaIxianMovement(game, next.actor, next.action, actions);
}
throw Error('The native Ixian movement programme did not reach its finite boundary');
}
export function passSemutaIxianMovement(game: Game, actions: SemutaIxianMovementStep[] = []): 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 = actSemutaIxianMovement(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaIxianMovement(game: Game, holder: string,
actions: SemutaIxianMovementStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaIxianMovement(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaIxianMovement(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
/** Finish only the current native allowance. HMS returns at the real newly
* initialized Storm boundary; caller decides when to reveal/traverse it. */
export function allowSemutaIxianMovement(game: Game, actions: SemutaIxianMovementStep[] = []): Game {
const kind = game.response?.kind; assert.ok(kind === 'ixMovement' || kind === 'mobileStronghold');
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 = actSemutaIxianMovement(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, kind);
if (kind === 'ixMovement') game = advanceSemutaIxianMovement(game, semutaIxianMovementClean, actions);
return game;
}
/** Fork the exact native paid-Semuta source before its unused auction counter,
* then actually allow all original setup/Bidding/Revival controls. No card,
* resource, force, phase, queue, route or pending movement is installed. */
export function createSemutaIxianMovementFixture(options: { kind?: SemutaIxianMovementKind; advanced?: boolean } = {}) {
const kind = options.kind ?? 'ixMovement', advanced = options.advanced ?? false;
const parent = createSemutaIxianAuctionFixture({ advanced });
let game = structuredClone(parent.game);
const actions: SemutaIxianMovementStep[] = structuredClone(parent.actions);
const owner = parent.owner, holder = parent.holder, spender = parent.spender, cost = parent.cost;
const act = (actor: string, action: Action) => { game = actSemutaIxianMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxianMovement(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const originalPoint = game.mobileStronghold!.location; assert.equal(originalPoint, 'polar_sink:0');
const source = 'polar_sink:0';
let shipment: { before: Game; steps: SemutaIxianMovementStep[]; after: Game } | null = null;
let replacement: Action | null = null, destination: string | null = null, replacementDestination: string | null = null;
let route: string[] | null = null;
let collection: { location: string; available: number; collected: number }[] = [];
let nativeAction: Action;
if (kind === 'ixMovement') {
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaIxianMovementClean(state));
shipment = transition(() => {
act(owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 1, allyPayment: 0 });
reach(semutaIxianMovementClean);
});
assert.equal(semutaIxianMovementPlayer(shipment.before, owner).spice - semutaIxianMovementPlayer(game, owner).spice, 2);
assert.equal(semutaIxianMovementPlayer(game, owner).forces[source], 1);
assert.equal(semutaIxianMovementPlayer(game, owner).elites!.forces[source], 1);
assert.equal(semutaIxianMovementPlayer(game, owner).forces[MOBILE_LOCATION], 6);
assert.equal(semutaIxianMovementPlayer(game, owner).moved, 0);
const view = viewGame(game, owner), own = view.players.find(p => p.id === owner)!;
const emptySand = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => location(t.id, sector)))
.filter(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)));
destination = emptySand.find(key => groundMoveRouteDistance(view, own, source, key) === 2 &&
botGroundMoveAllowed(view, own, source, key, 1)) ?? null;
replacementDestination = emptySand.find(key => groundMoveRouteDistance(view, own, source, key) === 1 &&
botGroundMoveAllowed(view, own, source, key, 1)) ?? null;
assert.ok(destination && replacementDestination, 'The actually shipped Cyborg has real two- and one-step destinations');
nativeAction = { type: 'move', forces: { [source]: 1 }, eliteForces: { [source]: 1 }, ...splitLocation(destination) };
replacement = { type: 'move', forces: { [source]: 1 }, eliteForces: { [source]: 1 }, ...splitLocation(replacementDestination) };
} else {
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'mobileStronghold' && !state.decision.placement);
const passengers = semutaIxianMovementPlayer(game, owner).forces[MOBILE_LOCATION]; assert.equal(passengers, 6);
// Prefer a genuinely positive prior blow beyond the next ordinary Storm's
// maximum sector7, so post-Storm continuation can also inspect the same pile.
route = mobileRoutes(game, 3).find(candidate => candidate.some(key => (game.spice[key] ?? 0) > 0) &&
candidate.filter(key => (game.spice[key] ?? 0) > 0).every(key => splitLocation(key).sector > 7)) ?? null;
assert.ok(route, `Native HMS needs a printed <=3 route over actual prior Spice: ${JSON.stringify(game.spice)}`);
assert.ok(mobileRouteDistance(route) <= 3);
collection = route.filter(key => (game.spice[key] ?? 0) > 0).map(key => ({ location: key,
available: game.spice[key], collected: Math.min(game.spice[key], passengers * 2) }));
assert.ok(collection.length && collection.reduce((sum, pile) => sum + pile.collected, 0) > 0);
nativeAction = { type: 'decision', route, collect: true };
destination = route.at(-1)!;
}
const beforeDeclaration = structuredClone(game), declaration = transition(() => act(owner, nativeAction));
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
if (kind === 'ixMovement') {
assert.ok(game.pendingIxMove); assert.equal(semutaIxianMovementPlayer(game, owner).moved, 0);
assert.deepEqual(semutaIxianMovementPlayer(game, owner).forces, semutaIxianMovementPlayer(beforeDeclaration, owner).forces);
} else {
assert.deepEqual(game.pendingMobileMove?.route, route); assert.equal(game.pendingMobileMove?.collect, true);
assert.equal(game.mobileStronghold!.location, originalPoint);
assert.deepEqual(game.spice, beforeDeclaration.spice);
}
assert.ok(semutaIxianMovementPlayer(game, holder).hand.some(card => card.id === parent.semuta));
assert.ok(semutaIxianMovementPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaIxianMovementInventory(game), parent.originalInventory);
const gameSource = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source: gameSource, beforeCost: gameSource, kind, advanced, owner, holder, spender,
cost, karama: cost, card: cost, semuta: parent.semuta, action, nativeAction, beforeDeclaration, declaration,
pending: structuredClone(kind === 'ixMovement' ? game.pendingIxMove : game.pendingMobileMove),
shipment, sourceLocation: source, destination, replacement, replacementDestination,
originalPoint, route, collect: kind === 'mobileStronghold', collection,
collectionAmount: collection.reduce((sum, pile) => sum + pile.collected, 0),
stormBefore: structuredClone(semutaIxianMovementStorm(game)), parent,
acquisition: parent.acquisition, acquired: parent.acquired, setup: parent.setup, afterSetup: parent.afterSetup,
originalInventory: parent.originalInventory, originalCards: parent.originalInventory,
inheritedActionCount: parent.sourceActionCount, actions, steps: actions, sourceActionCount: actions.length,
staging: [...parent.staging,
'Fork the original paid-cache source before its unused Karama counter, then actually allow the original Ix pool, optional Technology decline, Atreides inspection and native normal auctions/Revival. Originally dealt Atreides Karama and paid CHOAM Semuta remain held.',
kind === 'ixMovement' ? 'Ix actually pays2 to ship one original Cyborg reserve to Polar Sink, then declares an empty two-step sand destination through its printed movement advantage. A distinct empty one-step native replacement is exposed; no move or counter is precompleted.' : `Every actual first-turn combined turn ends without moving the six original HMS passengers. Native first Collection leaves the actual prior Spice blow untouched. At the original second-turn HMS chooser, declare collect:true on the real printed route ${route!.join(' → ')} over ${collection.map(pile => `${pile.location}=${pile.available}`).join(', ')}. No Spice, route, force or income is installed.`,
'Stop before the actual cancellation cost. Native ground denial keeps the current move unused; native HMS denial clears the route and begins the original Storm exactly once. A later Storm reveal/resolution is a legitimate suffix, not state that this source pretends frozen.',
] };
}
export type SemutaIxianMovementFixture = ReturnType<typeof createSemutaIxianMovementFixture>;