Integrate expanded native Stronghold faction rules

Broaden existing Advanced Stronghold profile to selected E1/E2 native pairs/mixed families and classic opponents;35/47 ordinary decks with separate Richese10cache. Native No-Field/real Stone acquisition/battles, FaceDancer originalwinner/replacement/endMentat and legalAI reuse existinghandlers. Targeted16, actual11 rule cases,18unstaged games11066actions/no rejects/290JSON restores; corrected per-family reporter exercised on3two-seat families. Human390pxG7VDY6KBv12 Stone kill/Habbanya/retain and736HJUD3v8 two-reserve FaceDance. Finalquick26/build/guide205links. No new save/privacy/custody/HTTP/full6500 assurance campaign, pending rulings/public/deployed gates unchanged.
This commit is contained in:
Quintine committed 2026-10-02 11:05:54 +10:00
1 parent 05ff2697bc
commit 288870c842
16 files changed
+1067 -82

No files matched your search

+42 -24
View File
@@ -15,6 +15,7 @@ import { join } from 'node:path';
import type { Game } from '../game/engine';
import { isAuditorLeader, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
import { createTechTokens } from '../game/tech-tokens';
@@ -105,41 +106,58 @@ void test('native Stronghold samples admit every two-to-six-seat roster and pres
assert.equal(run(out, '--profile', 'stronghold-factions',
'--players', 'all', '--seed', '1000', '--max-actions', '1').status, 1);
const results = json<{ results: CliResult[] }>(join(out, 'results.json')).results;
assert.deepEqual(results.map(result => result.name),
[2, 3, 4, 5, 6].map(seats => `stronghold-factions-${seats}-advanced`));
const roster = ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'];
const canonical = treacheryDeck(['ix', 'choam']).map(card => card.id).sort();
assert.equal(canonical.length, 47);
assert.equal(new Set(canonical).size, 47);
const families = [
{ native: 'ixians-choam', expansions: ['ix', 'choam'],
roster: ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 151 },
{ native: 'ixians-tleilaxu', expansions: ['ix'],
roster: ['ixians', 'tleilaxu', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 156 },
{ native: 'choam-richese', expansions: ['choam'],
roster: ['choam', 'richese', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 161 },
{ native: 'ixians-tleilaxu-choam-richese', expansions: ['ix', 'choam'],
roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'], counts: [4, 5, 6], ordinal: 166 },
];
const scenarios = families.flatMap(family => family.counts.map((count, index) => ({
...family, count, seed: 1000 + family.ordinal + index,
name: `stronghold-factions-${family.native}-${count}-advanced`,
})));
assert.deepEqual(results.map(result => result.name), scenarios.map(scenario => scenario.name));
for (const [index, result] of results.entries()) {
const scenario = scenarios[index];
assert.equal(result.seed, scenario.seed);
assert.equal(result.error, 'Error: Action limit');
assert.equal(result.actions, 1);
const game = json<Game>(join(out, `failed-${result.name}.json`));
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['ix', 'choam']);
assert.deepEqual(game.players.map(player => player.faction), roster.slice(0, index + 2));
assert.deepEqual(game.expansions, scenario.expansions);
assert.deepEqual(game.players.map(player => player.faction), scenario.roster.slice(0, scenario.count));
assert.deepEqual(game.players.map(player => player.bot),
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, index + 2));
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, scenario.count));
const deck = treacheryDeck(scenario.expansions).map(card => card.id).sort();
assert.equal(deck.length, scenario.expansions.includes('ix') ? 47 : 35);
const cache = scenario.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : [];
const inventory = sampleInventory(game);
assert.deepEqual(inventory.cards, canonical);
assert.deepEqual(inventory.cards, [...deck, ...cache].sort());
assert.deepEqual((game.richeseCache ?? []).map(card => card.id).sort(), cache);
verifySampleCustody(game, inventory);
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(),
STRONGHOLD_CARDS.map(card => card.id).sort());
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.equal(game.players.find(player => player.faction === 'choam')!.leaders
.filter(isAuditorLeader).length, 1);
const choam = game.players.find(player => player.faction === 'choam');
if (choam) assert.equal(choam.leaders.filter(isAuditorLeader).length, 1);
}
for (const scenario of scenarios.filter(candidate => candidate.count === 6)) {
const snapshot = join(out, `failed-${scenario.name}.json`);
const original = readFileSync(snapshot, 'utf8');
const continued = join(area, `continued-${scenario.native}`);
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000',
'--max-actions', '1').status, 1);
const report = assertFailedEvidence(continued, scenario.name, scenario.seed, true);
assert.equal(report.options.resume?.path, snapshot);
verifySampleCustody(json<Game>(join(continued, `failed-${scenario.name}.json`)),
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
assert.equal(readFileSync(snapshot, 'utf8'), original);
}
const snapshot = join(out, 'failed-stronghold-factions-6-advanced.json');
const original = readFileSync(snapshot, 'utf8');
const continued = join(area, 'continued');
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000',
'--max-actions', '1').status, 1);
const report = assertFailedEvidence(continued, 'stronghold-factions-6-advanced', 1155, true);
assert.equal(report.options.resume?.path, snapshot);
verifySampleCustody(json<Game>(join(continued, 'failed-stronghold-factions-6-advanced.json')),
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
assert.equal(readFileSync(snapshot, 'utf8'), original);
});
void test('native Stronghold admission and resume reject foreign profiles and corrupted custody before gameplay', (t) => {
@@ -150,11 +168,11 @@ void test('native Stronghold admission and resume reject foreign profiles and co
const out = join(area, 'native');
assert.equal(run(out, '--profile', 'stronghold-factions', '--players', '2',
'--max-actions', '1').status, 1);
const source = json<Game>(join(out, 'failed-stronghold-factions-2-advanced.json'));
const source = json<Game>(join(out, 'failed-stronghold-factions-ixians-choam-2-advanced.json'));
const corruptions: [string, (game: Game) => void][] = [
['basic', game => { game.advanced = false; }],
['wrong-deck', game => { game.expansions = ['ix']; }],
['foreign-faction', game => { game.players[1].faction = 'richese'; }],
['foreign-faction', game => { game.players[1].faction = 'ecaz'; }],
['missing-module', game => { delete game.strongholdCards; }],
['nexus', game => { game.nexusCards = { cards: null, phase: null }; }],
['tech', game => { game.techTokens = createTechTokens(); }],
+273
View File
@@ -0,0 +1,273 @@
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): 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);
// Select legal original dials so the next storm band cannot cross Habbanya.
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 4;
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.
game = advance(game, g => g.decision?.kind === 'richeseCache');
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');
}
+196
View File
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
import type { Card } from '../game/cards';
import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type TleilaxuStrongholdsOptions = { initial?: Game; kind?: 'sietch_tabr' | typeof MOBILE_STRONGHOLD };
export type TleilaxuStrongholdsFixture = {
initial: Game; afterSetup: Game; beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game; beforeBattle: Game; game: Game; revealed: Game; faceDance: Game;
ix: string; tleilaxu: string; opponent: string; kind: StrongholdId; location: string;
pointer: string; winnerLeader: string; loserLeader: string; weapon: string; defense: string;
battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[];
sourceActions: StrongholdFactionsNativeStep[]; staging: string[];
};
export const tleilaxuStrongholdsPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`;
function run(game: Game, action: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(action)); return applyAction(game, action.actor, action.action);
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action, 'A real human battle plan is required');
game = run(game, action, actions);
}
throw Error('Native Tleilaxu Stronghold boundary did not open');
}
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, location: string, count: number, elite = 0) {
const player = tleilaxuStrongholdsPlayer(game, actor);
assert.ok(player.reserves >= count && count >= elite && (player.elites?.reserves ?? 0) >= elite);
player.reserves -= count; player.forces[location] = (player.forces[location] ?? 0) + count;
if (elite) { player.elites!.reserves -= elite; player.elites!.forces[location] = (player.elites!.forces[location] ?? 0) + elite; }
}
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) {
const player = tleilaxuStrongholdsPlayer(game, actor), held = player.hand.find(c => c.kind === kind);
if (held) return held.id;
const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
assert.ok(player.hand.length < 4);
const card = game.deck.splice(index, 1)[0]; player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); return card.id;
}
function matchingLeader(game: Game, ix: string, tleilaxu: string) {
const held = tleilaxuStrongholdsPlayer(game, tleilaxu).faceDancers!;
return tleilaxuStrongholdsPlayer(game, ix).leaders.filter(l => !l.dead && held.some(c => !c.revealed && c.leader === l.id)).sort((a, b) => b.strength - a.strength)[0];
}
/** Root Advanced PDF logical p38, lines 1066–1075: actual native draw, original
* winner's rewards, no second bounty, and zero/up-to-army replacement. Only
* the actual first native Face Dancer shuffle uses test entropy, scoped to
* its source setup action. Provided original CLI setup is cloned, never
* redealt; saved choices, deck and offers remain untouched. Conserved
* board/card staging is labeled; no owners, phase, plans or opportunities are
* assigned. This intentionally excludes foreign gholas, capture and overlays. */
export function createTleilaxuStrongholdsFixture(options: TleilaxuStrongholdsOptions = {}): TleilaxuStrongholdsFixture {
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
const kind = options.kind ?? 'sietch_tabr';
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));
assert.equal(game.advanced, true);
assert.ok(game.expansions.includes('ix') && game.expansions.every(deck => deck === 'ix' || deck === 'choam') &&
new Set(game.expansions).size === game.expansions.length, 'Use the selected native Ix deck, optionally with CHOAM');
} else {
game = createGame('TLEILAXUSTRONGHOLDS', newPlayer('ix', 'Ixians', 'ixians'), true, ['ix']);
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
}
assert.deepEqual(game.players.map(p => p.faction).sort(), ['guild', 'ixians', 'tleilaxu']);
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = initializeStrongholdFactionsGameForAudit(game);
}
const initial = structuredClone(game);
for (let setup = 0; game.status === 'setup' && setup < 200; setup++) {
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action);
if (game.decision?.kind !== 'ixSetup') {
game = run(game, action, actions); continue;
}
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.equal(game.setupStage, undefined);
assert.ok(game.players.every(p => p.faceDancers === undefined));
// Ix first shuffles the unchosen starting Treachery Cards. Preserve that
// genuine shuffle; seed only the subsequent native Traitor Deck shuffle.
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const originalRandom = crypto.getRandomValues.bind(crypto);
let remainderCalls = Math.max(0, game.ixSetupCards!.length - 2);
let faceCalls = game.players.reduce((sum, p) => sum + p.leaders.length, 0) + 1 -
new Set(game.players.flatMap(p => p.traitors)).size - 1;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
assert.ok(array instanceof Uint32Array, 'Native setup entropy uses integer random words');
if (remainderCalls > 0) { remainderCalls--; originalRandom(array); return array; }
if (faceCalls <= 0) { originalRandom(array); return array; }
faceCalls--;
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
};
try { game = run(game, action, actions); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
assert.equal(game.status, 'playing');
staging.push('First-draw test entropy: only native Face Dancer Traitor Deck shuffle in the original Ix starting-card decision; restore immediately; no redeal, saved-choice rewrite or later draw override.');
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const ix = game.players.find(p => p.faction === 'ixians')!.id;
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
game = advance(game, state => state.phase === 8 && clean(state), actions);
reserveBoard(game);
place(game, ix, MOBILE_LOCATION, 6, 3); place(game, ix, key('sietch_tabr'), 1);
place(game, tleilaxu, key('arrakeen'), 1); place(game, opponent, key('carthag'), 1);
staging.push('Before real first END Mentat: redistribute original counters through their own reserves; Ix controls HMS and Tabr, Tleilaxu Arrakeen, Guild Carthag; no three-hold victory.');
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
while (game.turn === 1) {
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action);
const before = structuredClone(game); game = run(game, action, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = action; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
assert.equal(game.strongholdCards!.owners[kind], ix);
const winnerLeader = matchingLeader(game, ix, tleilaxu);
assert.ok(winnerLeader, 'Actual first native Face Dancer draw must provide an Ix leader');
game = advance(game, state => state.phase === 5 && clean(state), actions);
const pointer = game.mobileStronghold!.location; assert.ok(pointer && pointer !== MOBILE_LOCATION);
reserveBoard(game);
const location = key(kind);
place(game, ix, location, 6, 3); place(game, opponent, location, 6);
if (kind !== MOBILE_STRONGHOLD) place(game, ix, MOBILE_LOCATION, 1, 1);
else place(game, ix, key('sietch_tabr'), 1);
place(game, ix, pointer, 2, 1); place(game, tleilaxu, key('arrakeen'), 3);
staging.push('Before native movement completion: conserve six counters per combatant (Ix three suboids/three cyborgs), two Ix outside the HMS pointer (one cyborg), real Tleilaxu board sources in Arrakeen; retain first-Mentat cards.');
for (const player of game.players) player.spice = 20;
staging.push('Explicit bank-balance staging: original three seats each start this battle with twenty spice; no bounty, support or Stronghold receipt injected.');
const weapon = heldCard(game, ix, 'projectile', staging), defense = heldCard(game, ix, 'shield', staging);
const loserLeader = tleilaxuStrongholdsPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; assert.ok(loserLeader);
game = advance(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([ix, opponent].includes(actor));
const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ix ? opponent : ix } };
game = run(game, battleAction, actions);
if (kind === MOBILE_STRONGHOLD) {
game = advance(game, state => state.decision?.kind === 'strongholdCopy' || !!state.battle?.strongholdCopy, actions);
assert.equal(strongholdControllers(game.players, true).sietch_tabr, ix);
if (game.decision?.kind === 'strongholdCopy')
game = run(game, { actor: ix, action: { type: 'decision', event: game.decision.event, stronghold: 'sietch_tabr' } }, actions);
else assert.equal(game.battle!.strongholdCopy, 'sietch_tabr', 'Native sole-copy declaration is automatic');
}
game = advance(game, state => !!state.battle && !state.battle.revealed && !nextStrongholdFactionsNativeStep(state), actions);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: ix, action: { type: 'battlePlan', dial: 2, support: 1, leader: winnerLeader.id, weapon, defense } },
{ actor: opponent, action: { type: 'battlePlan', dial: 1.5, support: 1, leader: loserLeader.id, weapon: null, defense: null } },
];
const fixture = { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game: structuredClone(game), ix, tleilaxu, opponent, kind, location, pointer, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, weapon, defense, battleAction, planActions, sourceActions: actions, staging };
const revealed = revealTleilaxuStrongholdsBattle(fixture, actions);
const faceDance = resolveTleilaxuStrongholdsBattle({ ...fixture, revealed }, actions);
return { ...fixture, revealed, faceDance };
}
export function revealTleilaxuStrongholdsBattle(fixture: Pick<TleilaxuStrongholdsFixture, 'game' | 'planActions'>, sourceActions: StrongholdFactionsNativeStep[] = []): Game {
let game = structuredClone(fixture.game);
for (const plan of fixture.planActions) {
game = run(game, plan, sourceActions);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); game = run(game, action, sourceActions);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function resolveTleilaxuStrongholdsBattle(fixture: Pick<TleilaxuStrongholdsFixture, 'revealed' | 'ix'>, sourceActions: StrongholdFactionsNativeStep[] = []): Game {
const quote = quoteStrongholdFactionsBattle(fixture.revealed); assert.equal(quote.winner, fixture.ix);
let game = structuredClone(fixture.revealed);
for (let n = 0; n < 160; n++) {
if (game.decision?.kind === 'faceDance') return game;
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); game = run(game, action, sourceActions);
}
throw Error('Original winner aftermath did not reach native Face Dance');
}
export function finishTleilaxuStrongholdsTurn(state: Game): Game {
const turn = state.turn; return advance(structuredClone(state), game => game.turn > turn, []);
}
+36 -15
View File
@@ -22,6 +22,8 @@ import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { createLeaderSkills } from '../game/leader-skills';
import { createStrongholdCards } from '../game/stronghold-cards';
import { treacheryDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { richeseCards } from '../game/richese-cards';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
@@ -955,19 +957,33 @@ void test('Banker income CLI requires a fresh exact-version private backup and p
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});
function strongholdFactionsLobby(count = 3) {
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), true, ['ix', 'choam']);
const factions = ['choam', 'atreides', 'harkonnen', 'emperor', 'guild'] as const;
function strongholdFactionsLobby(
count = 3,
roster: readonly FactionId[] = ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'],
expansions = ['ix', 'choam'],
) {
const game = createGame('PROTOTYP', newPlayer('i', roster[0], roster[0]), true, expansions);
for (let index = 1; index < count; index++)
joinGame(game, newPlayer('seat-' + index, factions[index - 1], factions[index - 1]));
joinGame(game, newPlayer('seat-' + index, roster[index], roster[index]));
game.players.forEach(player => { player.ready = true; });
return game;
}
void test('Stronghold factions admits only fresh exact-version native Advanced lobbies without changing seats or other rooms', t => {
const { db } = fixture(t);
for (const count of [2, 3, 4, 5, 6]) {
const lobby = strongholdFactionsLobby(count);
const families: { roster: FactionId[]; expansions: string[]; minimum?: number }[] = [
{ roster: ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix', 'choam'] },
{ roster: ['ixians', 'tleilaxu', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix'] },
{ roster: ['choam', 'richese', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['choam'] },
{ roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'guild'], expansions: ['ix', 'choam'], minimum: 4 },
...(['ixians', 'tleilaxu', 'choam', 'richese'] as const).map(faction => ({
roster: [faction, 'guild', 'atreides', 'harkonnen', 'emperor', 'fremen'] as FactionId[],
expansions: [faction === 'ixians' || faction === 'tleilaxu' ? 'ix' : 'choam'],
})),
];
for (const { family, count } of families.flatMap(family =>
[2, 3, 4, 5, 6].filter(count => count >= (family.minimum ?? 2)).map(count => ({ family, count })))) {
const lobby = strongholdFactionsLobby(count, family.roster, family.expansions);
const original = structuredClone(lobby);
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(lobby), lobby.code);
const seats = db.prepare('SELECT * FROM seats').all();
@@ -982,19 +998,21 @@ void test('Stronghold factions admits only fresh exact-version native Advanced l
assert.equal(row.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, true);
assert.deepEqual(saved.expansions, ['ix', 'choam']);
assert.deepEqual(saved.expansions, family.expansions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
original.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(saved.playerPositions, original.playerPositions);
const cards = [...saved.deck, ...saved.discard, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)];
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, 47);
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(family.expansions).map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, family.expansions.includes('ix') ? 47 : 35);
const cache = family.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : [];
assert.deepEqual((saved.richeseCache ?? []).map(card => card.id).sort(), cache);
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
const ix = saved.players.find(p => p.faction === 'ixians')!;
assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) +
const ix = saved.players.find(p => p.faction === 'ixians');
if (ix) assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) +
Object.values(ix.elites?.forces ?? {}).reduce((sum, amount) => sum + amount, 0), 7);
const choam = saved.players.find(p => p.faction === 'choam')!;
assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1);
const choam = saved.players.find(p => p.faction === 'choam');
if (choam) assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1);
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.deepEqual(view.strongholdCards, saved.strongholdCards);
@@ -1017,8 +1035,11 @@ void test('Stronghold faction admission rejects incompatible modules, history, o
game => { game.advanced = false; },
game => { game.expansions = ['ix']; },
game => { game.expansions = ['ix', 'choam', 'ecaz']; },
game => { game.players[1] = newPlayer(game.players[1].id, 'Classic', 'guild'); game.players[1].ready = true; },
game => { game.players[2] = newPlayer(game.players[2].id, 'Tleilaxu', 'tleilaxu'); game.players[2].ready = true; },
game => { game.expansions = ['choam']; },
game => { game.expansions = ['ix', 'choam', 'ix']; },
game => { game.expansions = []; },
game => { game.players[2].faction = 'ecaz'; },
game => { game.players[0].faction = 'guild'; game.players[1].faction = 'fremen'; },
game => { game.players[0].ready = false; },
game => { game.turn = 2; },
game => { game.players[0].battleLosses = 1; },
+213
View File
@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { presenceAt } from '../game/force-presence';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { createRicheseStrongholdsFixture, finishRicheseStrongholdsBattle, nextRicheseStrongholdsNativeStep, revealRicheseStrongholdsBattle, richeseStrongholdsPlayer, type RicheseStrongholdsFixture } from './fixture-richese-strongholds';
function chooseStone(fixture: RicheseStrongholdsFixture, mode: 'kill' | 'ignore') {
const game = revealRicheseStrongholdsBattle(fixture);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, fixture.richese);
return applyAction(game, fixture.richese, { type: 'decision', event: game.battle!.event, mode });
}
/** Actual native settlement, independently accounting for later Collection. */
function settle(fixture: RicheseStrongholdsFixture, revealed: Game, callers: string[] = []) {
// Calls are declared on the real battle before deriving the independent quote.
let voted = revealed;
while (voted.battle?.revealed && !voted.decision && !voted.response) {
const next = nextRicheseStrongholdsNativeStep(voted);
if (!next || next.action.type !== 'traitorCall') break;
const remaining = viewGame(voted, voted.host).battle!.traitorVoters.filter(id => voted.battle!.traitorCalls[id] === undefined);
if (remaining.length === 1) break;
next.action.call = callers.includes(next.actor);
voted = applyAction(voted, next.actor, next.action);
}
// The last vote resolves immediately, so quote the still-live original source
// with exactly those public calls; never alter the engine's actual source.
const quoted = structuredClone(voted);
for (const actor of viewGame(quoted, quoted.host).battle!.traitorVoters) quoted.battle!.traitorCalls[actor] = callers.includes(actor);
const quote = quoteStrongholdFactionsBattle(quoted);
const resolved = finishRicheseStrongholdsBattle(fixture, voted, callers);
const collection = resolved.phase === 7 && !resolved.phaseOpening
? quoteSpiceCollection({ ...resolved, players: resolved.players.map(p => ({ ...p, spice: 0 })) }).receipts
: [];
for (const player of voted.players) {
const payment = quote.payments.find(p => p.player === player.id)?.ownPayment ?? 0;
const income = quote.strongholdIncome.filter(p => p.player === player.id).reduce((sum, p) => sum + p.amount, 0);
const bounty = quote.bounty?.player === player.id ? quote.bounty.amount : 0;
const choam = quote.choamIncome?.owner === player.id ? quote.choamIncome.amount : 0;
const collected = collection.filter(p => p.player === player.id).reduce((sum, p) => sum + p.strongholds + p.collected, 0);
assert.equal(richeseStrongholdsPlayer(resolved, player.id).spice, player.spice - payment + income + bounty + choam + collected, player.faction);
}
assert.equal(resolved.lastBattleContext?.winner, quote.winner);
return { resolved, quote };
}
void test('genuine E2 Richese setup supplies the separate cache, one-force shipment and first END Mentat control even for zero', () => {
for (const noFieldValue of [0, 3, 5] as const) {
const fixture = createRicheseStrongholdsFixture({ noFieldValue });
assert.equal(treacheryDeck(fixture.initial.expansions).length, 35);
assert.equal(fixture.initial.deck.length, 35);
assert.equal(fixture.initial.richeseCache!.length, 10);
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'richese')!.noField!.tokens.length, 3);
const before = richeseStrongholdsPlayer(fixture.beforeShipment, fixture.richese);
const shipped = richeseStrongholdsPlayer(fixture.afterShipment, fixture.richese);
assert.equal(shipped.spice, before.spice - 1, 'actual marker shipment pays one stronghold-force price');
assert.equal(shipped.reserves, before.reserves, 'shipment does not yet transfer physical reserves');
assert.equal(presenceAt(shipped, fixture.kind), 1);
assert.equal(strongholdControllers(fixture.beforeFirstMentat.players, false)[fixture.kind], fixture.richese);
assert.equal(fixture.beforeFirstMentat.strongholdCards!.owners[fixture.kind], null);
const ended = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(ended.turn, 2);
assert.equal(ended.strongholdCards!.owners[fixture.kind], fixture.richese);
assert.equal(fixture.afterFirstMentat.strongholdCards!.claimedTurn, 1);
}
});
void test('original undealt CLI initializer continues without redealing, and native unbid-cache acquisition really supplies Stone', () => {
const original = createRicheseStrongholdsFixture().initial;
const before = structuredClone(original);
const fixture = createRicheseStrongholdsFixture({ initial: original, stone: true });
assert.deepEqual(original, before);
assert.deepEqual(fixture.initial, before);
const acquisition = fixture.acquisition!;
assert.ok(acquisition.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
assert.equal(acquisition.before.players.some(p => p.hand.some(c => c.id === 'richese-stone-burner')), false);
let game = acquisition.before;
for (let count = 0; !richeseStrongholdsPlayer(game, fixture.richese).hand.some(card => card.id === 'richese-stone-burner') && count < 100; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
for (const player of acquisition.before.players) {
assert.equal(richeseStrongholdsPlayer(game, player.id).spice, player.spice, 'unbid acquisition has no original purchase charge');
}
assert.deepEqual(richeseStrongholdsPlayer(game, fixture.richese).hand.map(card => card.id).sort(),
[...richeseStrongholdsPlayer(acquisition.before, fixture.richese).hand.map(card => card.id), 'richese-stone-burner'].sort());
assert.ok(richeseStrongholdsPlayer(game, fixture.richese).hand.some(c => c.id === 'richese-stone-burner'));
assert.equal(game.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
});
void test('actual voluntary reveal removes zero presence or materializes 3/5 up to reserves, never exceeding the native twenty-counter pool', () => {
for (const noFieldValue of [0, 3, 5] as const) {
const fixture = createRicheseStrongholdsFixture({ noFieldValue, reserves: 2 });
const before = fixture.beforeReveal;
const revealed = applyAction(before, fixture.revealStep.actor, fixture.revealStep.action);
const player = richeseStrongholdsPlayer(revealed, fixture.richese);
assert.equal(player.forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`] ?? 0, Math.min(2, noFieldValue));
assert.equal(player.reserves, 2 - Math.min(2, noFieldValue));
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
assert.equal(presenceAt(player, fixture.kind), Math.min(2, noFieldValue));
assert.equal(player.noField!.deployed, null);
assert.equal(revealed.strongholdCards!.owners[fixture.kind], fixture.richese, 'losing physical control does not reassign the retained card before END Mentat');
}
});
void test('native marker-only plans use the reserve-limited private pool, reveal 0/3/5 only after both plans, and preserve supported costs', () => {
for (const noFieldValue of [0, 3, 5] as const) {
const fixture = createRicheseStrongholdsFixture({ noFieldValue, ownerDial: noFieldValue ? 1 : 0, support: noFieldValue ? 1 : 0 });
const own = viewGame(fixture.game, fixture.richese).battle!;
assert.equal(own.ownForces!.normal, noFieldValue);
assert.ok(own.ownForces!.normal <= 20);
assert.equal(own.strongholdEffects[fixture.richese], fixture.kind);
const sealed = applyAction(fixture.game, fixture.richese, fixture.planActions[0].action);
assert.ok(richeseStrongholdsPlayer(sealed, fixture.richese).noField!.deployed);
assert.equal(richeseStrongholdsPlayer(sealed, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`], undefined);
const revealed = revealRicheseStrongholdsBattle(fixture);
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).noField!.deployed, null);
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`] ?? 0, noFieldValue);
const { resolved, quote } = settle(fixture, revealed);
assert.equal(quote.winner, fixture.richese, 'printed Habbanya advantage applies to the zero-plan tie as well as ordinary supported victory');
assert.equal(quote.payments.find(p => p.player === fixture.richese)!.ownPayment, noFieldValue ? 1 : 0);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.opponent).tanks, 6);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).tanks, noFieldValue ? 1 : 0);
}
});
void test('Arrakeen allowance funds real supported marker battle costs without creating spendable spice', () => {
const fixture = createRicheseStrongholdsFixture({ kind: 'arrakeen', noFieldValue: 5, ownerDial: 3, support: 3 });
const revealed = revealRicheseStrongholdsBattle(fixture);
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).spice, 20, 'sealing a subsidized plan does not credit its wallet');
const { quote, resolved } = settle(fixture, revealed);
const payment = quote.payments.find(p => p.player === fixture.richese)!;
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 1);
assert.equal(quote.choamIncome!.amount, 1);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).forces['arrakeen:10'], 2);
});
void test('real cache-acquired Stone uses Habbanya undialed ties in both revealed modes, with declared support, native deaths and ordinary retention', () => {
for (const mode of ['kill', 'ignore'] as const) {
const fixture = createRicheseStrongholdsFixture({ stone: true, ownerDial: 2, opponentDial: 2, support: 2, opponentSupport: 2 });
const chosen = chooseStone(fixture, mode);
const { resolved, quote } = settle(fixture, chosen);
assert.equal(quote.winner, fixture.richese);
assert.deepEqual(quote.stone!.attacker, [4]);
assert.deepEqual(quote.stone!.defender, [4]);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`], 4);
for (const plan of fixture.planActions) {
assert.equal(richeseStrongholdsPlayer(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill');
assert.equal(quote.payments.find(p => p.player === plan.actor)!.cost, 2);
}
assert.equal(quote.choamIncome!.amount, 2, 'actual four-spice battle support produces native CHOAM income');
assert.ok(richeseStrongholdsPlayer(resolved, fixture.richese).hand.some(c => c.id === 'richese-stone-burner'));
}
const loser = createRicheseStrongholdsFixture({ stone: true, ownerDial: 3, support: 3, opponentDial: 1, opponentSupport: 1 });
const lost = settle(loser, chooseStone(loser, 'ignore'));
assert.equal(lost.quote.winner, loser.opponent, 'Habbanya does not override unequal undialed forces');
assert.ok(lost.resolved.discard.some(c => c.id === 'richese-stone-burner'));
});
void test('native traitor and explosion precedence outrank real Stone plus Habbanya; successful traitor waives declared support', () => {
const traitor = createRicheseStrongholdsFixture({ stone: true, traitors: 'owner', ownerDial: 2, support: 2 });
const called = settle(traitor, chooseStone(traitor, 'kill'), [traitor.richese]);
assert.equal(called.quote.winner, traitor.richese);
assert.equal(called.quote.payments.find(p => p.player === traitor.richese)!.freeByTraitor, true);
assert.equal(richeseStrongholdsPlayer(called.resolved, traitor.richese).leaders.find(l => l.id === traitor.planActions[0].action.leader)!.dead, false);
const mutual = createRicheseStrongholdsFixture({ stone: true, traitors: 'both' });
assert.equal(settle(mutual, chooseStone(mutual, 'kill'), [mutual.richese, mutual.opponent]).quote.winner, null);
const explosion = createRicheseStrongholdsFixture({ stone: true, ownerDefense: 'shield', opponentWeapon: 'lasgun', ownerDial: 1, support: 1, opponentDial: 1, opponentSupport: 1 });
const exploded = settle(explosion, chooseStone(explosion, 'kill'));
assert.equal(exploded.quote.explosion, true);
assert.equal(exploded.quote.winner, null);
assert.equal(exploded.quote.bounty, null);
assert.equal(richeseStrongholdsPlayer(exploded.resolved, explosion.richese).tanks, 6);
assert.equal(richeseStrongholdsPlayer(exploded.resolved, explosion.opponent).tanks, 6);
});
void test('all four existing policies legally consume real Richese marker plans, Stone admission and both-mode choice views', () => {
const marker = createRicheseStrongholdsFixture({ noFieldValue: 3, kind: 'arrakeen' });
const stone = createRicheseStrongholdsFixture({ stone: true, ownerDial: 1, support: 1, opponentDial: 1, opponentSupport: 1 });
for (const difficulty of DIFFICULTIES) {
for (const fixture of [marker, stone]) {
let game = structuredClone(fixture.game);
for (const actor of [fixture.richese, fixture.opponent]) {
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan'); assert.ok(plan, difficulty);
game = applyAction(game, actor, plan);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
while (game.decision || game.response) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
settle(fixture, game);
}
const game = revealRicheseStrongholdsBattle(stone);
const view = viewGame(game, stone.richese); view.players.find(p => p.id === stone.richese)!.bot = difficulty;
const mode = botActions(view)[0];
assert.ok(mode.mode === 'kill' || mode.mode === 'ignore');
const chosen = applyAction(game, stone.richese, mode);
assert.equal(chosen.battle!.stoneBurner![stone.richese], mode.mode);
assert.equal(finishRicheseStrongholdsBattle(stone, chosen).lastBattleContext!.winner, stone.richese);
}
});
+152
View File
@@ -0,0 +1,152 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { createTleilaxuStrongholdsFixture, finishTleilaxuStrongholdsTurn, tleilaxuStrongholdsPlayer as player, type TleilaxuStrongholdsFixture } from './fixture-tleilaxu-strongholds';
function at(game: Game, actor: string, site: string) {
return Object.entries(player(game, actor).forces).filter(([key]) => splitLocation(key).territory === site).reduce((sum, [, count]) => sum + count, 0);
}
function replace(fixture: TleilaxuStrongholdsFixture, sources: Record<string, number>): Game {
return applyAction(fixture.faceDance, fixture.tleilaxu, { type: 'decision', reveal: true, sources, sector: territory(fixture.kind).sectors[0] });
}
function returnedWinner(fixture: TleilaxuStrongholdsFixture, after: Game) {
const before = player(fixture.faceDance, fixture.ix), winner = player(after, fixture.ix);
const remaining = at(fixture.faceDance, fixture.ix, fixture.kind);
const elite = Object.entries(before.elites!.forces).filter(([key]) => splitLocation(key).territory === fixture.kind).reduce((sum, [, count]) => sum + count, 0);
assert.equal(at(after, fixture.ix, fixture.kind), 0);
assert.equal(winner.reserves, before.reserves + remaining);
assert.equal(winner.elites!.reserves, before.elites!.reserves + elite);
assert.equal(winner.tanks, before.tanks, 'Face Dance returns survivors, not a second set of battle casualties');
assert.equal(winner.elites!.tanks, before.elites!.tanks);
assert.equal(winner.forces[fixture.pointer], before.forces[fixture.pointer], 'forces outside the HMS pointer are not inside its battle');
assert.equal(winner.elites!.forces[fixture.pointer], before.elites!.forces[fixture.pointer]);
assert.equal(winner.spice, before.spice, 'the Face Dancer leader earns no extra bounty or clawback');
assert.deepEqual(winner.hand, before.hand, 'original winner keeps its actual physical battle cards');
const leader = winner.leaders.find(l => l.id === fixture.winnerLeader)!;
assert.equal(leader.dead, true); assert.equal(leader.deaths, before.leaders.find(l => l.id === fixture.winnerLeader)!.deaths + 1);
assert.equal(after.lastBattleContext!.winner, fixture.ix);
assert.deepEqual(after.strongholdCards, fixture.faceDance.strongholdCards);
assert.equal(player(after, fixture.tleilaxu).faceDancers!.find(c => c.leader === fixture.winnerLeader)!.revealed, true);
}
void test('actual native Ix/Tleilaxu setup draws its matching Face Dancer and first END Mentat awards the Stronghold Cards', () => {
const fixture = createTleilaxuStrongholdsFixture();
assert.equal(fixture.afterSetup.advanced, true);
assert.deepEqual(fixture.afterSetup.players.map(p => p.faction), ['ixians', 'tleilaxu', 'guild']);
const cards = [...fixture.afterSetup.deck, ...fixture.afterSetup.discard, ...fixture.afterSetup.players.flatMap(p => p.hand)];
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(fixture.afterSetup.expansions).map(c => c.id).sort());
assert.equal(player(fixture.afterSetup, fixture.ix).elites!.forces[MOBILE_LOCATION], 3);
assert.ok(player(fixture.afterSetup, fixture.tleilaxu).faceDancers!.some(c => c.leader === fixture.winnerLeader && !c.revealed), 'match came from actual first native draw');
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(fixture.beforeFirstMentat.strongholdCards!.owners).every(owner => owner === null));
const claimed = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(claimed.turn, 2);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, true));
assert.deepEqual(claimed.strongholdCards, fixture.afterFirstMentat.strongholdCards);
});
void test('original undealt CLI setup is not mutated, reinitialized or deterministically redealt', () => {
const original = createTleilaxuStrongholdsFixture().initial, before = structuredClone(original);
const fixture = createTleilaxuStrongholdsFixture({ initial: original });
assert.deepEqual(original, before); assert.deepEqual(fixture.initial, before);
assert.ok(player(fixture.faceDance, fixture.tleilaxu).faceDancers!.some(c => c.leader === fixture.winnerLeader && !c.revealed));
assert.equal(fixture.faceDance.decision?.kind, 'faceDance');
});
void test('original winner receives native casualties, leader bounty, physical card choice and retained Tabr income before Face Dance', () => {
for (const kind of ['sietch_tabr', MOBILE_STRONGHOLD] as const) {
const fixture = createTleilaxuStrongholdsFixture({ kind });
const quote = quoteStrongholdFactionsBattle(fixture.revealed);
assert.equal(quote.winner, fixture.ix);
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.ix, amount: 1 }]);
assert.deepEqual(quote.bounty, { player: fixture.ix, amount: player(fixture.revealed, fixture.opponent).leaders.find(l => l.id === fixture.loserLeader)!.strength });
const original = player(fixture.revealed, fixture.ix), winner = player(fixture.faceDance, fixture.ix), losses = quote.casualties!.options[0];
assert.equal(winner.tanks, original.tanks + losses.normal + losses.elite);
assert.equal(winner.elites!.tanks, original.elites!.tanks + losses.elite);
assert.equal(at(fixture.faceDance, fixture.ix, kind), 6 - losses.normal - losses.elite);
assert.equal(winner.spice, original.spice - quote.payments.find(p => p.player === fixture.ix)!.ownPayment + quote.bounty!.amount + 1);
assert.ok(winner.hand.some(c => c.id === fixture.weapon)); assert.ok(winner.hand.some(c => c.id === fixture.defense));
assert.equal(player(fixture.faceDance, fixture.opponent).tanks, 6);
assert.equal(player(fixture.faceDance, fixture.opponent).leaders.find(l => l.id === fixture.loserLeader)!.dead, true);
assert.equal(winner.leaders.find(l => l.id === fixture.winnerLeader)!.dead, false);
assert.equal(fixture.faceDance.decision?.kind, 'faceDance');
assert.equal(fixture.faceDance.decision!.identity, fixture.winnerLeader);
assert.deepEqual(fixture.faceDance.strongholdCards, fixture.beforeBattle.strongholdCards);
if (kind === MOBILE_STRONGHOLD) assert.equal(fixture.revealed.battle!.strongholdCopy, 'sietch_tabr');
}
});
void test('native replacement permits zero, partial and full quantities from actual reserves and board, without transferring cards mid-turn', () => {
const fixture = createTleilaxuStrongholdsFixture(), maximum = at(fixture.faceDance, fixture.ix, fixture.kind);
const board = `arrakeen:${territory('arrakeen').sectors[0]}`;
const choices: Record<string, number>[] = [{}, { reserves: 1 }, { [board]: 2 }, { reserves: maximum - 2, [board]: 2 }];
for (const sources of choices) {
const after = replace(fixture, sources), total = Object.values(sources).reduce((sum, count) => sum + count, 0);
returnedWinner(fixture, after);
assert.equal(at(after, fixture.tleilaxu, fixture.kind), total);
assert.equal(player(after, fixture.tleilaxu).reserves, player(fixture.faceDance, fixture.tleilaxu).reserves - (sources.reserves ?? 0));
assert.equal(player(after, fixture.tleilaxu).forces[board], 3 - (sources[board] ?? 0));
assert.equal(strongholdControllers(after.players, true)[fixture.kind], total ? fixture.tleilaxu : null);
assert.equal(strongholdBenefit(after.strongholdCards, fixture.ix, fixture.kind), fixture.kind);
assert.equal(strongholdBenefit(after.strongholdCards, fixture.tleilaxu, fixture.kind), null);
const next = finishTleilaxuStrongholdsTurn(after);
assert.equal(next.strongholdCards!.claimedTurn, after.turn);
assert.equal(next.strongholdCards!.owners[fixture.kind], total ? fixture.tleilaxu : null);
assert.equal(strongholdBenefit(next.strongholdCards, fixture.tleilaxu, fixture.kind), total ? fixture.kind : null);
}
});
void test('HMS replacement returns inside subtypes only, leaving the outside pointer and copied benefit intact until END Mentat', () => {
const fixture = createTleilaxuStrongholdsFixture({ kind: MOBILE_STRONGHOLD });
const maximum = at(fixture.faceDance, fixture.ix, MOBILE_STRONGHOLD);
const after = replace(fixture, { reserves: maximum }); returnedWinner(fixture, after);
assert.equal(at(after, fixture.tleilaxu, MOBILE_STRONGHOLD), maximum);
assert.equal(after.mobileStronghold!.location, fixture.pointer);
assert.equal(player(after, fixture.ix).forces[fixture.pointer], 2);
assert.equal(player(after, fixture.ix).elites!.forces[fixture.pointer], 1);
assert.equal(after.strongholdCards!.owners[MOBILE_STRONGHOLD], fixture.ix);
assert.equal(after.strongholdCards!.owners.sietch_tabr, fixture.ix);
const next = finishTleilaxuStrongholdsTurn(after);
assert.equal(next.strongholdCards!.owners[MOBILE_STRONGHOLD], fixture.tleilaxu);
assert.equal(next.strongholdCards!.owners.sietch_tabr, fixture.ix);
});
void test('army cap and physical source availability reject illegal replacement without consuming the actual Face Dancer', () => {
const fixture = createTleilaxuStrongholdsFixture(), before = structuredClone(fixture.faceDance);
const maximum = at(before, fixture.ix, fixture.kind), board = `arrakeen:${territory('arrakeen').sectors[0]}`;
for (const sources of [{ reserves: maximum + 1 }, { [board]: 4 }, { reserves: -1 }, { reserves: 0.5 }, { [fixture.location]: 1 }]) {
assert.throws(() => replace(fixture, sources));
assert.deepEqual(fixture.faceDance, before);
}
});
void test('declining the genuine matching Face Dancer leaves the winner, leader and end-turn control unchanged', () => {
const fixture = createTleilaxuStrongholdsFixture();
const after = applyAction(fixture.faceDance, fixture.tleilaxu, { type: 'decision', reveal: false });
assert.equal(at(after, fixture.ix, fixture.kind), at(fixture.faceDance, fixture.ix, fixture.kind));
assert.equal(player(after, fixture.ix).leaders.find(l => l.id === fixture.winnerLeader)!.dead, false);
assert.equal(player(after, fixture.tleilaxu).faceDancers!.find(c => c.leader === fixture.winnerLeader)!.revealed, false);
assert.equal(finishTleilaxuStrongholdsTurn(after).strongholdCards!.owners[fixture.kind], fixture.ix);
});
void test('all four native policies execute the actual Stronghold Face Dance using legal physical sources', () => {
for (const kind of ['sietch_tabr', MOBILE_STRONGHOLD] as const) {
const fixture = createTleilaxuStrongholdsFixture({ kind });
for (const level of DIFFICULTIES) {
const view = viewGame(fixture.faceDance, fixture.tleilaxu); view.players.find(p => p.id === fixture.tleilaxu)!.bot = level;
const action: Action | undefined = botActions(view).find(a => a.type === 'decision'); assert.ok(action);
assert.equal(action.reveal, true);
const sources = action.sources as Record<string, number>, count = Object.values(sources).reduce((sum, n) => sum + n, 0);
assert.equal(count, at(fixture.faceDance, fixture.ix, kind));
const after = applyAction(fixture.faceDance, fixture.tleilaxu, action); returnedWinner(fixture, after);
assert.equal(at(after, fixture.tleilaxu, kind), count);
assert.equal(finishTleilaxuStrongholdsTurn(after).strongholdCards!.owners[kind], fixture.tleilaxu);
}
}
});