Offer Semuta on transport, Gamont and Storm conversion costs
This commit is contained in:
1 parent
06b0b354d0
commit
27959b4655
11 files changed
+1413
-2
No files matched your search
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { strongholdProgress } from '../game/victory-progress';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching,
|
||||
semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory,
|
||||
} from './fixture-semuta-advisor-matching';
|
||||
|
||||
export type SemutaAdvisorGamontStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorGamont = actSemutaAdvisorMatching;
|
||||
export const passSemutaAdvisorGamont = passSemutaAdvisorMatching;
|
||||
export const claimSemutaAdvisorGamont = claimSemutaAdvisorMatching;
|
||||
export const semutaAdvisorGamontPlayer = semutaAdvisorMatchingPlayer;
|
||||
export const semutaAdvisorGamontInventory = semutaAdvisorMatchingInventory;
|
||||
export const semutaAdvisorGamontClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingAutomaticAdvisorGamont && !game.pendingAutomaticAdvisorFlips?.length;
|
||||
export const semutaAdvisorGamontPhysical = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks,
|
||||
shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived }));
|
||||
export const semutaAdvisorGamontStrongholds = (game: Game, id: string) =>
|
||||
strongholdProgress(game).progress.find(p => p.player === id)!.strongholds;
|
||||
|
||||
/** Native markets close without selling the held fuel. Original final CHOAM
|
||||
* confirmation, conversion and every fresh group remain caller-owned. */
|
||||
export function nextSemutaAdvisorGamont(game: Game): SemutaAdvisorGamontStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision?.kind === 'choamMentat') return null;
|
||||
return nextSemutaAdvisorMatching(game);
|
||||
}
|
||||
export const nextSemutaAdvisorGamontStep = nextSemutaAdvisorGamont;
|
||||
export function advanceSemutaAdvisorGamont(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorGamontStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorGamont(game);
|
||||
assert.ok(step, `Choose the original Gamont/conversion/final control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAdvisorGamont(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Semuta/Gamont programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorGamont(game: Game, actions: SemutaAdvisorGamontStep[] = []): Game {
|
||||
const source = game.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'gamont');
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.event === source.event && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorGamont(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.event, source.event); return game;
|
||||
}
|
||||
/** Settle this original conversion only. Stop at ordinary Mentat or the
|
||||
* restored final chooser, before any actual victory confirmation. */
|
||||
export function finishSemutaAdvisorGamont(game: Game, actions: SemutaAdvisorGamontStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaAdvisorGamont(game, state => !state.response && !state.phaseOpening &&
|
||||
!state.pendingTreacheryDiscard && !state.pendingAutomaticAdvisorGamont && !state.pendingAutomaticAdvisorFlips?.length &&
|
||||
(!state.decision || state.decision.kind === 'choamMentat'), actions);
|
||||
}
|
||||
/** Explicit original ready/market/final-confirmation actions, after the caller
|
||||
* has resolved conversion. No synthetic victory or next turn is assigned. */
|
||||
export function closeSemutaAdvisorGamont(game: Game, actions: SemutaAdvisorGamontStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
for (let count = 0; count < 160; count++) {
|
||||
if (game.status === 'finished' || game.turn > turn) return game;
|
||||
assert.ok(!game.response && !game.pendingTreacheryDiscard && !game.pendingAutomaticAdvisorGamont &&
|
||||
!game.pendingAutomaticAdvisorFlips?.length, 'Resolve the actual conversion/fresh cost before confirming Mentat');
|
||||
const step = game.decision?.kind === 'choamMentat' && !game.phaseOpening
|
||||
? { actor: game.decision.player, action: { type: 'decision', done: true } satisfies Action }
|
||||
: nextSemutaAdvisorGamont(game);
|
||||
assert.ok(step); game = actSemutaAdvisorGamont(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Gamont Mentat did not reach victory or its next turn');
|
||||
}
|
||||
|
||||
/** Genuine paid Semuta and original Gamont/Karama. A native return and its
|
||||
* completed fuel disposal earn the automatic conversion source before cost. */
|
||||
export function createSemutaAdvisorGamontFixture(options: { final?: boolean } = {}) {
|
||||
const final = options.final ?? true, seller = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides';
|
||||
const territory = 'arrakeen', location = 'arrakeen:10';
|
||||
let game = createGame('SEMUTAADVISORGAMONT', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorGamontStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorGamont(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorGamont(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-advanced-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorGamontInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'), gamontCard = game.deck.find(card => card.name === 'Trip to Gamont');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); assert.equal(ordinary.length, 2);
|
||||
assert.ok(printed && gamontCard);
|
||||
const selected = [ordinary[0], gamontCard, ordinary[1], printed], dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: { before: Game; steps: SemutaAdvisorGamontStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaAdvisorGamont(game); assert.ok(step);
|
||||
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(step.actor, step.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game), cost = printed.id, gamont = gamontCard.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorGamontPlayer(game, owner).forces[location], 1); assert.equal(isAdvisor(semutaAdvisorGamontPlayer(game, owner), territory), true);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorGamontStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaAdvisorGamontPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorGamont(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaAdvisorGamontPlayer(beforeBid, holder).spice - semutaAdvisorGamontPlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'advisorBattle');
|
||||
const declinedPreparation = transition(() => {
|
||||
act(owner, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaAdvisorGamontClean(state));
|
||||
});
|
||||
const bgShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(semutaAdvisorGamontClean);
|
||||
});
|
||||
assert.equal(semutaAdvisorGamontPlayer(bgShipment.before, owner).spice - semutaAdvisorGamontPlayer(game, owner).spice, 2);
|
||||
const bgMovement = transition(() => {
|
||||
act(owner, { type: 'move', from: 'carthag:11', territory: 'sietch_tabr', sector: 14, amount: 1, elite: 0 });
|
||||
reach(semutaAdvisorGamontClean); act(owner, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === spender && semutaAdvisorGamontClean(state));
|
||||
const outsiderDeparture = transition(() => {
|
||||
act(spender, { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 9, elite: 0 });
|
||||
reach(semutaAdvisorGamontClean);
|
||||
});
|
||||
assert.equal(semutaAdvisorGamontPlayer(game, spender).forces[location], 1);
|
||||
reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorGamontClean(state));
|
||||
const ordinaryMentat = structuredClone(game);
|
||||
if (final) reach(state => state.phase === 8 && !state.response && !state.phaseOpening && state.decision?.kind === 'choamMentat');
|
||||
const beforeGamont = structuredClone(game);
|
||||
assert.equal(semutaAdvisorGamontStrongholds(game, owner).length, 2);
|
||||
const gamontAction: Action = { type: 'card', mode: 'choam', card: gamont, target: spender, from: location, elite: 0 };
|
||||
act(holder, gamontAction);
|
||||
const declaredGamont = structuredClone(game), gamontAt = actions.length - 1;
|
||||
const fuelDisposals: { before: Game; steps: SemutaAdvisorGamontStep[]; after: Game; event: string }[] = [];
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.producer !== 'gamont' && count < 70; count++) {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard;
|
||||
assert.equal(pending.continuation.kind, 'choamGamontDiscard');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy })), [{ card: gamont, by: holder }]);
|
||||
assert.equal(viewGame(game, holder).semutaReaction?.canCommit ?? false, false, 'Semuta cannot claim its own holder’s fuel');
|
||||
fuelDisposals.push({ ...transition(() => { game = passSemutaAdvisorGamont(game, actions); }), event: pending.batch.event });
|
||||
} else {
|
||||
assert.equal(game.response?.kind, 'choamWorthless');
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
act(actor.id, { type: 'passResponse' });
|
||||
}
|
||||
}
|
||||
const automatic = game.response?.automaticAdvisorFlip; assert.ok(automatic?.producer === 'gamont');
|
||||
assert.ok(game.pendingAutomaticAdvisorGamont && game.pendingAutomaticAdvisorFlips?.length);
|
||||
assert.equal(semutaAdvisorGamontPlayer(game, spender).forces[location] ?? 0, 0);
|
||||
assert.equal(semutaAdvisorGamontPlayer(game, spender).reserves, semutaAdvisorGamontPlayer(beforeGamont, spender).reserves + 1);
|
||||
assert.equal(game.discard.filter(card => card.id === gamont).length, 1);
|
||||
assert.ok(semutaAdvisorGamontPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorGamontPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
|
||||
const originalAutomaticSource = structuredClone(automatic), originalParent = structuredClone(game.pendingAutomaticAdvisorGamont);
|
||||
const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips);
|
||||
const originalStance = structuredClone(semutaAdvisorGamontPlayer(game, owner).advisors![territory]);
|
||||
assert.equal(originalStance.aloneFlipHold, automatic.event);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
const chooser = final ? { kind: 'choamMentat' as const, player: holder } : null;
|
||||
const originalContinuation = { chooser, ready: [...game.ready], choamMentatPending: game.choamMentatPending };
|
||||
assert.deepEqual(semutaAdvisorGamontInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, final, advanced: true as const, owner, bg: owner, holder, choam: holder,
|
||||
actor: holder, spender, target: spender, atreides: spender, seller, richese: seller, recipient: owner,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, gamont, card: gamont, action, nativeAction: gamontAction, gamontAction,
|
||||
originalResponse, originalAutomaticSource, originalParent, originalQueue, originalStance, originalContinuation,
|
||||
effect: structuredClone(originalParent.receipt),
|
||||
originalPhysical: structuredClone(semutaAdvisorGamontPhysical(game)), chooser,
|
||||
confirmationActor: holder, confirmationAction: { type: 'decision', done: true } satisfies Action,
|
||||
territory, location, from: location, to: 'reserves', initial, lobby, setup, initialization, beforeStartingDeal,
|
||||
afterSetup, advisorSetup, deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, declinedPreparation, bgShipment, bgMovement, outsiderDeparture, ordinaryMentat,
|
||||
beforeGamont, declaredGamont, fuelDisposals,
|
||||
gamontHistory: { before: beforeGamont, steps: structuredClone(actions.slice(gamontAt)), after: source },
|
||||
nearWin: { fighters: ['carthag', 'sietch_tabr'], advisors: [territory], lastOutsider: spender },
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A lobby, exact CHOAM/main35/cache10 and no modules. Only unopened original card/Spice positions and scalar lotteries are selected. Atreides really receives printed Karama; CHOAM really receives Gamont and pays cache1 for Semuta.',
|
||||
'BG really predicts and places its original advisor beside A10 Arrakeen10, declines native preparation, pays two for two Carthag fighters and uses actual city range to move one to Tabr14. Atreides really moves nine home counters to Basin10, leaving one beside the advisor. Native Collection earns the two genuine fighter-held strongholds.',
|
||||
final ? 'Original Mentat readiness and closing market really finish before the native final CHOAM chooser. The held original Gamont is then actually declared and its native Worthless counter allowed.' : 'At actual clean ordinary Mentat, the held original Gamont is really declared and its native Worthless counter allowed.',
|
||||
'The actual Gamont fuel leaves CHOAM’s hand once and its disposal completes before the last A counter returns and opens BG’s mandatory conversion. CHOAM’s own Semuta never claims its own fuel; any observed original disposal receives real passes, and no fake reaction window is installed.',
|
||||
'Stop before Atreides spends the retained printed Karama. Original returned counter, discarded fuel, automatic queue/parent and alone stance hold are native. No played hand, wallet, force, stance, clock, parent or victory is assigned; final confirmation remains an actual original action.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorGamontFixture = ReturnType<typeof createSemutaAdvisorGamontFixture>;
|
||||
@@ -0,0 +1,243 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaAdvisorStormStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorStorm = actSemutaCombatCapabilities;
|
||||
export const semutaAdvisorStormPlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaAdvisorStormInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaAdvisorStormClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaAdvisorStorm = passSemutaCombatCapabilities;
|
||||
export const claimSemutaAdvisorStorm = claimSemutaCombatCapabilities;
|
||||
export const semutaAdvisorStormPhysical = (game: Game) => ({
|
||||
spice: game.spice,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, moved: p.moved, shipped: p.shipped })),
|
||||
});
|
||||
|
||||
/** Quiet native controls only. Intrusion is source-owned; an actual mandatory
|
||||
* Storm conversion stops this driver until the caller resolves that source. */
|
||||
export function nextSemutaAdvisorStorm(game: Game): SemutaAdvisorStormStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'oh_gap', sector: 10 } };
|
||||
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null && !game.decision) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: game.stormDialers.indexOf(actor) === 0 ? 2 : 3 } };
|
||||
}
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'intrusion') return null;
|
||||
if (d?.kind === 'advisorBattle' || d?.kind === 'advisor')
|
||||
return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaAdvisorStormStep = nextSemutaAdvisorStorm;
|
||||
export function advanceSemutaAdvisorStorm(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorStormStep[] = []): Game {
|
||||
for (let count = 0; count < 2600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaAdvisorStorm(game);
|
||||
assert.ok(next, `Supply the native Storm/advisor control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAdvisorStorm(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Semuta/Storm advisor programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorStorm(game: Game, actions: SemutaAdvisorStormStep[] = []): Game {
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'storm');
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'storm' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorStorm(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'storm'); return game;
|
||||
}
|
||||
/** Finish remaining native traversal only. Stop when phase1 opens, before its
|
||||
* ordinary Spice windows/readiness reveal any new Spice Card. */
|
||||
export function finishSemutaAdvisorStorm(game: Game, actions: SemutaAdvisorStormStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaAdvisorStorm(game, state => state.phase === 1, actions);
|
||||
}
|
||||
|
||||
/** Scope only the real six-card native shuffle, on the actual final CHOAM
|
||||
* Mentat Done. No game Storm Card, order, forecast or traversal field is assigned. */
|
||||
function drawOriginalStorm(game: Game, step: SemutaAdvisorStormStep, distance: 3 | 4,
|
||||
actions: SemutaAdvisorStormStep[]) {
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 8);
|
||||
assert.ok(!game.response && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard);
|
||||
assert.equal(game.decision?.kind, 'choamMentat');
|
||||
assert.deepEqual(step, { actor: game.decision.player, action: { type: 'decision', done: true } });
|
||||
const original = [1, 2, 3, 4, 5, 6], order = [distance, ...original.filter(n => n !== distance)];
|
||||
const current = [...original], scalars: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||
[current[index], current[from]] = [current[from], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, order);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) {
|
||||
assert.ok(cursor < scalars.length, 'Only the original six-card shuffle consumes selected scalar entropy');
|
||||
array[0] = scalars[cursor++];
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, step.actor, step.action);
|
||||
assert.equal(cursor, scalars.length); actions.push(structuredClone(step));
|
||||
return { game: next, original, order, scalars };
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Genuine Advanced setup, cache1, paid reserve and accepted Intrusion earn
|
||||
* split-sector advisors; actual Storm traversal kills the last outsider first. */
|
||||
export function createSemutaAdvisorStormFixture(options: { continues?: boolean } = {}) {
|
||||
const continues = options.continues ?? false, distance: 3 | 4 = continues ? 4 : 3;
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', outsider = 'atreides', spender = 'emperor';
|
||||
const territory = 'oh_gap', location = 'oh_gap:10', outsiderLocation = 'oh_gap:9', from = 6;
|
||||
let game = createGame('SEMUTAADVISORSTORM', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorStormStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorStorm(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorStorm(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true); assert.equal(game.advanced, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorStormInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 4);
|
||||
assert.equal(ordinary.length, 4); const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = printed.id;
|
||||
const setupChoices = transition(() => { reach(state => state.status === 'playing'); });
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorStormPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(semutaAdvisorStormPlayer(game, owner).advisors?.[territory], undefined);
|
||||
assert.equal(game.storm, 1);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1 && state.storm === from); });
|
||||
const firstStormDials = firstStorm.steps.filter(step => step.action.type === 'stormDial');
|
||||
assert.deepEqual(firstStormDials.map(step => step.action.amount), [2, 3]);
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorStormStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaAdvisorStormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaAdvisorStorm(game); 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(semutaAdvisorStormPlayer(beforeBid, holder).spice - semutaAdvisorStormPlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === outsider && semutaAdvisorStormClean(state));
|
||||
const shipment = transition(() => {
|
||||
act(outsider, { type: 'ship', territory, sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
|
||||
});
|
||||
const intrusionBefore = structuredClone(game), intrusionAt = actions.length;
|
||||
act(owner, { type: 'decision', accept: true });
|
||||
assert.ok(game.response?.kind === 'advisorFlip'); assert.equal(game.response.advisors, true);
|
||||
const intrusionResponse = structuredClone(game.response);
|
||||
reach(semutaAdvisorStormClean);
|
||||
const intrusion = { before: intrusionBefore, steps: structuredClone(actions.slice(intrusionAt)), after: structuredClone(game) };
|
||||
assert.equal(semutaAdvisorStormPlayer(shipment.before, outsider).spice - semutaAdvisorStormPlayer(game, outsider).spice, 2);
|
||||
assert.equal(semutaAdvisorStormPlayer(game, outsider).forces[outsiderLocation], 1);
|
||||
assert.ok(semutaAdvisorStormPlayer(game, owner).advisors?.[territory]);
|
||||
reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorStormClean(state));
|
||||
const mentat = structuredClone(game);
|
||||
const mentatReadiness = transition(() => {
|
||||
for (const p of game.players.slice(0, -1)) act(p.id, { type: 'ready' });
|
||||
});
|
||||
const mentatClose = transition(() => { act(game.players.at(-1)!.id, { type: 'ready' }); });
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 8);
|
||||
assert.equal(game.decision?.kind, 'choamMarket'); assert.equal(game.decision.player, holder);
|
||||
const closingMarket = transition(() => { act(holder, { type: 'decision', done: true }); });
|
||||
const finalChoice = (game as Game).decision;
|
||||
assert.equal(finalChoice?.kind, 'choamMentat'); assert.equal(finalChoice.player, holder);
|
||||
const drawStep: SemutaAdvisorStormStep = { actor: holder, action: { type: 'decision', done: true } };
|
||||
const beforeDraw = structuredClone(game), drawAt = actions.length;
|
||||
const draw = drawOriginalStorm(game, drawStep, distance, actions); game = draw.game;
|
||||
const stormDraw = { before: beforeDraw, steps: structuredClone(actions.slice(drawAt)), after: structuredClone(game) };
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 0); assert.equal(game.storm, from);
|
||||
assert.equal(game.stormPending, distance); assert.equal(game.stormMovementSource?.kind, 'card');
|
||||
assert.ok(semutaAdvisorStormClean(game)); assert.equal(game.ready.length, 0);
|
||||
const stormSource = structuredClone(game.stormMovementSource);
|
||||
const stormCardLog = structuredClone(game.log.findLast(row => row.component?.kind === 'stormCard' && row.component.distance === distance));
|
||||
assert.ok(stormCardLog);
|
||||
const stormReadiness = transition(() => {
|
||||
for (const p of game.players.slice(0, -1)) act(p.id, { type: 'ready' });
|
||||
});
|
||||
assert.equal(game.ready.length, game.players.length - 1);
|
||||
const triggerActor = game.players.at(-1)!.id, triggerAction: Action = { type: 'ready' };
|
||||
const storm = transition(() => { act(triggerActor, triggerAction); });
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'storm');
|
||||
const automatic = game.response.automaticAdvisorFlip;
|
||||
assert.equal(automatic.owner, owner); assert.equal(automatic.territory, territory);
|
||||
assert.deepEqual(automatic.forces, { [location]: 1 }); assert.equal(automatic.storm.sector, 9);
|
||||
assert.ok(game.stormResolution && game.stormResolution.traversed === 3); assert.deepEqual(game.stormResolution.pending, []);
|
||||
assert.equal(semutaAdvisorStormPlayer(game, outsider).forces[outsiderLocation] ?? 0, 0);
|
||||
assert.equal(semutaAdvisorStormPlayer(game, outsider).tanks, semutaAdvisorStormPlayer(storm.before, outsider).tanks + 1);
|
||||
assert.equal(semutaAdvisorStormPlayer(game, owner).forces[location], 1);
|
||||
assert.ok(game.pendingAutomaticAdvisorStorm?.ready); assert.deepEqual(semutaAdvisorStormInventory(game), originalInventory);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, continues, distance, from,
|
||||
richese, holder, owner, outsider, atreides: outsider, spender, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
territory, location, outsiderLocation, triggerActor, triggerAction,
|
||||
originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic),
|
||||
originalParent: structuredClone(game.pendingAutomaticAdvisorStorm!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalPhysical: structuredClone(semutaAdvisorStormPhysical(game)),
|
||||
originalStance: structuredClone(semutaAdvisorStormPlayer(game, owner).advisors!),
|
||||
originalCursor: structuredClone(game.stormResolution), originalStormMovementSource: structuredClone(game.stormMovementSource),
|
||||
originalStormDials: structuredClone(game.stormDials), originalDeck: structuredClone(game.deck),
|
||||
originalReady: structuredClone(game.ready), originalSpiceDeck: structuredClone(game.spiceDeck),
|
||||
originalSpiceDiscard: structuredClone(game.spiceDiscard), initial, lobby, setup, setupChoices,
|
||||
beforeStartingDeal, afterSetup, dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder,
|
||||
firstStorm, firstStormDials, acquisition, acquired, cache: acquisition, shipment, intrusion, intrusionResponse,
|
||||
mentat, mentatReadiness, mentatClose, closingMarket, stormDraw, originalStormCards: draw.original, stormCardOrder: draw.order,
|
||||
stormCardScalars: draw.scalars, stormSource, stormCardLog, stormReadiness, storm,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original undealt Advanced R/C/BG/A/E Semuta initializer with CHOAM only, no Fremen or optional modules. Native BG setup places its real counter at empty Oh Gap10 and normally makes it a fighter. Only original unopened deals/Spice and scalar lotteries are selected; C really buys cache1 Semuta and E receives the printed Karama.',
|
||||
'Native first secret dials2/3 move Storm1→6. Atreides actually pays2 to ship one original reserve into Oh Gap9; BG accepts actual Intrusion and its ordinary advisorFlip response is allowed. Separate accompaniment is declined, retaining one actual advisor at10 beside one outsider at9.',
|
||||
'Real remaining movement, peaceful Battle/Collection and Mentat readiness execute. Select only the original six-card native shuffle when the actual final CHOAM Mentat Done opens turn2 to reveal distance3/4 with its original source/log. No card, forecast, order, stance, force, clock or cursor is assigned.',
|
||||
'Actual turn2 Storm readiness begins traversal from6. Sector9 really kills Atreides’ last outsider and opens mandatory conversion before sector10. Stop before the real cancellation cost; the held surviving BG group and full native cursor are actual engine output.',
|
||||
] };
|
||||
}
|
||||
export type SemutaAdvisorStormFixture = ReturnType<typeof createSemutaAdvisorStormFixture>;
|
||||
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
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';
|
||||
import {
|
||||
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
|
||||
} from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaAdvisorTransportStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorTransport = actSemutaRicheseCacheKarama;
|
||||
export const passSemutaAdvisorTransport = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaAdvisorTransport = claimSemutaFremenGuildMovement;
|
||||
export const semutaAdvisorTransportPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaAdvisorTransportInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaAdvisorTransportClean = semutaRicheseCacheKaramaClean;
|
||||
export const semutaAdvisorTransportPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready,
|
||||
movementRemaining: game.movementRemaining,
|
||||
guildTimingGranted: game.guildTimingGranted, guildTimingLocked: game.guildTimingLocked,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
auction: game.auction, richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding,
|
||||
basicAlliedShipment: game.basicAlliedShipment,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
|
||||
});
|
||||
|
||||
/** Actual native setup and transport continuations. The Guild takes its real
|
||||
* early opportunity; the original automatic flip and fresh cost remain owned
|
||||
* by the caller. No scalar selection installs a movement order or grant. */
|
||||
export function nextSemutaAdvisorTransport(game: Game): SemutaAdvisorTransportStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'advisorFlip') return null;
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg);
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: bg.id, action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'tueks_sietch', sector: 5 } };
|
||||
if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: true } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaAdvisorTransport(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorTransportStep[] = []): Game {
|
||||
for (let count = 0; count < 1400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorTransport(game);
|
||||
assert.ok(step, 'Choose the original automatic flip or fresh cost before continuing');
|
||||
game = actSemutaAdvisorTransport(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original transport-conversion programme did not reach its finite boundary');
|
||||
}
|
||||
/** Allow only the original committed transport's conversion. Permission, rate
|
||||
* and the physical shipment have already completed; none is replayed. */
|
||||
export function allowSemutaAdvisorTransport(game: Game,
|
||||
actions: SemutaAdvisorTransportStep[] = []): Game {
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'guildTransport');
|
||||
for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorTransport(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response ?? null, null); return game;
|
||||
}
|
||||
/** Resolve only the original transport arrival, then genuinely close this
|
||||
* already-shipped Guild turn. Do not submit another shipment or ground move. */
|
||||
export function finishSemutaAdvisorTransport(game: Game,
|
||||
actions: SemutaAdvisorTransportStep[] = []): Game {
|
||||
const guild = game.players.find(player => player.faction === 'guild'); assert.ok(guild);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorTransport, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingArrivalReaction ?? null, null);
|
||||
assert.ok(semutaAdvisorTransportClean(game));
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, guild.id);
|
||||
assert.equal(guild.shipped, true); assert.equal(guild.moved, 0);
|
||||
game = actSemutaAdvisorTransport(game, guild.id, { type: 'endMovement' }, actions);
|
||||
assert.ok(!game.movementRemaining?.includes(guild.id));
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Native Advanced R/C/BG/A/G, paid original cache Semuta, and all five native
|
||||
* Guild counters leaving the original BG advisor at Tuek's Sietch5. Only
|
||||
* original unopened card/Spice positions and scoped scalar lotteries are
|
||||
* selected. No played resource, stance, clock, control or receipt is installed. */
|
||||
export function createSemutaAdvisorTransportFixture(options: { kind: 'cross' | 'return' }) {
|
||||
const { kind } = options, richese = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides', guild = 'guild';
|
||||
let game = createGame('SEMUTAADVISORTRANSPORT', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of [holder, owner, spender, guild] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorTransportStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorTransport(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorTransport(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) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorTransportInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id;
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorTransportPlayer(game, owner).forces['tueks_sietch:5'], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorTransportPlayer(game, owner), 'tueks_sietch'), true);
|
||||
assert.equal(semutaAdvisorTransportPlayer(game, guild).forces['tueks_sietch:5'], 5);
|
||||
assert.ok(semutaAdvisorTransportPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorTransportStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaAdvisorTransportPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorTransport(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM really purchases the original Semuta from cache');
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaAdvisorTransportPlayer(beforeBid, holder).spice - semutaAdvisorTransportPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaAdvisorTransportPlayer(game, richese).spice - semutaAdvisorTransportPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
|
||||
const openingOffer = structuredClone(game);
|
||||
const declinedPreparation = transition(() => {
|
||||
act(owner, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === guild && semutaAdvisorTransportClean(state));
|
||||
});
|
||||
assert.equal(semutaAdvisorTransportPlayer(game, guild).shipped, false);
|
||||
assert.equal(semutaAdvisorTransportPlayer(game, guild).moved, 0);
|
||||
const from = 'tueks_sietch:5', territory = 'tueks_sietch', to = kind === 'return' ? 'reserves' : 'imperial_basin:10';
|
||||
const nativeAction: Action = { type: 'guildShip', from, amount: 5, elite: 0, allyPayment: 0,
|
||||
territory: kind === 'return' ? 'reserves' : 'imperial_basin', ...(kind === 'cross' ? { sector: 10 } : {}) };
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const transport = transition(() => {
|
||||
act(guild, nativeAction);
|
||||
reach(state => state.response?.automaticAdvisorFlip?.producer === 'guildTransport');
|
||||
});
|
||||
const actor = semutaAdvisorTransportPlayer(game, guild);
|
||||
const price = semutaAdvisorTransportPlayer(transport.before, guild).spice - actor.spice;
|
||||
assert.ok(price > 0, 'The native accepted Guild rate is actually paid before the automatic flip');
|
||||
assert.equal(actor.forces[from], undefined); assert.equal(actor.shipped, true); assert.equal(actor.moved, 0);
|
||||
if (kind === 'return') assert.equal(actor.reserves, semutaAdvisorTransportPlayer(transport.before, guild).reserves + 5);
|
||||
else assert.equal(actor.forces[to], 5);
|
||||
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'guildTransport');
|
||||
assert.ok(game.pendingAutomaticAdvisorTransport && game.pendingAutomaticAdvisorFlips?.length === 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorTransportPlayer(game, owner), territory), true);
|
||||
assert.ok(semutaAdvisorTransportPlayer(game, owner).advisors?.[territory]?.aloneFlipHold);
|
||||
assert.equal(game.pendingGuildTransport ?? null, null);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.ok(semutaAdvisorTransportPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorTransportPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaAdvisorTransportInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, advanced: true as const, richese, holder, owner, bg: owner,
|
||||
spender, atreides: spender, guild, actor: guild, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
|
||||
from, to, territory, sector: 5, location: from, amount: 5, elite: 0, price,
|
||||
originalResponse: structuredClone(game.response!),
|
||||
originalAutomaticSource: structuredClone(game.response!.automaticAdvisorFlip!),
|
||||
originalParent: structuredClone(game.pendingAutomaticAdvisorTransport!),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalArrival: structuredClone(game.pendingArrivalReaction ?? null),
|
||||
originalHeldStance: structuredClone(semutaAdvisorTransportPlayer(game, owner).advisors![territory]),
|
||||
originalPhysical: structuredClone(semutaAdvisorTransportPhysical(game)), originalHistory: structuredClone(game.log),
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, originalSpice,
|
||||
acquired, acquisition, openingOffer, declinedPreparation, beforeDeclaration, transport: { ...transport, price },
|
||||
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer acts only on the ready undealt R/C/BG/A/G lobby, with CHOAM expansion and no optional modules. Select original unopened starting-card and ordinary first-Spice positions only.',
|
||||
'Actually predict Atreides, choose original private Traitors and use advisorSetup at Tueks Sietch5 beside Guilds five native counters. Atreides is genuinely dealt its original printed Karama; CHOAM actually buys original cache Semuta for one spice.',
|
||||
'Complete actual first Storm, both Spice blows, Charity, Bidding and Revival. Decline the genuine initial advisorBattle offer, take the original native Guild timing opportunity and pass its original response. No played order, clock, grant, hand, wallet or counter is installed.',
|
||||
kind === 'return'
|
||||
? 'Submit actual guildShip returning all five original Tueks counters to abstract reserves; allow original transport permission and native rate. The native fee and physical return commit before capturing the alone-advisor response.'
|
||||
: 'Submit actual guildShip crossing all five original Tueks counters to empty Imperial Basin10; allow original transport permission and native rate. The native fee and board transfer commit before capturing the alone-advisor response.',
|
||||
'Stop at the original automatic guildTransport advisorFlip before the Atreides cost is played. Parent/source/queue/held stance stay original. Final native turn closure uses endMovement once, with no new shipment or ground move.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorTransportFixture = ReturnType<typeof createSemutaAdvisorTransportFixture>;
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import {
|
||||
createSemutaAdvisorGamontFixture as fixture, actSemutaAdvisorGamont as act,
|
||||
passSemutaAdvisorGamont as pass, claimSemutaAdvisorGamont as claim,
|
||||
allowSemutaAdvisorGamont as allow, finishSemutaAdvisorGamont as finish,
|
||||
closeSemutaAdvisorGamont as close, semutaAdvisorGamontPlayer as player,
|
||||
semutaAdvisorGamontInventory as inventory, semutaAdvisorGamontPhysical as physical,
|
||||
semutaAdvisorGamontStrongholds as strongholds, type SemutaAdvisorGamontFixture,
|
||||
} from './fixture-semuta-advisor-gamont';
|
||||
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, choamMentatPending: game.choamMentatPending });
|
||||
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: SemutaAdvisorGamontFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(player(f.afterSetup, f.holder).hand.some(card => card.id === f.gamont && card.name === 'Trip to Gamont'));
|
||||
assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
|
||||
assert.equal(player(f.afterSetup, f.spender).forces[f.location], 10);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.equal(player(f.bgShipment.before, f.owner).spice - player(f.bgShipment.after, f.owner).spice, 2);
|
||||
assert.ok(f.bgShipment.steps.some(step => step.action.type === 'ship' && step.action.amount === 2));
|
||||
assert.ok(f.bgMovement.steps.some(step => step.action.type === 'move' && step.action.from === 'carthag:11' && step.action.amount === 1));
|
||||
assert.ok(f.outsiderDeparture.steps.some(step => step.actor === f.spender && step.action.type === 'move' && step.action.amount === 9));
|
||||
assert.equal(player(f.beforeGamont, f.spender).forces[f.location], 1);
|
||||
assert.equal(player(f.beforeGamont, f.spender).forces['imperial_basin:10'], 9);
|
||||
assert.equal(player(f.beforeGamont, f.owner).forces['carthag:11'], 1);
|
||||
assert.equal(player(f.beforeGamont, f.owner).forces['sietch_tabr:14'], 1);
|
||||
assert.equal(isAdvisor(player(f.beforeGamont, f.owner), f.territory), true);
|
||||
assert.equal(strongholds(f.beforeGamont, f.owner).length, 2);
|
||||
assert.equal(f.beforeGamont.decision?.kind ?? null, f.final ? 'choamMentat' : null);
|
||||
assert.equal(f.declaredGamont.response?.kind, 'choamWorthless');
|
||||
assert.equal(f.gamontHistory.steps[0].actor, f.holder); assert.deepEqual(f.gamontHistory.steps[0].action, f.gamontAction);
|
||||
assert.ok(f.gamontHistory.steps.some(step => step.actor === f.spender && step.action.type === 'passResponse'));
|
||||
for (const disposal of f.fuelDisposals) {
|
||||
assert.equal(disposal.before.pendingTreacheryDiscard?.continuation.kind, 'choamGamontDiscard');
|
||||
assert.equal(viewGame(disposal.before, f.holder).semutaReaction?.canCommit ?? false, false);
|
||||
assert.ok(disposal.steps.every(step => step.action.type === 'semutaPass'));
|
||||
assert.notEqual(disposal.after.pendingTreacheryDiscard?.batch.event, disposal.event);
|
||||
}
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(player(f.game, f.holder).hand.some(card => card.id === f.gamont), false);
|
||||
assert.equal(f.game.discard.filter(card => card.id === f.gamont).length, 1);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta), 'Own fuel disposal never spends Semuta');
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost), 'The original Gamont counter was genuinely declined');
|
||||
assert.equal(player(f.game, f.spender).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(f.game, f.spender).reserves, player(f.beforeGamont, f.spender).reserves + 1);
|
||||
assert.equal(player(f.game, f.spender).tanks, player(f.beforeGamont, f.spender).tanks);
|
||||
assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'gamont'); assert.equal(f.originalAutomaticSource.gamont.returned, true);
|
||||
assert.equal(f.originalAutomaticSource.gamont.mentat, f.final);
|
||||
assert.equal(f.originalAutomaticSource.gamont.player, f.holder); assert.equal(f.originalAutomaticSource.gamont.target, f.spender);
|
||||
assert.equal(f.originalParent.card, f.gamont); assert.deepEqual(f.originalParent.receipt, f.originalAutomaticSource.gamont);
|
||||
assert.deepEqual(f.game.pendingAutomaticAdvisorGamont, f.originalParent);
|
||||
assert.deepEqual(f.game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.equal(f.originalStance.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(strongholds(f.game, f.owner).length, 2);
|
||||
assert.equal(f.game.status, 'playing'); assert.deepEqual(f.game.winner, []);
|
||||
assert.equal(f.game.phase, 8); assert.equal(f.game.turn, 1); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAdvisorGamontFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Expected the original Gamont conversion cost');
|
||||
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorGamont, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.discard.filter(card => card.id === f.gamont).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function nativeOutcome(state: Game, f: SemutaAdvisorGamontFixture, canceled: boolean) {
|
||||
const game = finish(state);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorGamont ?? null, null);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []); assert.equal(game.response ?? null, null);
|
||||
assert.deepEqual(game.decision, f.chooser); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled);
|
||||
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance);
|
||||
assert.equal(player(game, f.spender).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(game, f.spender).reserves, player(f.game, f.spender).reserves);
|
||||
assert.equal(game.discard.filter(card => card.id === f.gamont).length, 1);
|
||||
assert.equal(strongholds(game, f.owner).length, canceled ? 2 : 3);
|
||||
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
|
||||
reject(game, f.holder, f.gamontAction);
|
||||
const closeSteps: { actor: string; action: Action }[] = [], completed = close(structuredClone(game), closeSteps);
|
||||
assert.ok(closeSteps.some(step => step.actor === f.holder && step.action.type === 'decision' && step.action.done === true));
|
||||
if (f.final) assert.deepEqual(closeSteps[0], { actor: f.confirmationActor, action: f.confirmationAction });
|
||||
else assert.ok(closeSteps.some(step => step.action.type === 'ready'));
|
||||
if (canceled) {
|
||||
assert.equal(completed.status, 'playing'); assert.equal(completed.turn, 2); assert.deepEqual(completed.winner, []);
|
||||
} else {
|
||||
assert.equal(completed.status, 'finished'); assert.equal(completed.turn, 1); assert.deepEqual(completed.winner, [f.owner]);
|
||||
}
|
||||
assert.equal(completed.discard.filter(card => card.id === f.gamont).length, 1);
|
||||
assert.equal(player(completed, f.spender).reserves, player(f.game, f.spender).reserves);
|
||||
assert.equal(player(completed, f.spender).tanks, player(f.game, f.spender).tanks);
|
||||
assert.equal(player(completed, f.spender).forces[f.location] ?? 0, 0);
|
||||
assert.deepEqual(inventory(completed), f.originalInventory); return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaAdvisorGamontFixture, claimed: boolean) {
|
||||
const game = nativeOutcome(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const final of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`native ${final ? 'final' : 'ordinary'} Gamont conversion cost Semuta ${claimed ? 'claim' : 'pass'} keeps the real return and resumes only original Mentat`, () => {
|
||||
const f = fixture({ final }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Gamont conversion allowance earns the real third stronghold but waits for actual final confirmation', () => {
|
||||
for (const final of [false, true]) {
|
||||
const f = fixture({ final }); nativeSource(f); const game = nativeOutcome(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Gamont conversion cost rejects stale foreign fuel and underlying chooser replies before stance or victory', () => {
|
||||
for (const final of [false, true]) {
|
||||
const f = fixture({ final }), 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.holder, f.gamontAction); reject(game, f.holder, f.confirmationAction);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
|
||||
for (const disposal of f.fuelDisposals) reject(game, f.holder, { type: 'semutaCommit', event: disposal.event });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass native ordinary and final Gamont conversion costs', () => {
|
||||
for (const final of [false, true]) {
|
||||
const f = fixture({ final }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
denied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import {
|
||||
createSemutaAdvisorStormFixture as fixture, actSemutaAdvisorStorm as act,
|
||||
passSemutaAdvisorStorm as pass, claimSemutaAdvisorStorm as claim,
|
||||
allowSemutaAdvisorStorm as allow, finishSemutaAdvisorStorm as finish,
|
||||
semutaAdvisorStormPlayer as player, semutaAdvisorStormInventory as inventory,
|
||||
semutaAdvisorStormPhysical as physical, type SemutaAdvisorStormFixture,
|
||||
} from './fixture-semuta-advisor-storm';
|
||||
|
||||
const variants = [{ continues: false }, { continues: true }];
|
||||
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: SemutaAdvisorStormFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === f.territory && step.action.sector === 10));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), false,
|
||||
'The empty native setup placement actually starts as a fighter');
|
||||
assert.deepEqual(f.firstStormDials.map(step => step.action.amount), [2, 3]);
|
||||
assert.equal(f.firstStorm.before.storm, 1); assert.equal(f.firstStorm.after.storm, 6);
|
||||
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.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.deepEqual(f.shipment.steps[0], { actor: f.outsider,
|
||||
action: { type: 'ship', territory: f.territory, sector: 9, amount: 1, elite: 0, allyPayment: 0 } });
|
||||
assert.equal(player(f.shipment.before, f.outsider).spice - player(f.shipment.after, f.outsider).spice, 2);
|
||||
assert.equal(player(f.shipment.before, f.outsider).reserves - player(f.shipment.after, f.outsider).reserves, 1);
|
||||
assert.equal(f.intrusion.before.decision?.kind, 'intrusion');
|
||||
assert.deepEqual(f.intrusion.steps[0], { actor: f.owner, action: { type: 'decision', accept: true } });
|
||||
assert.equal(f.intrusionResponse.kind, 'advisorFlip'); assert.equal(f.intrusionResponse.advisors, true);
|
||||
assert.ok(f.intrusion.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.equal(isAdvisor(player(f.intrusion.after, f.owner), f.territory), true);
|
||||
assert.equal(player(f.intrusion.after, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.intrusion.after, f.outsider).forces[f.outsiderLocation], 1);
|
||||
assert.equal(f.mentatClose.steps.length, 1); assert.equal(f.mentatClose.steps[0].action.type, 'ready');
|
||||
assert.equal(f.mentatClose.after.decision?.kind, 'choamMarket');
|
||||
assert.equal(f.closingMarket.after.decision?.kind, 'choamMentat');
|
||||
assert.deepEqual(f.stormDraw.steps, [{ actor: f.holder, action: { type: 'decision', done: true } }]);
|
||||
assert.deepEqual(f.originalStormCards, [1, 2, 3, 4, 5, 6]);
|
||||
assert.equal(f.stormCardOrder[0], f.distance); assert.deepEqual([...f.stormCardOrder].sort((a, b) => a - b), f.originalStormCards);
|
||||
assert.equal(f.stormCardScalars.length, 5); assert.equal(f.stormDraw.after.stormPending, f.distance);
|
||||
assert.equal(f.stormDraw.after.turn, 2); assert.equal(f.stormDraw.after.phase, 0);
|
||||
assert.equal(f.stormSource?.kind, 'card'); assert.equal(f.stormCardLog.component?.distance, f.distance);
|
||||
assert.equal(f.stormReadiness.steps.length, f.game.players.length - 1);
|
||||
assert.deepEqual(f.storm.steps, [{ actor: f.triggerActor, action: f.triggerAction }]);
|
||||
assert.equal(f.storm.before.stormResolution, null); assert.equal(f.game.storm, f.from);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'storm');
|
||||
assert.deepEqual(Object.keys(f.originalAutomaticSource.storm).sort((a, b) => a.localeCompare(b)), ['distance', 'event', 'from', 'sector', 'traversed']);
|
||||
assert.equal(f.originalAutomaticSource.storm.from, 6); assert.equal(f.originalAutomaticSource.storm.distance, f.distance);
|
||||
assert.equal(f.originalAutomaticSource.storm.traversed, 3); assert.equal(f.originalAutomaticSource.storm.sector, 9);
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(f.originalParent.ready, true); assert.deepEqual(f.originalParent.receipt, f.originalAutomaticSource.storm);
|
||||
assert.equal(f.originalParent.cursor, JSON.stringify([f.originalCursor, f.originalStormMovementSource ?? null]));
|
||||
assert.equal(player(f.game, f.outsider).forces[f.outsiderLocation] ?? 0, 0);
|
||||
assert.equal(player(f.game, f.outsider).tanks, player(f.storm.before, f.outsider).tanks + 1);
|
||||
assert.equal(player(f.game, f.outsider).battleLosses, player(f.storm.before, f.outsider).battleLosses);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.owner).tanks, player(f.storm.before, f.owner).tanks);
|
||||
assert.equal(fighterCount(player(f.game, f.owner), f.territory), 0);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAdvisorStormFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the native current-sector conversion cost');
|
||||
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorStorm, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.stormResolution, f.originalCursor); assert.deepEqual(game.stormMovementSource, f.originalStormMovementSource);
|
||||
assert.deepEqual(game.stormDials, f.originalStormDials); assert.deepEqual(game.ready, f.originalReady);
|
||||
assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(game.spiceDeck, f.originalSpiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, f.originalSpiceDiscard); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.equal(game.phase, 0); assert.equal(game.storm, 6); assert.equal(game.stormResolution?.traversed, 3);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1, 'Future sector10 casualties wait through the fresh cost');
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function finished(state: Game, f: SemutaAdvisorStormFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.phase, 1); assert.equal(game.turn, 2); assert.equal(game.storm, f.continues ? 10 : 9);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorStorm, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(game.stormResolution, null);
|
||||
assert.equal(game.stormMovementSource, undefined); assert.equal(game.stormPending, null);
|
||||
assert.equal(player(game, f.outsider).forces[f.outsiderLocation] ?? 0, 0);
|
||||
assert.equal(player(game, f.outsider).tanks, player(f.storm.before, f.outsider).tanks + 1,
|
||||
'The completed sector9 casualty is never applied twice');
|
||||
assert.equal(player(game, f.outsider).forces['arrakeen:10'], 10);
|
||||
assert.equal(player(game, f.owner).forces[f.location] ?? 0, f.continues ? 0 : 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(f.storm.before, f.owner).tanks + Number(f.continues));
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), !f.continues && prevented);
|
||||
assert.equal(fighterCount(player(game, f.owner), f.territory), !f.continues && !prevented ? 1 : 0);
|
||||
if (prevented && !f.continues) assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
for (const p of game.players.filter(p => ![f.owner, f.outsider].includes(p.id)))
|
||||
assert.deepEqual(physical(game).players.find(row => row.id === p.id), f.originalPhysical.players.find(row => row.id === p.id));
|
||||
assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(game.spiceDeck, f.originalSpiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, f.originalSpiceDiscard, 'No subsequent Spice reveal is consumed by this continuation');
|
||||
assert.deepEqual(game.stormDials, {}); assert.deepEqual(game.ready, []);
|
||||
const log = game.log.slice(f.game.log.length), finalText = `Storm moved ${f.distance} sectors to sector ${f.continues ? 10 : 9}.`;
|
||||
assert.equal(log.filter(row => row.text === finalText).length, 1);
|
||||
const conversion = log.findIndex(row => row.text === `${player(game, f.owner).name} changed all forces in Oh Gap to fighters.`);
|
||||
assert.equal(conversion >= 0, !prevented);
|
||||
if (!prevented) assert.ok(conversion < log.findIndex(row => row.text === finalText),
|
||||
'Allowance converts before the actual remaining traversal completes');
|
||||
assert.ok(steps.every(step => !['card', 'ship', 'move', 'stormDial'].includes(step.action.type)));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaAdvisorStormFixture, claimed: boolean) {
|
||||
const game = finished(state, f, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`Advanced native ${variant.continues ? 'continuing' : 'ending'} Storm advisor conversion cost Semuta ${claimed ? 'claim' : 'pass'} holds sector9 then resumes only the remaining traversal`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Storm conversion allowance converts the real peaceful group before ending or continuing into its actual next-sector casualty', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f); const game = finished(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh Storm conversion cost rejects stale and underlying controls with the exact current-sector parent and surviving advisor still held', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.triggerActor, f.triggerAction);
|
||||
reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory });
|
||||
reject(game, f.outsider, { type: 'move', from: f.outsiderLocation, amount: 1, territory: 'imperial_basin', sector: 9 });
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorStorm, f.originalParent);
|
||||
assert.deepEqual(game.stormResolution, f.originalCursor); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1);
|
||||
canceled(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native ending and continuing Storm conversion used costs', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit');
|
||||
assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const 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);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaAdvisorTransportFixture as fixture, actSemutaAdvisorTransport as act,
|
||||
passSemutaAdvisorTransport as pass, claimSemutaAdvisorTransport as claim,
|
||||
allowSemutaAdvisorTransport as allow, finishSemutaAdvisorTransport as finish,
|
||||
semutaAdvisorTransportPlayer as player, semutaAdvisorTransportInventory as inventory,
|
||||
semutaAdvisorTransportPhysical as physical, type SemutaAdvisorTransportFixture,
|
||||
} from './fixture-semuta-advisor-transport';
|
||||
|
||||
const transportLogs = (game: Game) => game.log.filter(row => row.text.includes('used Guild transport for 5 forces.')).length;
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaAdvisorTransportFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'guild']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'predict' && step.action.faction === 'atreides'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === f.territory && step.action.sector === f.sector));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.from], 1);
|
||||
assert.equal(player(f.afterSetup, f.guild).forces[f.from], 5);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
|
||||
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.equal(f.openingOffer.decision?.kind, 'advisorBattle');
|
||||
assert.ok(f.declinedPreparation.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.accept === false));
|
||||
assert.equal(f.transport.before.active, f.guild); assert.equal(player(f.transport.before, f.guild).shipped, false);
|
||||
assert.deepEqual(f.transport.steps[0], { actor: f.guild, action: f.nativeAction });
|
||||
assert.ok(f.price > 0); assert.equal(f.transport.price, f.price);
|
||||
assert.equal(player(f.game, f.guild).spice, player(f.transport.before, f.guild).spice - f.price);
|
||||
assert.equal(player(f.game, f.guild).forces[f.from], undefined);
|
||||
if (f.kind === 'return') assert.equal(player(f.game, f.guild).reserves, player(f.transport.before, f.guild).reserves + 5);
|
||||
else assert.equal(player(f.game, f.guild).forces[f.to], 5);
|
||||
assert.equal(player(f.game, f.guild).shipped, true); assert.equal(player(f.game, f.guild).moved, 0);
|
||||
assert.equal(f.game.active, f.guild); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5);
|
||||
assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.ok(f.originalAutomaticSource.producer === 'guildTransport');
|
||||
assert.deepEqual(f.originalAutomaticSource.transport, f.originalParent);
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(f.originalParent.player, f.guild); assert.equal(f.originalParent.origin, f.territory);
|
||||
assert.equal(f.originalParent.to, f.kind === 'return' ? 'reserves' : 'imperial_basin');
|
||||
assert.equal(f.originalParent.amount, 5); assert.equal(f.originalParent.elite, 0);
|
||||
assert.deepEqual(f.originalParent.group, { [f.from]: 5 });
|
||||
assert.equal(f.originalHeldStance.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(transportLogs(f.game) - transportLogs(f.transport.before), 1);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaAdvisorTransportFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:advisorFlip');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original native advisor-flip cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorTransport, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalArrival);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalHeldStance);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function settled(game: Game, f: SemutaAdvisorTransportFixture, canceled: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorTransport, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingGuildTransport ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.phaseOpening ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled);
|
||||
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalHeldStance);
|
||||
else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.equal(transportLogs(game), transportLogs(f.game));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaAdvisorTransportFixture, claimed: boolean) {
|
||||
game = settled(game, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
return game;
|
||||
}
|
||||
function closeOriginalTurn(game: Game, f: SemutaAdvisorTransportFixture, canceled: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], guild = structuredClone(player(game, f.guild));
|
||||
const result = finish(game, steps);
|
||||
assert.deepEqual(steps, [{ actor: f.guild, action: { type: 'endMovement' } }]);
|
||||
assert.ok(!result.movementRemaining?.includes(f.guild));
|
||||
assert.equal(player(result, f.guild).spice, guild.spice);
|
||||
assert.deepEqual(player(result, f.guild).forces, guild.forces);
|
||||
assert.equal(player(result, f.guild).reserves, guild.reserves);
|
||||
assert.equal(player(result, f.guild).moved, 0);
|
||||
assert.equal(isAdvisor(player(result, f.owner), f.territory), canceled);
|
||||
assert.equal(result.pendingAutomaticAdvisorTransport, undefined); assert.equal(result.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(transportLogs(result), transportLogs(f.game));
|
||||
assert.deepEqual(inventory(result), f.originalInventory); return result;
|
||||
}
|
||||
|
||||
for (const kind of ['cross', 'return'] as const) for (const claimed of [false, true]) void test(
|
||||
`Advanced native ${kind} transport advisor conversion used cancellation cost ${claimed ? 'claim' : 'pass'} retains the paid shipment and resumes its arrival once`, () => {
|
||||
const f = fixture({ kind }); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
closeOriginalTurn(game, f, true);
|
||||
});
|
||||
|
||||
for (const kind of ['cross', 'return'] as const) void test(
|
||||
`Advanced native ${kind} transport advisor conversion allowance flips only the stranded group after the original payment`, () => {
|
||||
const f = fixture({ kind }); nativeSource(f);
|
||||
const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
closeOriginalTurn(game, f, false);
|
||||
});
|
||||
|
||||
for (const kind of ['cross', 'return'] as const) void test(
|
||||
`fresh ${kind} transport conversion costs reject stale and underlying controls while the committed parent stays parked`, () => {
|
||||
const f = fixture({ kind }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.guild, f.nativeAction); reject(game, f.guild, { type: 'endMovement' });
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorTransport, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
prevented(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('changed committed transport evidence rejects before cost and before a fresh Semuta answer', () => {
|
||||
const f = fixture({ kind: 'cross' }), beforeCost = structuredClone(f.game);
|
||||
assert.ok(beforeCost.pendingAutomaticAdvisorTransport); beforeCost.pendingAutomaticAdvisorTransport.amount--;
|
||||
reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(beforeCost.discard.some(card => card.id === f.cost), false);
|
||||
const afterCost = open(f); assert.ok(afterCost.pendingAutomaticAdvisorTransport);
|
||||
afterCost.pendingAutomaticAdvisorTransport.amount--;
|
||||
reject(afterCost, f.holder, { type: 'semutaCommit', event: afterCost.pendingTreacheryDiscard!.batch.event });
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer both actual committed transport conversion costs legally', () => {
|
||||
for (const kind of ['cross', 'return'] as const) {
|
||||
const f = fixture({ kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
closeOriginalTurn(prevented(game, f, claimed), f, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user