Offer Semuta on native movement cancellation costs
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user