276 lines
15 KiB
TypeScript
276 lines
15 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 (!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');
|
|
}
|