Files
Dune/tests/fixture-sapho-late-aggressor.ts
T
Quintine 84200ebc74 Allow late Sapho priority before final battle settlement
Fresh Richese-roster battles support clean aggressor changes after plans and revelation. Both combatants explicitly finish a public final opportunity regardless of held cards or result; an actual Sapho use clears both marks with a fresh event. Close priority before physical Stone allocation and other outcome settlement. Preserve plans, votes, participants, Habbanya priority and reserved physical cards. Keep old unstamped timing and discard signature bytes. Route all final vote exits through native Homeworld defense first. Add persistent controls and finite minimal bot readiness.

Validation:16-file177/177, types/lint/build and eight original-action/rendered-table programmes. Actual cache1 and paid forces use40 Basic/47 Advanced source actions; genuine Stone1 turn1/Sapho1 turn2 uses86. Ordinary and mixed count2 ties change through sealed-plan/final-window Sapho, explicit revised acknowledgements and native losses. Sixteen minimal proposals are legal; controlled reservation and pure window/control cases are separate. Bounded review fixed the Harkonnen Nexus final-vote route; frozen feedback corrected four historical fixture assumptions. All checks then passed together.

Final-window protocol is provisional. Direct late Semuta claim/capture, combined World/Nexus/Sapho, wider combinations, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 13:46:00 +11:00

192 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho);
export const LATE_SAPHO = sapho.id;
export const LATE_SAPHO_STONE = 'richese-stone-burner';
export type SaphoLateStep = { actor: string; action: Action };
export const saphoLatePlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const saphoLateClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function actSaphoLate(game: Game, actor: string, action: Action, actions: SaphoLateStep[] = []): Game {
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function nextSaphoLateStep(game: Game, stone = false): SaphoLateStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'stoneAllocation' || decision.kind === 'battleLosses') return null;
if (decision.kind === 'richeseCache') {
const id = stone && game.turn === 1 ? LATE_SAPHO_STONE : LATE_SAPHO;
const card = game.richeseCache!.find(card => card.id === id) ?? game.richeseCache![0]; assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'onceAround', direction: 'clockwise' } };
}
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
if (decision.kind === 'stoneBurner') return { actor: decision.player,
action: { type: 'decision', event: decision.event, mode: 'ignore' } };
}
const lot = game.richeseAuction;
if (saphoLateClean(game) && lot && !lot.outcome && [LATE_SAPHO, LATE_SAPHO_STONE].includes(lot.cardId) &&
lot.active === 'emperor' && lot.bid === 0)
return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceSaphoLate(game: Game, until: (state: Game) => boolean,
actions: SaphoLateStep[] = [], stone = false): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original late Sapho programme ended before its next owned control');
const step = nextSaphoLateStep(game, stone); assert.ok(step, 'Choose the actual private battle consequence before continuing');
game = actSaphoLate(game, step.actor, step.action, actions);
}
throw Error('The original late Sapho programme did not reach its finite boundary');
}
/** Original public host, paid cache Sapho and paid opposing Arrakeen forces.
* Stone adds a genuine first-turn paid Stone and next-turn paid Sapho, with
* accepted original circles keeping Richese the second-turn physical attacker.
* Only unopened Spice positions and scalar lotteries are controlled. */
export function createSaphoLateAggressorFixture(options: { advanced?: boolean; stone?: boolean } = {}) {
const stone = options.stone ?? false, advanced = options.advanced ?? stone;
assert.ok(!stone || advanced, 'The bounded mixed physical Stone programme uses Advanced Sardaukar');
let game = createGame('SAPHOLATEAGGRESSOR', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SaphoLateStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoLate(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoLate(game, until, actions, stone); };
if (stone) {
act('richese', { type: 'seatPosition', position: 4 });
act('emperor', { type: 'seatPosition', position: 5 });
}
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
for (let count = 0; count < (stone ? 2 : 1) * (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
let acquiredStone: Game | null = null;
if (stone) {
reach(state => saphoLatePlayer(state).hand.some(card => card.id === LATE_SAPHO_STONE));
acquiredStone = structuredClone(game);
}
reach(state => saphoLatePlayer(state).hand.some(card => card.id === LATE_SAPHO));
const acquiredSapho = structuredClone(game), targetTurn = stone ? 2 : 1;
const shipments: { before: Game; step: SaphoLateStep; after: Game }[] = [], shipped = new Set<string>();
for (let count = 0; shipped.size < 2 && count < 600; count++) {
if (game.turn === targetTurn && game.phase === 5 && game.active && saphoLateClean(game) &&
['richese', 'emperor'].includes(game.active) && !shipped.has(game.active)) {
const actor = game.active, before = structuredClone(game), step: SaphoLateStep = { actor, action: {
type: 'ship', territory: 'arrakeen', sector: 10, amount: stone && actor === 'emperor' ? 6 : 3,
elite: stone && actor === 'emperor' ? 1 : 0, allyPayment: 0,
} };
act(step.actor, step.action); reach(saphoLateClean);
shipments.push({ before, step, after: structuredClone(game) }); shipped.add(actor);
} else {
const step = nextSaphoLateStep(game, stone); assert.ok(step); act(step.actor, step.action);
}
}
assert.equal(shipped.size, 2);
reach(state => state.phase === 6 && !!state.active && saphoLateClean(state));
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
const chooseBattle: SaphoLateStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: 'arrakeen', target: choice.chooser === 'richese' ? 'emperor' : 'richese',
} };
act(chooseBattle.actor, chooseBattle.action);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && saphoLateClean(state));
assert.equal(game.battle!.attacker, 'richese', 'Original paid source/circles keep Richese the physical attacker');
assert.equal(game.battle!.defender, 'emperor');
const plans: SaphoLateStep[] = ['richese', 'emperor'].map(actor => {
const leader = saphoLatePlayer(game, actor).leaders.find(leader => leader.strength === 3 && !leader.dead && !leader.usedAt);
assert.ok(leader, 'Use a genuinely dealt faction leader disc');
return { actor, action: { type: 'battlePlan', leader: leader.id, dial: stone && actor === 'emperor' ? 3 : 1,
support: advanced ? 1 : 0, weapon: stone && actor === 'emperor' ? LATE_SAPHO_STONE : null } };
});
return { game, lobby, setup, afterSetup, acquiredStone, acquiredSapho, shipments, beforeBattle, chooseBattle, plans,
owner: 'emperor', opponent: 'richese', location: 'arrakeen:10', card: LATE_SAPHO, stone, advanced,
sourceActionCount: actions.length, actions,
staging: ['Original native host/setup, cache1 purchase(s), funded physical/typed shipments and actual chooseBattle create the source. Only unopened land Spice positions/scalar lotteries are selected. The Stone branch uses accepted Richese circle4/Emperor circle5 lobby choices before readiness; no postdeal hand, force, wallet, phase, battle stamp or receipt is installed.'] };
}
export type SaphoLateAggressorFixture = ReturnType<typeof createSaphoLateAggressorFixture>;
export function saphoLateAggressorAction(game: Game, owner = 'emperor'): Action {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'battleAggressor');
assert.ok(option, 'The genuine owned late aggressor opportunity must be available');
return { type: 'card', card: LATE_SAPHO, ...option };
}
export function saphoLateOutcomeReady(game: Game, actor: string): Action {
const window = viewGame(game, actor).battle?.outcomeWindow; assert.ok(window && !window.closed);
return { type: 'battleOutcomeReady', event: window.event };
}
/** Leave the original outcome window open. One-plan Sapho keeps the sealed
* opposing plan; final-window Sapho may follow its own actual acknowledgement
* and must reset both acknowledgement marks without replaying any original vote. */
export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, options: {
useAt?: 'none' | 'onePlan' | 'window'; readyBeforeUse?: boolean;
} = {}) {
const useAt = options.useAt ?? 'none';
let game = structuredClone(fixture.game), used: Action | null = null, afterUse: Game | null = null;
let beforeUse: Game | null = null, acknowledged: Game | null = null;
const actions: SaphoLateStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoLate(game, actor, action, actions); };
act(fixture.plans[0].actor, fixture.plans[0].action);
const onePlan = structuredClone(game);
if (useAt === 'onePlan') {
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
act(fixture.owner, used); afterUse = structuredClone(game);
}
act(fixture.plans[1].actor, fixture.plans[1].action);
const revealed = structuredClone(game);
game = advanceSaphoLate(game, state => !!state.battle?.outcomeWindow && !state.battle.outcomeWindow.closed && saphoLateClean(state), actions, fixture.stone);
const opened = structuredClone(game);
if (useAt === 'window') {
if (options.readyBeforeUse) {
act(fixture.owner, saphoLateOutcomeReady(game, fixture.owner)); acknowledged = structuredClone(game);
}
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
act(fixture.owner, used); afterUse = structuredClone(game);
}
return { game, onePlan, revealed, opened, beforeUse, used, afterUse, acknowledged, useAt, actions };
}
/** Readies and later Stone/typed losses are chosen solely from actual owned
* controls. No acknowledgement invents a new plan, weapon choice or vote. */
export function completeSaphoLateAggressor(fixture: SaphoLateAggressorFixture,
programme: ReturnType<typeof prepareSaphoLateOutcome>, undialed = 2) {
let game = structuredClone(programme.game);
const actions: SaphoLateStep[] = [], readies: SaphoLateStep[] = [], allocations: {
before: Game; step: SaphoLateStep; after: Game;
}[] = [];
for (let count = 0; count < 700; count++) {
if (!game.battle && saphoLateClean(game)) return { game, actions, readies, allocations };
const decision = !game.response && !game.phaseOpening ? game.decision : null;
const window = game.battle?.outcomeWindow;
let step: SaphoLateStep | null;
if (window && !window.closed && saphoLateClean(game)) {
const actor = [window.attacker, window.defender].find(actor => !window.ready.includes(actor)); assert.ok(actor);
step = { actor, action: saphoLateOutcomeReady(game, actor) }; readies.push(structuredClone(step));
} else if (decision?.kind === 'stoneAllocation') {
const offer = viewGame(game, decision.player).battle!.stoneAllocation; assert.ok(offer && offer.totals.includes(undialed));
step = { actor: decision.player, action: { type: 'decision', event: offer.event, undialed } };
const before = structuredClone(game); game = actSaphoLate(game, step.actor, step.action, actions);
allocations.push({ before, step, after: structuredClone(game) }); continue;
} else if (decision?.kind === 'battleLosses') step = { actor: decision.player, action: { type: 'decision', choice: 0 } };
else step = nextSaphoLateStep(game, fixture.stone);
assert.ok(step, 'Complete the real late Sapho native consequence explicitly');
game = actSaphoLate(game, step.actor, step.action, actions);
}
throw Error('The original late Sapho outcome did not complete its finite native continuation');
}