Offer Semuta on native movement cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 01:28:26 +11:00
1 parent 30c066726b
commit d018a506e1
9 files changed
+972 -6

No files matched your search

@@ -0,0 +1,227 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory,
semutaRicheseCacheKaramaPlayer,
} from './fixture-semuta-richese-cache-karama';
export type SemutaFremenGuildMovementStep = { actor: string; action: Action };
export const actSemutaFremenGuildMovement = actSemutaRicheseCacheKarama;
export const semutaFremenGuildMovementPlayer = semutaRicheseCacheKaramaPlayer;
export const semutaFremenGuildMovementInventory = semutaRicheseCacheKaramaInventory;
export const semutaFremenGuildMovementClean = semutaRicheseCacheKaramaClean;
export function nextSemutaFremenGuildMovement(game: Game, guildWait = false): SemutaFremenGuildMovementStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.response && !game.phaseOpening) {
const fremen = game.players.find(player => player.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 } } };
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
}
const next = nextSemutaRicheseCacheKarama(game);
if (next?.action.type === 'stormDial' && game.turn === 1)
return { actor: next.actor, action: { ...next.action,
amount: guildWait && next.actor === game.stormDialers[0] ? 6 : 0 } };
return next;
}
export function advanceSemutaFremenGuildMovement(game: Game, until: (state: Game) => boolean,
actions: SemutaFremenGuildMovementStep[] = [], guildWait = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaFremenGuildMovement(game, guildWait);
assert.ok(next, 'Resolve the original fresh reaction or native declaration before continuing');
game = actSemutaFremenGuildMovement(game, next.actor, next.action, actions);
}
throw Error('The native Fremen/Guild source did not reach its finite boundary');
}
export function semutaFremenGuildMove(game: Game, range: 1 | 2, from = 'sietch_tabr:14'): Action {
const to = TERRITORIES.filter(territory => territory.type === 'sand').flatMap(territory =>
territory.sectors.filter(sector => sector !== game.storm).map(sector => location(territory.id, sector)))
.find(to => gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) === range);
assert.ok(to, `The original Tabr group needs a legal range-${range} sand destination`);
const destination = splitLocation(to);
return { type: 'move', from, amount: 1, elite: 0, territory: destination.territory, sector: destination.sector };
}
/** Original public Basic/undealt Advanced setup and paid cache1. WAIT uses only
* actual lobby circle choices and first-Storm dials, never installed order. */
export function createSemutaFremenGuildMovementFixture(options: {
kind?: 'fremenMovement' | 'guildTiming'; advanced?: boolean; take?: boolean;
} = {}) {
const kind = options.kind ?? 'fremenMovement';
const advanced = options.advanced ?? kind === 'guildTiming', take = options.take ?? true;
assert.ok(kind !== 'guildTiming' || advanced, 'Flexible native Guild timing is Advanced only');
const guildWait = kind === 'guildTiming' && !take;
const richese = 'richese', holder = 'choam', fremen = 'fremen', guild = 'guild', spender = 'emperor';
const owner = kind === 'fremenMovement' ? fremen : guild;
let game = createGame('SEMUTAFREMENGUILDMOVEMENT', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of [holder, fremen, guild, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaFremenGuildMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaFremenGuildMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => {
game = advanceSemutaFremenGuildMovement(game, until, actions, guildWait);
};
if (guildWait) {
act(fremen, { type: 'seatPosition', position: 6 });
act(guild, { type: 'seatPosition', position: 3 });
act(fremen, { type: 'seatPosition', position: 4 });
}
for (const player of game.players) act(player.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(player => player.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaFremenGuildMovementInventory(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').slice(0, 4); assert.equal(ordinary.length, 4);
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 { position, from, card };
});
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(semutaFremenGuildMovementPlayer(game, fremen).forces['sietch_tabr:14'], 10);
assert.ok(semutaFremenGuildMovementPlayer(game, spender).hand.some(card => card.id === cost));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (advanced ? 2 : 1); position++) {
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
spiceOrder.push({ position, from, card: structuredClone(card) });
}
game.spiceDeck.unshift(...front);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bid: SemutaFremenGuildMovementStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaFremenGuildMovementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaFremenGuildMovement(game, guildWait); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
}
act(next.actor, next.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(semutaFremenGuildMovementPlayer(beforeBid, holder).spice - semutaFremenGuildMovementPlayer(game, holder).spice, 1);
assert.equal(semutaFremenGuildMovementPlayer(game, richese).spice - semutaFremenGuildMovementPlayer(beforeBid, richese).spice, 1);
let shipment: { before: Game; action: Action; after: Game; steps: SemutaFremenGuildMovementStep[] } | null = null;
let beforeDeclaration: Game, nativeAction: Action;
if (kind === 'fremenMovement') {
reach(state => state.phase === 5 && state.active === fremen && semutaFremenGuildMovementClean(state));
const before = structuredClone(game), start = actions.length;
const action: Action = { type: 'ship', territory: 'the_great_flat', sector: 15, amount: 1, elite: 0, allyPayment: 0 };
act(fremen, action); reach(semutaFremenGuildMovementClean);
shipment = { before, action, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).shipped, true);
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).moved, 0);
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).forces['the_great_flat:15'], 1);
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).reserves, semutaFremenGuildMovementPlayer(before, fremen).reserves - 1);
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).spice, semutaFremenGuildMovementPlayer(before, fremen).spice);
beforeDeclaration = structuredClone(game); nativeAction = semutaFremenGuildMove(game, 2);
act(fremen, nativeAction);
assert.ok(game.pendingFremenMove);
assert.deepEqual(semutaFremenGuildMovementPlayer(game, fremen), semutaFremenGuildMovementPlayer(beforeDeclaration, fremen));
} else {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
assert.equal(game.decision?.player, guild);
assert.equal(game.movementRemaining![0] === guild, !take);
if (guildWait) {
assert.equal(game.playerPositions![guild], 3);
assert.equal(PLAYER_CIRCLE_SECTORS[game.playerPositions![guild] - 1], 8);
assert.equal(game.storm, 7);
assert.deepEqual(actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount).sort((a,b) => Number(a)-Number(b)), [0, 6]);
}
beforeDeclaration = structuredClone(game); nativeAction = { type: 'decision', take };
act(guild, nativeAction);
assert.equal(game.response?.take, take); assert.equal(game.active, null);
}
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision, null);
assert.ok(!game.pendingTreacheryDiscard);
assert.ok(semutaFremenGuildMovementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaFremenGuildMovementPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaFremenGuildMovementInventory(game), originalInventory);
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
return {
game, source, beforeCost: source, kind, advanced, take, guildWait,
richese, holder, fremen, guild, spender, owner, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
action, nativeAction, beforeDeclaration, pending: structuredClone(game.pendingFremenMove ?? null),
originalResponse: structuredClone(game.response), originalOrder: [...game.order],
originalRemaining: [...game.movementRemaining!], originalActive: game.active,
shipment, initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder,
originalSpice, spiceOrder, acquired, acquisition, originalInventory, inventory: originalInventory,
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
advanced ? 'Original Advanced Semuta initializer on the ready undealt R/C/F/G/E lobby.' : 'Actual public bare Basic R/C/F/G/E ready/start initializes its original Semuta preview.',
'Only unopened original deal/Spice positions and scalar lotteries are selected. Fremen really places ten starting forces at Tabr14; Emperor really receives its printed Karama. CHOAM pays one in the native first cache auction for the original Semuta.',
kind === 'fremenMovement'
? 'Earlier original combined turns finish. Fremen actually ships one native reserve free to The Great Flat15, then declares one ordinary Tabr force to a shared-board distance-two sand destination. Its actual native response retains the exact uncommitted group and move0.'
: guildWait
? 'Actual lobby circle moves Fremen to temporary empty6, Guild to vacated3, then Fremen to vacated4. Printed circle3 points to sector8. Native first-Storm dials6+0 move the storm from1 to7, genuinely making Guild first. Guild really chooses WAIT out of order; no order, clock, grant or pending response is installed.'
: 'Default native circles and original first-Storm zero dials leave Guild nonfirst. At the actual first phase-five timing decision Guild chooses TAKE out of order, before any physical combined turn.',
'Stop before the exact dealt cancellation cost. No played hand, wallet, force, clock, queue, shipment allowance or pending receipt is assigned.',
],
};
}
export type SemutaFremenGuildMovementFixture = ReturnType<typeof createSemutaFremenGuildMovementFixture>;
export function passSemutaFremenGuildMovement(game: Game, actions: SemutaFremenGuildMovementStep[] = []): 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(player => !reaction.passed.includes(player.id)); assert.ok(actor);
game = actSemutaFremenGuildMovement(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaFremenGuildMovement(game: Game, holder: string,
actions: SemutaFremenGuildMovementStep[] = []): 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 = actSemutaFremenGuildMovement(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaFremenGuildMovement(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
export function allowSemutaFremenGuildMovement(game: Game, actions: SemutaFremenGuildMovementStep[] = []): Game {
const kind = game.response?.kind; assert.ok(kind === 'fremenMovement' || kind === 'guildTiming');
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actSemutaFremenGuildMovement(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, kind); return game;
}
export function replaceSemutaFremenMovement(game: Game, fixture: Pick<SemutaFremenGuildMovementFixture, 'fremen'>,
actions: SemutaFremenGuildMovementStep[] = []) {
assert.equal(game.active, fixture.fremen); assert.equal(game.response, null);
const before = structuredClone(game), replacement = semutaFremenGuildMove(game, 1);
game = actSemutaFremenGuildMovement(game, fixture.fremen, replacement, actions);
game = advanceSemutaFremenGuildMovement(game, semutaFremenGuildMovementClean, actions);
const afterReplacement = structuredClone(game), end: Action = { type: 'endMovement' };
assert.equal(semutaFremenGuildMovementPlayer(game, fixture.fremen).moved, 1);
game = actSemutaFremenGuildMovement(game, fixture.fremen, end, actions);
return { game, before, replacement, afterReplacement, end };
}
+167
View File
@@ -0,0 +1,167 @@
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>;
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { location } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaFremenGuildMovementFixture as fixture, actSemutaFremenGuildMovement as act,
passSemutaFremenGuildMovement as pass, claimSemutaFremenGuildMovement as claim,
allowSemutaFremenGuildMovement as allow, replaceSemutaFremenMovement as replace,
semutaFremenGuildMovementInventory as inventory, semutaFremenGuildMovementPlayer as player,
type SemutaFremenGuildMovementFixture,
} from './fixture-semuta-fremen-guild-movement';
const variants = [
{ kind: 'fremenMovement' as const, advanced: false },
{ kind: 'fremenMovement' as const, advanced: true },
{ kind: 'guildTiming' as const, advanced: true, take: true },
{ kind: 'guildTiming' as const, advanced: true, take: false },
];
const physical = (game: Game) => ({
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })),
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order,
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
});
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: SemutaFremenGuildMovementFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.fremen).forces['sietch_tabr:14'], 10);
assert.ok(f.actions.some(step => step.actor === f.fremen && 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.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.game.phase, 5); assert.equal(f.game.turn, 1);
assert.equal(f.game.response?.kind, f.kind); 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.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
if (f.kind === 'fremenMovement') {
assert.ok(f.shipment && f.pending);
assert.equal(player(f.game, f.owner).shipped, true); assert.equal(player(f.game, f.owner).moved, 0);
assert.equal(player(f.game, f.owner).forces['the_great_flat:15'], 1);
assert.equal(player(f.game, f.owner).spice, player(f.shipment.before, f.owner).spice);
assert.equal(f.pending.turn, f.game.turn); assert.equal(f.pending.move, 0);
assert.deepEqual(f.pending.order.group, [['sietch_tabr:14', 1]]);
assert.equal(f.pending.order.elite, 0);
assert.deepEqual(player(f.game, f.owner), player(f.beforeDeclaration, f.owner));
} else {
assert.equal(f.game.response?.take, f.take);
assert.equal(f.game.active, null);
assert.equal(f.game.movementRemaining![0] === f.guild, !f.take);
assert.equal(f.game.guildTimingGranted, false); assert.equal(f.game.guildTimingLocked, false);
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration));
if (!f.take) {
assert.equal(f.game.storm, 7); assert.equal(f.game.playerPositions![f.guild], 3);
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount).sort((a,b) => Number(a)-Number(b)), [0, 6]);
assert.equal(f.actions.filter(step => step.action.type === 'seatPosition').length, 3);
}
}
}
function open(f: SemutaFremenGuildMovementFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.continuation.kind, 'karamaMovementDiscard');
assert.equal(pending.batch.cause, `karama:${f.kind}`);
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
[{ id: f.cost, by: f.spender, public: true }]);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.response ?? null, null);
if (pending.continuation.kind !== 'karamaMovementDiscard') throw Error('Use the exact native movement-cost continuation');
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
assert.equal(pending.continuation.use.kind, 'cancel');
assert.equal(pending.continuation.use.response.kind, f.kind);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.deepEqual(physical(game), physical(f.game));
assert.deepEqual(game.movementRemaining, f.originalRemaining); assert.equal(game.active, f.originalActive);
assert.equal(game.guildTimingGranted, f.game.guildTimingGranted);
assert.equal(game.guildTimingLocked, f.game.guildTimingLocked);
assert.deepEqual(game.pendingFremenMove ?? null, f.pending);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
function prevented(game: Game, f: SemutaFremenGuildMovementFixture, claimed: boolean) {
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.deepEqual(physical(game), physical(f.game));
assert.deepEqual(game.movementRemaining, f.originalRemaining);
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));
if (f.kind === 'fremenMovement') {
assert.equal(game.pendingFremenMove ?? null, null);
assert.equal(game.active, f.fremen); assert.equal(player(game, f.fremen).moved, 0);
assert.deepEqual(player(game, f.fremen).fremenMovementBlocked, { turn: game.turn, move: 0 });
assert.deepEqual(player(game, f.fremen).forces, player(f.beforeDeclaration, f.fremen).forces);
reject(game, f.fremen, f.nativeAction);
} else {
assert.equal(game.active, f.originalRemaining[0]);
assert.equal(game.guildTimingLocked, true); assert.equal(game.guildTimingGranted, false);
assert.ok(game.players.every(player => !player.shipped && player.moved === 0));
}
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
function finishReplacement(game: Game, f: SemutaFremenGuildMovementFixture) {
const result = replace(game, f), destination = location(result.replacement.territory as string, result.replacement.sector as number);
assert.equal(player(result.afterReplacement, f.fremen).moved, 1);
assert.equal(player(result.afterReplacement, f.fremen).forces['sietch_tabr:14'], player(game, f.fremen).forces['sietch_tabr:14'] - 1);
assert.equal(player(result.afterReplacement, f.fremen).forces[destination], (player(game, f.fremen).forces[destination] ?? 0) + 1);
assert.equal(player(result.afterReplacement, f.fremen).spice, player(game, f.fremen).spice);
assert.equal(player(result.afterReplacement, f.fremen).shipped, true);
assert.equal(result.end.type, 'endMovement');
assert.ok(!result.game.movementRemaining!.includes(f.fremen));
assert.deepEqual(inventory(result.game), f.originalInventory);
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} ${variant.kind}${variant.kind === 'guildTiming' ? variant.take ? ' TAKE' : ' WAIT' : ''} printed cost is ${claimed ? 'claimed' : 'passed'} before native prevention`, () => {
const f = fixture(variant); nativeSource(f);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
const game = prevented(claimed ? claim(pending, f.holder) : pass(pending), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
if (f.kind === 'fremenMovement') finishReplacement(game, f);
});
void test('native allowance controls commit the original range-two group or the exact Guild timing once without card costs', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f);
const game = allow(structuredClone(f.game));
assert.equal(game.pendingTreacheryDiscard ?? null, null);
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));
if (f.kind === 'fremenMovement') {
const to = location(f.nativeAction.territory as string, f.nativeAction.sector as number);
assert.equal(game.pendingFremenMove ?? null, null);
assert.equal(player(game, f.fremen).moved, 1); assert.equal(player(game, f.fremen).shipped, true);
assert.equal(player(game, f.fremen).forces['sietch_tabr:14'], player(f.game, f.fremen).forces['sietch_tabr:14'] - 1);
assert.equal(player(game, f.fremen).forces[to], (player(f.game, f.fremen).forces[to] ?? 0) + 1);
assert.equal(player(game, f.fremen).fremenMovementBlocked ?? null, null);
} else {
assert.deepEqual(physical(game), physical(f.game));
assert.equal(game.guildTimingGranted, true); assert.equal(game.guildTimingLocked, false);
const expected = f.take ? f.originalRemaining : [...f.originalRemaining.filter(id => id !== f.guild), f.guild];
assert.deepEqual(game.movementRemaining, expected);
assert.equal(game.active, f.take ? f.guild : expected[0]);
}
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('the exact fresh movement cost rejects underlying declarations and stale reactions without repeating physical settlement', () => {
for (const variant of variants) {
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
reject(pending, f.spender, f.action);
reject(pending, f.owner, f.nativeAction);
reject(pending, f.owner, { type: 'passResponse' });
const game = prevented(claim(pending, f.holder), f, true);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
}
});
void test('all four minimal policies answer every original Fremen/Guild movement-cost source legally', () => {
for (const variant of variants) {
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(pending, 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(pending), f.holder, action);
const claimed = action.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(player => player.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);
prevented(game, f, claimed);
}
}
});
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Action, type Game, viewGame } from '../game/engine';
import { MOBILE_LOCATION, mobileRouteDistance } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaIxianMovementFixture as fixture, actSemutaIxianMovement as act,
advanceSemutaIxianMovement as advance, passSemutaIxianMovement as pass,
claimSemutaIxianMovement as claim, allowSemutaIxianMovement as allow,
semutaIxianMovementPlayer as player, semutaIxianMovementInventory as inventory,
semutaIxianMovementClean as clean, semutaIxianMovementStorm as storm,
type SemutaIxianMovementFixture,
} from './fixture-semuta-ixian-movement';
const forcesAndMoney = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice,
forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
moved: p.moved, shipped: p.shipped }));
function reject(game: Game, actor: string, proposed: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaIxianMovementFixture) {
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.ok(f.parent.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
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.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(f.originalPoint, 'polar_sink:0'); assert.equal(f.game.mobileStronghold?.location, f.originalPoint);
assert.equal(player(f.game, f.owner).forces[MOBILE_LOCATION], 6);
assert.equal(player(f.game, f.owner).elites!.forces[MOBILE_LOCATION], 3);
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
if (f.kind === 'ixMovement') {
assert.ok(f.shipment && f.replacement && f.destination && f.replacementDestination);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
assert.equal(player(f.shipment.before, f.owner).spice - player(f.shipment.after, f.owner).spice, 2);
assert.equal(player(f.game, f.owner).forces[f.sourceLocation], 1);
assert.equal(player(f.game, f.owner).elites!.forces[f.sourceLocation], 1);
assert.equal(player(f.game, f.owner).moved, 0); assert.equal(player(f.game, f.owner).shipped, true);
assert.deepEqual(f.game.pendingIxMove, f.pending);
} else {
assert.ok(f.route && f.collection.length && f.collectionAmount > 0);
assert.ok(mobileRouteDistance(f.route) <= 3); assert.equal(f.route[0], f.originalPoint);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
assert.equal(f.beforeDeclaration.decision?.kind, 'mobileStronghold');
assert.equal(f.nativeAction.collect, true); assert.deepEqual(f.game.pendingMobileMove, f.pending);
assert.equal(f.stormBefore.turnBeginnings, 0, 'Native Storm initialization follows this actual HMS opportunity');
for (const pile of f.collection) assert.equal(f.game.spice[pile.location], pile.available);
assert.equal(f.actions.filter(step => step.action.type === 'endMovement').length, f.game.players.length);
}
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaIxianMovementFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'karamaMovementDiscard');
if (pending.continuation.kind !== 'karamaMovementDiscard') throw Error('Expected the exact movement cancellation cost');
assert.deepEqual(pending.continuation.use.response, f.game.response);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
assert.deepEqual(forcesAndMoney(game), forcesAndMoney(f.game));
assert.deepEqual(game.pendingIxMove, f.game.pendingIxMove); assert.deepEqual(game.pendingMobileMove, f.game.pendingMobileMove);
assert.deepEqual(game.mobileStronghold, f.game.mobileStronghold); assert.deepEqual(game.spice, f.game.spice);
assert.deepEqual(game.movementRemaining, f.game.movementRemaining); assert.equal(game.active, f.game.active);
assert.deepEqual(storm(game), f.stormBefore, 'No native Storm initialization/card draw precedes the cost reaction');
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); return game;
}
function costCompleted(game: Game, f: SemutaIxianMovementFixture, claimed: boolean) {
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(inventory(game), f.originalInventory);
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));
}
function groundDenied(game: Game, f: SemutaIxianMovementFixture, claimed: boolean) {
costCompleted(game, f, claimed); assert.ok(f.replacement && f.replacementDestination && f.destination);
assert.equal(game.pendingIxMove, null); assert.equal(game.active, f.owner);
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
assert.deepEqual(forcesAndMoney(game), forcesAndMoney(f.game)); assert.deepEqual(storm(game), f.stormBefore);
assert.deepEqual(player(game, f.owner).ixMovementBlocked, { turn: f.game.turn, move: player(f.game, f.owner).moved });
assert.equal(player(game, f.owner).moved, 0); reject(game, f.owner, f.nativeAction);
game = act(game, f.owner, f.replacement); game = advance(game, clean);
assert.equal(player(game, f.owner).moved, 1); assert.equal(player(game, f.owner).shipped, true);
assert.equal(player(game, f.owner).forces[f.sourceLocation] ?? 0, 0);
assert.equal(player(game, f.owner).elites!.forces[f.sourceLocation] ?? 0, 0);
assert.equal(player(game, f.owner).forces[f.replacementDestination], 1);
assert.equal(player(game, f.owner).elites!.forces[f.replacementDestination], 1);
assert.equal(player(game, f.owner).forces[f.destination] ?? 0, 0);
assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], 6);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.owner, f.nativeAction); return game;
}
function initializedOnce(game: Game, f: SemutaIxianMovementFixture) {
const after = storm(game);
assert.equal(after.turnBeginnings, f.stormBefore.turnBeginnings + 1);
assert.equal(after.revealedCards.length, f.stormBefore.revealedCards.length + Number(f.advanced));
if (f.advanced) {
assert.ok(after.revealedCards.at(-1)! >= 1 && after.revealedCards.at(-1)! <= 6);
assert.deepEqual(game.stormDialers, []);
} else assert.deepEqual(game.stormDialers, f.game.lastBattle);
}
function hmsDenied(game: Game, f: SemutaIxianMovementFixture, claimed: boolean) {
costCompleted(game, f, claimed); assert.equal(game.pendingMobileMove, null);
assert.equal(game.mobileStronghold?.location, f.originalPoint);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
assert.deepEqual(player(game, f.owner).forces, player(f.game, f.owner).forces);
for (const pile of f.collection) assert.equal(game.spice[pile.location], pile.available);
assert.notEqual(game.decision?.kind, 'mobileStronghold'); initializedOnce(game, f);
const continued = advance(game, state => state.phase === 1 && clean(state));
assert.equal(continued.turn, 2); assert.equal(continued.mobileStronghold?.location, f.originalPoint);
assert.equal(player(continued, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(continued, f.owner).forces[MOBILE_LOCATION], 6);
for (const pile of f.collection) assert.equal(continued.spice[pile.location], pile.available);
assert.deepEqual(inventory(continued), f.originalInventory); initializedOnce(continued, f); return continued;
}
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native Cyborg-range cost Semuta ${claimed ? 'claim' : 'pass'} keeps the paid group and permits its real one-step replacement`, () => {
const f = fixture({ advanced }); nativeSource(f); const offered = open(f);
groundDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native HMS collection cost Semuta ${claimed ? 'claim' : 'pass'} leaves the real Spice pile and begins the original Storm once`, () => {
const f = fixture({ kind: 'mobileStronghold', advanced }); nativeSource(f); const offered = open(f);
hmsDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('original Ixian range and positive HMS collection allowances retain their actual native transfers and Storm suffix', () => {
for (const advanced of [false, true]) for (const kind of ['ixMovement', 'mobileStronghold'] as const) {
const f = fixture({ advanced, kind }), game = allow(structuredClone(f.game));
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));
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(inventory(game), f.originalInventory);
if (kind === 'ixMovement') {
assert.ok(f.destination); assert.equal(game.pendingIxMove, null);
assert.equal(player(game, f.owner).forces[f.destination], 1);
assert.equal(player(game, f.owner).elites!.forces[f.destination], 1);
assert.equal(player(game, f.owner).forces[f.sourceLocation] ?? 0, 0);
assert.equal(player(game, f.owner).moved, 1); assert.equal(player(game, f.owner).ixMovementBlocked, undefined);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
} else {
assert.equal(game.pendingMobileMove, null); assert.equal(game.mobileStronghold?.location, f.destination);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + f.collectionAmount);
for (const pile of f.collection) assert.equal(game.spice[pile.location] ?? 0, pile.available - pile.collected);
initializedOnce(game, f);
}
}
});
void test('native Ixian movement cost reactions reject stale, foreign and underlying actions before transfer or Storm initialization', () => {
for (const advanced of [false, true]) for (const kind of ['ixMovement', 'mobileStronghold'] as const) {
const f = fixture({ advanced, 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.spender, f.action);
reject(game, f.owner, f.nativeAction); reject(game, f.owner, { type: 'passResponse' });
if (kind === 'mobileStronghold') reject(game, f.owner, { type: 'ready' });
const done = kind === 'ixMovement' ? groundDenied(pass(game), f, false) : hmsDenied(pass(game), f, false);
reject(done, f.holder, { type: 'semutaCommit', event }); reject(done, f.spender, f.action);
}
});
void test('all four minimal profiles legally answer native Basic and Advanced Cyborg or HMS movement costs', () => {
for (const advanced of [false, true]) for (const kind of ['ixMovement', 'mobileStronghold'] as const) {
const f = fixture({ advanced, kind }), 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);
if (kind === 'ixMovement') groundDenied(game, f, claimed); else hmsDenied(game, f, claimed);
}
}
});