Files
Dune/tests/fixture-richese-strongholds.ts
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

281 lines
16 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { NoFieldValue } from '../game/richese-no-field';
import type { StrongholdId } from '../game/stronghold-cards';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type RicheseStrongholdsOptions = {
initial?: Game;
seatIds?: [string, string, string];
kind?: 'arrakeen' | 'habbanya_ridge_sietch';
noFieldValue?: NoFieldValue;
markerBattle?: boolean;
reserves?: number;
stone?: boolean;
ownerDial?: number;
opponentDial?: number;
support?: number;
opponentSupport?: number;
traitors?: 'owner' | 'both';
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
};
export type RicheseStrongholdsFixture = {
initial: Game;
afterSetup: Game;
beforeShipment: Game;
shipmentStep: StrongholdFactionsNativeStep;
afterShipment: Game;
beforeFirstMentat: Game;
firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game;
beforeReveal: Game;
revealStep: StrongholdFactionsNativeStep;
beforeBattle: Game;
battleStep: StrongholdFactionsNativeStep;
game: Game;
richese: string;
choam: string;
opponent: string;
kind: StrongholdId;
acquisition: { before: Game; steps: StrongholdFactionsNativeStep[]; after: Game } | null;
planActions: StrongholdFactionsNativeStep[];
staging: string[];
};
export const richeseStrongholdsPlayer = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing native seat ${actor}`);
return player;
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Consume only live native windows; ordinary setup choices come from real views. */
export function nextRicheseStrongholdsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
if (clean(game) && game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
const window = game.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
if (actor) return {actor, action: {type: 'battleOutcomeReady', event: window.event}};
}
if (!game.phaseOpening && !game.response && game.decision) {
const decision = game.decision, actor = decision.player;
const event = game.richeseBidding?.event;
if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
if (decision.kind === 'richeseCache') {
const card = game.richeseCache?.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache?.[0];
assert.ok(card);
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
}
if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } };
if (decision.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: decision.event, mode: 'ignore' } };
}
if (clean(game) && game.richeseAuction && !game.richeseAuction.outcome) {
assert.ok(game.richeseAuction.active);
return { actor: game.richeseAuction.active, action: { type: 'richeseBid', event: game.richeseAuction.event, amount: game.richeseAuction.method === 'silent' ? 0 : null } };
}
return nextStrongholdFactionsNativeStep(game);
}
function step(game: Game): Game {
const next = nextRicheseStrongholdsNativeStep(game);
assert.ok(next, 'A native Richese continuation requires an unsealed human plan');
return applyAction(game, next.actor, next.action);
}
function advance(game: Game, predicate: (game: Game) => boolean, firstStormDial = 4): Game {
for (let count = 0; count < 1600; count++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
// Use legal original dials that leave the intended controlled battle outside the next Storm band.
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstStormDial;
game = applyAction(game, next.actor, next.action);
}
throw Error('Native Richese continuation did not reach its requested boundary');
}
function initialize(game: Game): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeStrongholdFactionsGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
}
}
function place(game: Game, actor: string, location: string, amount: number) {
const player = richeseStrongholdsPlayer(game, actor);
assert.ok(player.reserves >= amount && amount >= 0);
player.reserves -= amount;
if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount;
}
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) {
const player = richeseStrongholdsPlayer(game, actor);
const held = player.hand.find(c => c.kind === kind);
if (held) return held.id;
const at = game.deck.findIndex(c => c.kind === kind);
assert.ok(at >= 0, `Missing canonical native ${kind}`);
assert.ok(player.hand.length < 4);
const card = game.deck.splice(at, 1)[0]; player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
return card.id;
}
function traitor(game: Game, actor: string, identity: string, staging: string[]) {
const player = richeseStrongholdsPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0]; assert.ok(returned);
const at = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(at >= 0 || donor);
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved native traitor ${identity} exchanged for ${returned}`);
}
/** Real Advanced E2 setup, native cache producer and one-force shipment, followed
* by actual first END Mentat. Only labeled conserved board/card/bank positions
* are staged after setup. No phases, owners, markers or cache faces are assigned.
* An original CLI initializer snapshot is continued without RNG or redealing. */
export function createRicheseStrongholdsFixture(options: RicheseStrongholdsOptions = {}): RicheseStrongholdsFixture {
const kind = options.kind ?? 'habbanya_ridge_sietch';
const location = `${kind}:${territory(kind).sectors[0]}`;
const staging: string[] = [];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0), 'Continue only the original fresh undealt CLI setup');
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['choam']);
assert.deepEqual(game.players.map(p => p.faction).sort(), ['choam', 'guild', 'richese']);
} else {
const ids = options.seatIds ?? ['richese', 'choam', 'guild'];
game = createGame('RICHESESTRONGHOLDS', newPlayer(ids[0], 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer(ids[1], 'CHOAM', 'choam'));
joinGame(game, newPlayer(ids[2], 'Guild', 'guild'));
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
}
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) {
let next: StrongholdFactionsNativeStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id); view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, `Native Richese setup stalled at ${game.setupStage}`);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const richese = game.players.find(p => p.faction === 'richese')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
// Stone comes from the actual unbid-cache acquisition producer, never a hand injection.
const firstStormDial = Math.min(4, Math.floor((territory(kind).sectors[0] - 7) / 2));
assert.ok(firstStormDial >= 0);
game = advance(game, g => g.decision?.kind === 'richeseCache', firstStormDial);
const acquisitionBefore = structuredClone(game), acquisitionSteps: StrongholdFactionsNativeStep[] = [];
for (let count = 0; !richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner') && count < 100; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
acquisitionSteps.push(next); game = applyAction(game, next.actor, next.action);
}
assert.ok(richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner'));
const acquisition = { before: acquisitionBefore, steps: acquisitionSteps, after: structuredClone(game) };
game = advance(game, g => g.phase === 5 && clean(g) && g.active === richese);
reserveBoard(game);
for (const player of game.players) player.spice = 20;
staging.push('After real setup and cache acquisition: return original board counters to their own reserves; stage twenty-spice bank balances for original seats');
const beforeShipment = structuredClone(game);
const token = richeseStrongholdsPlayer(game, richese).noField!.tokens.find(t => t.value === (options.noFieldValue ?? 5))!;
const shipmentStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'ship', noField: token.id, event: richeseStrongholdsPlayer(game, richese).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } };
game = applyAction(game, shipmentStep.actor, shipmentStep.action);
game = advance(game, g => !g.response && !g.decision && !g.phaseOpening);
const afterShipment = structuredClone(game);
game = advance(game, g => g.phase === 8 && clean(g));
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 1 && count < 100; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
const before = structuredClone(game); game = applyAction(game, next.actor, next.action);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
assert.equal(game.status, 'playing');
assert.equal(game.strongholdCards!.owners[kind], richese);
const afterFirstMentat = structuredClone(game);
game = advance(game, g => g.phase === 5 && clean(g));
reserveBoard(game);
const markerBattle = options.markerBattle ?? !options.stone;
const reserves = options.reserves ?? 20;
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves <= 20);
place(game, richese, 'polar_sink:0', 20 - reserves);
place(game, opponent, location, 6);
for (const player of game.players) player.spice = 20;
staging.push(`Before native second Movement completion: stage six opposing battle counters, ${20 - reserves} Richese counters in Polar Sink and twenty-spice bank balances; preserve real concealed marker and first-Mentat card ownership`);
const beforeReveal = structuredClone(game);
const revealStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'revealNoField', event: richeseStrongholdsPlayer(game, richese).noFieldEvent, token: token.id } };
if (!markerBattle) {
game = applyAction(game, richese, revealStep.action);
reserveBoard(game); place(game, richese, location, 6); place(game, opponent, location, 6);
staging.push('After actual voluntary materialization: conserve six ordinary physical counters per combatant at the printed Stronghold, never a mixed-marker battle');
}
const defense = options.ownerDefense ? heldCard(game, richese, options.ownerDefense, staging) : null;
const enemyWeapon = options.opponentWeapon ? heldCard(game, opponent, options.opponentWeapon, staging) : null;
const ownLeader = richeseStrongholdsPlayer(game, richese).leaders.find(l => l.strength === 5 && !l.dead)!;
const otherLeader = richeseStrongholdsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead)!;
assert.ok(ownLeader && otherLeader);
if (options.traitors) {
traitor(game, richese, otherLeader.id, staging);
if (options.traitors === 'both') traitor(game, opponent, ownLeader.id, staging);
}
game = advance(game, g => g.phase === 6 && clean(g));
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === richese || actor === opponent);
const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === richese ? opponent : richese } };
game = applyAction(game, actor, battleStep.action);
for (let count = 0; count < 120; count++) {
const next = nextRicheseStrongholdsNativeStep(game);
if (!next) break;
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.battle && !game.battle.revealed && !game.decision && !game.response);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: richese, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, leader: ownLeader.id, weapon: options.stone ? 'richese-stone-burner' : null, defense } },
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: otherLeader.id, weapon: enemyWeapon } },
];
return { initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeReveal, revealStep, beforeBattle, battleStep, game, richese, choam, opponent, kind, acquisition, planActions, staging };
}
export function revealRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture): Game {
let game = structuredClone(fixture.game);
for (const plan of fixture.planActions) {
game = applyAction(game, plan.actor, plan.action);
while (game.response || game.decision?.kind === 'fullPlanOffer') game = step(game);
}
assert.ok(game.battle?.revealed);
return game;
}
export function finishRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture, state: Game, callers: string[] = []): Game {
let game = state;
for (let count = 0; count < 160; count++) {
if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
game = applyAction(game, next.actor, next.action);
}
throw Error('Native Richese battle suffix did not finish');
}