Accelerate rules-first feedback and integrate native Strongholds

Apply user priority: save/recovery/privacy/custody assurance after all rules. Add quick types/lint, concise failure-preserving reporter, verbose mode, actual empty-execution guard, signal handling and focused verify name selection; compact canonical context/handoffs without mass moves. Tooling15, six negative CLI, named/empty source-bound smokes,206 doc links; same15-case278B vs2455B,2.35s vs2.36s no compute speedclaim. Native Advanced Ix/CHOAM47-card Stronghold profile: actual firstMentat/HMS/sixeffects,18native/four legalpolicies,five genuine2..6games3129actions/no rejection, human390px LAUDR2WNv14 bank2/personal1 plan and real battle. Earlier162/6513 checks before assurance amendment; finalquick25/build/guide pass. No new preservation/privacy audit or public/deployed/full-mode claim.
This commit is contained in:
Quintine committed 2026-10-02 09:30:52 +10:00
1 parent af6d27095c
commit e9830b6ced
27 files changed
+2339 -194

No files matched your search

+82
View File
@@ -13,6 +13,11 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Game } from '../game/engine';
import { isAuditorLeader, treacheryDeck } from '../game/cards';
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
import { createTechTokens } from '../game/tech-tokens';
const root = new URL('../', import.meta.url).pathname;
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
type CliResult = {
@@ -94,6 +99,83 @@ function assertFailedEvidence(
return report;
}
void test('native Stronghold samples admit every two-to-six-seat roster and preserve canonical physical inventories', (t) => {
const area = temporary(t);
const out = join(area, 'native');
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);
for (const [index, result] of results.entries()) {
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.players.map(player => player.bot),
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, index + 2));
const inventory = sampleInventory(game);
assert.deepEqual(inventory.cards, canonical);
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 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) => {
const area = temporary(t);
const basic = join(area, 'basic');
assert.equal(run(basic, '--profile', 'stronghold-factions', '--rules', 'basic').status, 1);
assert.equal(existsSync(basic), false);
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 corruptions: [string, (game: Game) => void][] = [
['basic', game => { game.advanced = false; }],
['wrong-deck', game => { game.expansions = ['ix']; }],
['foreign-faction', game => { game.players[1].faction = 'richese'; }],
['missing-module', game => { delete game.strongholdCards; }],
['nexus', game => { game.nexusCards = { cards: null, phase: null }; }],
['tech', game => { game.techTokens = createTechTokens(); }],
['missing-stronghold', game => { Reflect.deleteProperty(game.strongholdCards!.owners, 'arrakeen'); }],
['foreign-owner', game => { game.strongholdCards!.owners.arrakeen = 'foreign-seat'; }],
['future-custody', game => { game.strongholdCards!.claimedTurn = game.turn + 1; }],
['missing-treachery', game => { game.deck.pop(); }],
];
for (const [label, corrupt] of corruptions) {
const game = structuredClone(source);
corrupt(game);
const snapshot = join(area, `${label}.json`);
writeFileSync(snapshot, JSON.stringify(game), { mode: 0o600 });
const before = readFileSync(snapshot, 'utf8');
const rejected = join(area, `${label}-resume`);
assert.equal(run(rejected, '--resume', snapshot, '--max-actions', '1').status, 1, label);
assert.equal(existsSync(rejected), false, label);
assert.equal(readFileSync(snapshot, 'utf8'), before, label);
}
});
void test('Ecaz card samples keep all three physical identities through saved resume', (t) => {
const area = temporary(t);
const out = join(area, 'ecaz-cards');
+407
View File
@@ -0,0 +1,407 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
import { isAuditorLeader, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { STRONGHOLD_CARDS, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
export type StrongholdFactionsNativeStep = { actor: string; action: Action };
export type StrongholdFactionsFixtureOptions = {
initial?: Game;
seatIds?: string[];
kind?: StrongholdId;
copy?: StrongholdId;
copyChoices?: 0 | 1 | 2;
ownerFaction?: FactionId;
attackerIsOwner?: boolean;
ownerDial?: number;
opponentDial?: number;
support?: number;
opponentSupport?: number;
allyPayment?: number;
choamFunding?: number;
ownerSpice?: number;
ownerLeader?: 'auditor';
ownerWeapon?: Card['kind'];
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
opponentDefense?: Card['kind'];
traitors?: 'owner' | 'both';
counter?: boolean;
mobileRoute?: string[];
};
export type StrongholdFactionsSourceFacts = {
event: string;
turn: number;
territory: StrongholdId;
attacker: string;
defender: string;
owner: string;
copyChoices: StrongholdId[];
cardOwners: Record<StrongholdId, string | null>;
currentControllers: Record<StrongholdId, string | null>;
physicalForces: { player: string; forces: Record<string, number>; eliteForces: Record<string, number> }[];
};
export type StrongholdFactionsFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game;
beforeBattle: Game;
battleAction: Action;
actor: string;
owner: string;
opponent: string;
choam: string;
game: Game;
event: string;
kind: StrongholdId;
copyAction: Action | null;
fundingAction: StrongholdFactionsNativeStep | null;
planActions: StrongholdFactionsNativeStep[];
staging: string[];
sourceFacts: StrongholdFactionsSourceFacts;
};
const seat = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original seat ${id}`);
return player;
};
const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`;
/** Only consumes actual native decisions. A null result with a live battle means
* a human must seal a plan; the helper never reads or invents that private plan. */
export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
if (game.status === 'finished') return null;
if (game.pendingTreacheryDiscard) return { actor: game.host, action: { type: 'advanceBots' } };
if (game.phaseOpening) {
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))?.id;
assert.ok(actor);
return { actor, action: { type: 'ready' } };
}
if (game.response) {
// Use projected legal responses: an absent Karama does not create a pause.
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).find(a => a.type === 'passResponse');
if (action) return { actor: player.id, action };
}
throw Error(`No actual eligible counter at ${game.response.kind}`);
}
const decision = game.decision;
if (decision) {
const actor = decision.player;
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
return { actor, action: { type: 'decision', decline: true, ...(decision.kind === 'captureOffer' ? { accept: false } : {}) } };
if (decision.kind === 'choamMarket') return { actor, action: { type: 'decision', done: true } };
if (decision.kind === 'choamFreeRevival') return { actor, action: { type: 'decision', decline: true } };
if (decision.kind === 'choamBattleFunding') return { actor, action: { type: 'decision', amount: 0 } };
if (decision.kind === 'strongholdCopy') return { actor, action: { type: 'decision', event: decision.event, stronghold: decision.choices[0] } };
if (decision.kind === 'auctionPayment') return { actor, action: { type: 'decision', karama: false } };
if (decision.kind === 'guildShipment') return { actor, action: { type: 'decision', allow: true } };
if (decision.kind === 'poisonTooth') return { actor, action: { type: 'decision', activate: true } };
if (decision.kind === 'battleLosses') return { actor, action: { type: 'decision', choice: 0 } };
if (decision.kind === 'battleCards') return { actor, action: { type: 'decision', discard: [] } };
if (decision.kind === 'choamAudit') return { actor, action: { type: 'decision', event: decision.event, audit: false } };
if (decision.kind === 'choamAuditPayment') return { actor, action: { type: 'decision', event: decision.event, pay: false } };
}
if (game.battle?.preLeader && !game.battle.preLeader.closed) {
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id));
if (actor) return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } };
}
if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
if (game.battle?.revealed) {
const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined);
if (actor) return { actor, action: { type: 'traitorCall', call: false } };
}
if (game.battle && !game.battle.revealed && !game.decision) return null;
if (game.status === 'playing' && !game.decision) {
if (game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: 0 } };
}
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
if (game.phase !== 6 || !game.active) {
const actor = game.players.find(p => !game.ready.includes(p.id))?.id;
if (actor) return { actor, action: { type: 'ready' } };
}
}
for (const player of game.players) {
if (decision && decision.player !== player.id) continue;
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const actions = botActions(view);
const action = actions.find(a => a.type === 'ready') ?? actions[0];
if (action) return { actor: player.id, action };
}
throw Error(`No native Stronghold continuation at ${game.status}/${game.turn}/${game.phase}/${decision?.kind}`);
}
function step(game: Game): Game {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'Native continuation stopped for an unsealed human plan');
return applyAction(game, next.actor, next.action);
}
function advance(game: Game, predicate: (state: Game) => boolean): Game {
for (let count = 0; count < 1800; count++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
game = step(game);
}
throw Error('Native Stronghold phase did not reach its requested boundary');
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Determinism is scoped only to the original native initializer. Saved decks,
* offers and native remainder shuffles are never reordered or redealt. */
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');
}
}
/** Canonical conserved staging, never hand replacement or face manufacture. */
function heldCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], excluded: string[] = []): Card {
const player = seat(game, actor);
const held = player.hand.find(card => matches(card) && !excluded.includes(card.id));
if (held) return held;
const index = game.deck.findIndex(card => matches(card) && !excluded.includes(card.id));
assert.ok(index >= 0, 'The required canonical card is neither held nor in the remaining native deck');
const card = game.deck.splice(index, 1)[0];
assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4));
player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
return card;
}
/** Return board counters to their own reserves without changing subtype totals. */
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, actor: string, location: string, count: number, elite = 0): void {
const player = seat(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 conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = seat(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
const index = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(index >= 0 || donor, 'Requested canonical traitor retains native reserve or hand custody');
if (index >= 0) game.traitorReserve!.splice(index, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}`);
}
/** Genuine Advanced native setup, first full turn and actual END Mentat custody.
* Only labeled conserved board/card/bank positions are staged. Never assign
* owners, phases, source receipts, quotations, pending windows or responses. */
export function createStrongholdFactionsFixture(options: StrongholdFactionsFixtureOptions = {}): StrongholdFactionsFixture {
const kind = options.kind ?? MOBILE_STRONGHOLD;
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].sort(), ['choam', 'ix']);
assert.ok(game.players.some(p => p.faction === 'ixians') && game.players.some(p => p.faction === 'choam'));
} else {
const ids = options.seatIds ?? ['ix', 'choam', 'guild'];
assert.ok(ids.length >= 2 && ids.length <= 6 && new Set(ids).size === ids.length);
const factions: FactionId[] = ['ixians', 'choam', 'guild', 'emperor', 'fremen', 'harkonnen'];
game = createGame('STRONGHOLDFACTIONS', newPlayer(ids[0], 'Ixians', factions[0]), true, ['ix', 'choam']);
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
}
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);
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
for (let count = 0; game.status === 'setup' && count < 200; count++) game = step(game);
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const ix = game.players.find(p => p.faction === 'ixians')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id;
const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'ixians'))?.id;
assert.ok(owner, 'Requested owner must be an original native seat');
const opponent = game.players.find(p => p.id !== owner && p.id !== choam)?.id ?? game.players.find(p => p.id !== owner)!.id;
if (options.choamFunding || options.allyPayment) assert.ok(owner !== choam && opponent !== choam, 'Native donor needs a separate CHOAM seat');
// Select real first dials, not the storm field. A later 1..6 native storm
// cannot obstruct this test's single printed battle sector.
for (let count = 0; !(game.phase === 8 && clean(game)) && count < 1600; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = kind === 'tueks_sietch' || kind === 'habbanya_ridge_sietch' ? 4 : 0;
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 8); assert.equal(game.turn, 1);
reserveBoard(game);
staging.push('Before actual first END Mentat: relocate existing physical counters through their own reserves; nobody controls three strongholds');
place(game, ix, MOBILE_LOCATION, 6, 3);
const firstCopy = options.copy && options.copy !== MOBILE_STRONGHOLD ? options.copy : 'arrakeen';
if (kind === MOBILE_STRONGHOLD) {
if (owner !== ix) {
reserveBoard(game);
place(game, owner, MOBILE_LOCATION, 6);
}
place(game, owner, key(firstCopy), 1);
} else place(game, owner, key(kind), 1);
const occupied = new Set([kind, MOBILE_STRONGHOLD, ...(kind === MOBILE_STRONGHOLD ? [firstCopy] : [])]);
const others = game.players.filter(p => p.id !== owner && p.id !== ix);
for (const [index, card] of STRONGHOLD_CARDS.filter(card => !occupied.has(card.id)).entries()) {
const recipient = others[Math.floor(index / 2)];
if (recipient) place(game, recipient.id, key(card.id), 1);
}
// Keep every first-turn owner below the solo victory threshold.
for (const player of game.players) assert.ok(Object.keys(player.forces).length <= 2);
let beforeFirstMentat: Game | undefined;
let firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextStrongholdFactionsNativeStep(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!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners[kind], owner);
const afterFirstMentat = structuredClone(game);
if (options.mobileRoute) {
game = advance(game, state => state.decision?.kind === 'mobileStronghold');
assert.equal(game.decision?.kind, 'mobileStronghold');
game = applyAction(game, ix, { type: 'decision', route: options.mobileRoute, collect: false });
}
if (options.choamFunding || options.allyPayment) {
game = advance(game, state => state.phase === 1 && clean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus);
const thumperActor = game.players.find(player => player.hand.some(card => card.effect === 'thumper'))?.id ?? owner;
const thumper = heldCard(game, thumperActor, card => card.effect === 'thumper', staging);
game = applyAction(game, thumperActor, { type: 'card', card: thumper.id });
game = advance(game, state => state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution);
game = applyAction(game, owner, { type: 'alliance', target: choam });
game = applyAction(game, choam, { type: 'alliance', target: owner });
assert.equal(seat(game, owner).ally, choam);
}
game = advance(game, state => state.phase === 5 && clean(state));
reserveBoard(game);
staging.push('Before native turn-two movement completion: conserve six battle counters per combatant and current copy-control counters; retain actual first-Mentat card custody');
for (const id of [owner, opponent]) place(game, id, key(kind), 6, seat(game, id).faction === 'ixians' ? 3 : 0);
if (kind !== MOBILE_STRONGHOLD && ix !== owner && ix !== opponent) place(game, ix, MOBILE_LOCATION, 6, 3);
if (kind === MOBILE_STRONGHOLD) {
const choices = options.copyChoices ?? 2;
if (choices) place(game, owner, key(firstCopy), 1);
if (choices === 2) place(game, owner, key(firstCopy === 'sietch_tabr' ? 'arrakeen' : 'sietch_tabr'), 1);
}
for (const player of game.players) {
player.spice = player.id === owner ? options.ownerSpice ?? 20 : 20;
}
staging.push('Bank funding staging: original seats receive explicit twenty-spice test balances (ownerSpice may set the affordability boundary); no aid or support receipt is injected');
const used: string[] = [];
const card = (actor: string, type: Card['kind'] | undefined) => {
if (!type) return null;
const selected = heldCard(game, actor, c => c.kind === type, staging, used);
used.push(selected.id); return selected.id;
};
const ownerWeapon = card(owner, options.ownerWeapon);
const ownerDefense = card(owner, options.ownerDefense);
const opponentWeapon = card(opponent, options.opponentWeapon);
const opponentDefense = card(opponent, options.opponentDefense);
if (options.counter) heldCard(game, opponent, c => c.effect === 'karama', staging);
const ownerLeader = seat(game, owner).leaders.find(l => options.ownerLeader === 'auditor' ? isAuditorLeader(l) : !isAuditorLeader(l) && l.strength === 5)
?? seat(game, owner).leaders.find(l => !isAuditorLeader(l));
const opponentLeader = seat(game, opponent).leaders.find(l => !isAuditorLeader(l) && l.strength === (options.ownerLeader === 'auditor' ? 2 : ownerLeader!.strength))
?? seat(game, opponent).leaders.find(l => !isAuditorLeader(l));
assert.ok(ownerLeader && opponentLeader);
if (options.ownerLeader === 'auditor') assert.ok(isAuditorLeader(ownerLeader), 'The actual native CHOAM Auditor is required');
if (options.traitors) {
conserveTraitor(game, owner, opponentLeader.id, staging);
if (options.traitors === 'both') conserveTraitor(game, opponent, ownerLeader.id, staging);
}
// Finish genuine movement; the battle queue determines its real chooser.
game = advance(game, state => state.phase === 6 && clean(state));
const beforeBattle = structuredClone(game);
const actor = game.active!;
assert.ok([owner, opponent].includes(actor));
if (options.attackerIsOwner !== undefined) assert.equal(actor === owner, options.attackerIsOwner, 'Native storm order decides physical attacker; this option does not rewrite it');
const battleAction: Action = { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner };
game = applyAction(game, actor, battleAction);
for (let count = 0; count < 120; count++) {
if (game.decision?.kind === 'strongholdCopy') break;
const next = nextStrongholdFactionsNativeStep(game);
if (!next) break;
if (game.decision?.kind === 'choamBattleFunding') next.action.amount = options.choamFunding ?? 0;
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.battle?.event && !game.battle.revealed);
const copyAction: Action | null = game.decision?.kind === 'strongholdCopy' ? { type: 'decision', event: game.decision.event, stronghold: options.copy ?? firstCopy } : null;
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, allyPayment: options.allyPayment ?? 0, leader: ownerLeader.id, weapon: ownerWeapon, defense: ownerDefense } },
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: opponentLeader.id, weapon: opponentWeapon, defense: opponentDefense } },
];
const fundingAction: StrongholdFactionsNativeStep | null = options.choamFunding || options.allyPayment ? { actor: choam, action: { type: 'decision', amount: options.choamFunding ?? options.allyPayment ?? 0 } } : null;
const currentControllers = strongholdControllers(game.players, !!game.mobileStronghold?.location);
const sourceFacts: StrongholdFactionsSourceFacts = {
event: game.battle.event, turn: game.turn, territory: kind,
attacker: game.battle.attacker, defender: game.battle.defender, owner,
copyChoices: STRONGHOLD_CARDS.filter(card => card.id !== MOBILE_STRONGHOLD && currentControllers[card.id] === owner).map(card => card.id),
cardOwners: { ...game.strongholdCards!.owners }, currentControllers,
physicalForces: game.players.map(player => ({ player: player.id, forces: { ...player.forces }, eliteForces: { ...player.elites?.forces } })),
};
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleAction, actor, owner, opponent, choam, game, event: game.battle.event, kind, copyAction, fundingAction, planActions, staging, sourceFacts };
}
/** Independent shared quote on the actual revealed source, including native
* subtype pools, donor escrow, bounty and bank income as separate legs. */
export function quoteStrongholdFactionsBattle(game: Game): BattleResolutionQuote {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = seat(game, actor), view = viewGame(game, actor).battle!;
const aid = player.ally ? game.aid[player.ally] : undefined;
assert.ok(view.ownForces);
return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor], poisonTooth: battle.poisonTooth?.[actor],
lateDefense: battle.lateDefense?.[actor], stoneMode: battle.stoneBurner?.[actor],
...(aid?.recipient === actor ? { aid: { donor: player.ally!, amount: aid.amount } } : {}) };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : seat(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: seat(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false });
}
export type StrongholdFactionsBattleQuote = BattleResolutionQuote;
+151
View File
@@ -20,6 +20,8 @@ import {
} from '../tools/prototype-room';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { createLeaderSkills } from '../game/leader-skills';
import { createStrongholdCards } from '../game/stronghold-cards';
import { treacheryDeck } from '../game/cards';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
@@ -952,3 +954,152 @@ void test('Banker income CLI requires a fresh exact-version private backup and p
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
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;
for (let index = 1; index < count; index++)
joinGame(game, newPlayer('seat-' + index, factions[index - 1], factions[index - 1]));
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 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();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
startPrototypeRoom(db, lobby.code, 7, 'stronghold-factions');
const row = db.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(saved.version, 8);
assert.equal(row.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, true);
assert.deepEqual(saved.expansions, ['ix', 'choam']);
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(saved.strongholdCards, createStrongholdCards());
const ix = saved.players.find(p => p.faction === 'ixians')!;
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);
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.deepEqual(view.strongholdCards, saved.strongholdCards);
assert.equal('deck' in view, false);
for (const otherPlayer of view.players)
if (otherPlayer.id !== p.id) assert.equal(otherPlayer.hand, undefined);
}
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
}
});
void test('Stronghold faction admission rejects incompatible modules, history, ownership and public retrofit immutably', t => {
const { db } = fixture(t);
const native = strongholdFactionsLobby();
const variants: ((game: Game) => void)[] = [
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.players[0].ready = false; },
game => { game.turn = 2; },
game => { game.players[0].battleLosses = 1; },
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.owners.arrakeen = game.host; },
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.claimedTurn = 1; },
game => { game.nexusCards = { cards: null, phase: null }; },
game => { game.leaderSkills = createLeaderSkills(() => 0); },
];
for (const mutate of variants) {
const game = structuredClone(native);
mutate(game);
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
}
for (const type of ['homeworlds', 'techTokens']) {
const game = applyAction(native, native.host, { type, enabled: true });
game.players.forEach(player => { player.ready = true; });
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
}
const original = structuredClone(native);
for (const action of [{ type: 'start', profile: 'stronghold-factions' },
{ type: 'initializeStrongholdFactionsGameForAudit' }, { type: 'strongholdFactions', enabled: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Stronghold factions CLI privately backs up the exact original native lobby and cannot reset or overwrite proof', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-stronghold-factions-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const lobby = strongholdFactionsLobby(6);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run('KEEPGAME', JSON.stringify(harkonnenLobby(false, 2)), 9, 50);
for (const p of lobby.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, p.id, 'private-native-session-' + p.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'stronghold-factions', '--db', database, '--room', lobby.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const result = invoke(7, output);
assert.equal(result.status, 0, result.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPGAME'), before.find(row => row.code === 'KEEPGAME'));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
const saved = JSON.parse(row.state as string) as Game;
assert.equal(row.version, 8);
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
lobby.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
assert.deepEqual([...saved.deck, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)].map(card => card.id).sort(),
treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
assert.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});
+43
View File
@@ -19,6 +19,7 @@ import {
resolveAssassinationBattle,
stageAssassinationBattle,
} from './moritani-assassinate-fixture';
import { createStrongholdFactionsFixture } from './fixture-stronghold-factions';
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
const game = completedMoritaniSkillsGame();
@@ -52,6 +53,48 @@ void test('Moritani assassination samples conserve retired Traitors after real b
duplicated.traitorReserve!.push('guild-1');
assert.throws(() => verifySampleCustody(duplicated, inventory), /physical traitor custody/);
});
void test('native Stronghold sample custody survives real end-Mentat acquisition and retained next-turn ownership', () => {
const fixture = createStrongholdFactionsFixture();
const inventory = sampleInventory(fixture.initial);
assert.equal(inventory.cards.length, 47);
assert.equal(inventory.strongholds?.length, 6);
verifySampleCustody(fixture.beforeFirstMentat, inventory);
verifySampleCustody(fixture.afterFirstMentat, inventory, fixture.beforeFirstMentat);
assert.equal(fixture.afterFirstMentat.strongholdCards!.claimedTurn, 1);
const owner = fixture.afterFirstMentat.strongholdCards!.owners.hidden_mobile_stronghold;
assert.ok(owner, 'actual end-Mentat claims the mobile card');
verifySampleCustody(fixture.beforeBattle, inventory, fixture.afterFirstMentat);
const saved = JSON.parse(JSON.stringify(fixture.game));
verifySampleCustody(saved, inventory, fixture.beforeBattle);
const missing = structuredClone(fixture.game);
delete missing.strongholdCards;
assert.throws(() => verifySampleCustody(missing, inventory), /Stronghold Cards module custody/);
const missingCard = structuredClone(fixture.game);
Reflect.deleteProperty(missingCard.strongholdCards!.owners, 'arrakeen');
assert.throws(() => verifySampleCustody(missingCard, inventory), /Each Stronghold Card/);
const foreign = structuredClone(fixture.game);
foreign.strongholdCards!.owners.arrakeen = 'not-a-seat';
assert.throws(() => verifySampleCustody(foreign, inventory), /current seat/);
const future = structuredClone(fixture.game);
future.strongholdCards!.claimedTurn = future.turn + 1;
assert.throws(() => verifySampleCustody(future, inventory), /future/);
const transferred = structuredClone(fixture.game);
const other = transferred.players.find(player => player.id !== owner)!;
transferred.strongholdCards!.owners.hidden_mobile_stronghold = other.id;
assert.throws(() => verifySampleCustody(transferred, inventory, fixture.game),
/holders must persist/);
const missingCyborg = structuredClone(fixture.game);
const ixians = missingCyborg.players.find(player => player.faction === 'ixians')!;
assert.ok(ixians.elites);
if (ixians.elites.reserves > 0) ixians.elites.reserves--;
else if (ixians.elites.tanks > 0) ixians.elites.tanks--;
else {
const location = Object.keys(ixians.elites.forces).find(key => ixians.elites!.forces[key] > 0);
assert.ok(location);
ixians.elites.forces[location]--;
}
assert.throws(() => verifySampleCustody(missingCyborg, inventory), /elite custody ixians/);
});
function fixture(advanced = false) {
let game = createGame(
+365
View File
@@ -0,0 +1,365 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { startPrototypeRoom } from '../tools/prototype-room';
import { unitStore } from './fixture-nexus-room-store';
import {
createStrongholdFactionsFixture,
nextStrongholdFactionsNativeStep,
quoteStrongholdFactionsBattle,
type StrongholdFactionsFixture,
type StrongholdFactionsFixtureOptions as Options,
} from './fixture-stronghold-factions';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
type SqlRow = Record<string, SQLOutputValue>;
interface ProtectedRows {
seats: SqlRow[];
entries: SqlRow[];
keys: SqlRow[];
receipts: SqlRow[];
}
interface Fixture extends StrongholdFactionsFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
code: string;
tokens: string[];
auths: SeatAuth[];
save(game: Game): void;
admitted: Game;
unrelated: SqlRow | undefined;
other: { view: { code: string } };
credentials: ProtectedRows;
}
function protectedRows(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
function census(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
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);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
if (player.faction === 'ixians') {
assert.ok(player.elites);
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), 7);
}
}
}
async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const created = await store.rooms.createRoom('Native Ix custody', 'ixians', true, ['ix', 'choam']);
const code = created.view.code;
const tokens = [created.token];
for (const faction of ['choam', 'atreides'] as const) {
const joined = await store.rooms.joinRoom(code, faction, faction);
assert.ok(joined.token);
tokens.push(joined.token);
}
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
for (const auth of auths) {
const lobby = await store.restart().readRoom(code);
await store.restart().act(code, auth, lobby.version, { type: 'ready' }, clock);
}
const lobby = await store.restart().readRoom(code);
startPrototypeRoom(store.sqlite, code, lobby.version, 'stronghold-factions');
const admitted = await store.restart().readRoom(code);
const native = createStrongholdFactionsFixture({ ...options, initial: admitted });
assert.deepEqual(native.initial, admitted, 'fixture consumes the exact original CLI-admitted setup without redealing');
assert.deepEqual(native.afterSetup.playerPositions, admitted.playerPositions);
assert.deepEqual(native.afterSetup.players.map(p => [p.id, p.name, p.faction]), admitted.players.map(p => [p.id, p.name, p.faction]));
census(admitted);
census(native.game);
const other = await store.rooms.createRoom('Unrelated retained room', 'guild', false, []);
const unrelated = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.view.code);
const credentials = protectedRows(store.sqlite);
const save = (game: Game) => {
assert.equal(game.code, code);
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(game), game.version, code).changes, 1);
};
save(native.beforeFirstMentat);
return { ...store, ...native, code, tokens, auths, save, admitted, unrelated, other, credentials };
}
async function authenticated(f: Fixture, actor: string) {
const index = f.auths.findIndex(auth => auth.playerId === actor);
assert.ok(index >= 0, 'native action belongs to an original credential');
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
assert.equal(auth.playerId, actor);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, game: Game, actor: string, action: Action) {
await f.restart().act(f.code, await authenticated(f, actor), game.version, action, clock);
const after = await f.restart().readRoom(f.code);
census(after);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.other.view.code), f.unrelated);
return after;
}
async function rejected(f: Fixture, actor: string, action: Action, version?: number) {
const before = await f.restart().readRoom(f.code);
const rows = f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = protectedRows(f.sqlite);
await assert.rejects(f.restart().act(f.code, await authenticated(f, actor), version ?? before.version, action, clock));
assert.deepEqual(await f.restart().readRoom(f.code), before);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(protectedRows(f.sqlite), credentials);
}
async function race(f: Fixture, game: Game, actor: string, actions: [Action, Action]) {
const auth = await authenticated(f, actor);
let release!: () => void;
let arrivals = 0;
const barrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await barrier;
};
try {
const results = await Promise.allSettled(actions.map(action => f.restart().act(f.code, auth, game.version, action, clock)));
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, game.version + 1);
census(after);
return after;
} finally {
f.hooks.beforeWrite = undefined;
}
}
async function drive(f: Fixture, game: Game, stop: (game: Game) => boolean) {
for (let count = 0; count < 250; count++) {
if (stop(game)) return game;
const next = game.decision?.kind === 'choamBattleFunding' && f.fundingAction
? f.fundingAction
: nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'real native continuation reaches the requested boundary');
game = await act(f, game, next.actor, next.action);
}
throw new Error('Native suffix did not reach its boundary');
}
async function openBattle(f: Fixture) {
f.save(f.beforeBattle);
let game = await f.restart().readRoom(f.code);
game = await act(f, game, f.actor, f.battleAction);
return drive(f, game, g => g.decision?.kind === 'strongholdCopy' || nextStrongholdFactionsNativeStep(g) === null);
}
async function privateViews(f: Fixture, game: Game) {
for (const auth of f.auths) {
const view = await f.restart().readSeatView(f.code, await authenticated(f, auth.playerId));
assert.deepEqual(view, viewGame(game, auth.playerId));
assert.deepEqual(view.strongholdCards, game.strongholdCards);
assert.equal('deck' in view, false);
for (const player of view.players)
if (player.id !== auth.playerId) assert.equal(player.hand, undefined);
if (game.battle && !game.battle.revealed) {
assert.deepEqual(view.battle!.cards, []);
assert.deepEqual(view.battle!.plans, game.battle.plans[auth.playerId] ? { [auth.playerId]: game.battle.plans[auth.playerId] } : {});
}
}
}
void test('authenticated native END Mentat first claims custody once under duplicate CAS and retains the original seats and 47-card inventory', async t => {
const f = await fixture(t);
const before = await f.restart().readRoom(f.code);
assert.deepEqual(before.strongholdCards, f.admitted.strongholdCards);
assert.equal(before.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(before.strongholdCards!.owners).every(owner => owner === null));
const { actor, action } = f.firstMentatStep;
const after = await race(f, before, actor, [action, action]);
assert.deepEqual(after.strongholdCards, f.afterFirstMentat.strongholdCards);
assert.equal(after.strongholdCards!.claimedTurn, before.turn);
assert.equal(after.strongholdCards!.owners.hidden_mobile_stronghold, f.owner);
assert.deepEqual(protectedRows(f.sqlite), f.credentials);
await rejected(f, actor, action, before.version);
await privateViews(f, after);
f.save(f.beforeBattle);
assert.deepEqual(f.beforeBattle.strongholdCards, after.strongholdCards, 'custody is retained next turn, not reassigned from current battle control');
await privateViews(f, await f.restart().readRoom(f.code));
});
void test('actual native HMS copy and sealed plans survive restart and credential recovery; foreign, stale and competing choices are immutable', async t => {
const f = await fixture(t, { ownerDial: 6, support: 3, opponentDial: 1.5, copy: 'arrakeen' });
const pending = await openBattle(f);
assert.equal(pending.decision?.kind, 'strongholdCopy');
assert.ok(f.copyAction);
const copyAction = { ...f.copyAction, event: pending.battle!.event };
if (pending.decision?.kind !== 'strongholdCopy' || f.game.decision?.kind !== 'strongholdCopy')
throw new Error('Native HMS copy choice missing');
assert.deepEqual(pending.decision.choices, f.game.decision.choices);
assert.equal(pending.decision.player, f.owner);
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(await f.restart().readRoom(f.code), pending);
await privateViews(f, pending);
await rejected(f, f.opponent, copyAction);
await rejected(f, f.owner, { ...copyAction, event: 'foreign-stale-event' });
await rejected(f, f.owner, { ...copyAction, stronghold: 'carthag' });
await rejected(f, f.owner, { ...copyAction, stronghold: 17 });
await rejected(f, f.opponent, f.planActions[1].action);
const recoverySecret = 'a'.repeat(64), newSessionToken = 'b'.repeat(64);
const index = f.auths.findIndex(auth => auth.playerId === f.owner);
await f.restart().setRecoveryKey(f.code, f.auths[index], pending.version, recoverySecret);
const secured = await f.restart().readRoom(f.code);
await f.restart().recoverSeat(f.code, {
playerId: f.owner, recoverySecret,
operationId: 'ac61a3f0-8dbe-4b15-9f27-a1d55329249c', newSessionToken,
});
await assert.rejects(f.restart().authenticate(f.code, f.tokens[index]));
f.tokens[index] = newSessionToken;
f.auths[index] = await f.restart().authenticate(f.code, newSessionToken);
const recovered = await f.restart().readRoom(f.code);
assert.equal(recovered.version, secured.version + 1);
assert.deepEqual(recovered.decision, pending.decision);
assert.deepEqual(recovered.players, pending.players);
assert.deepEqual(recovered.battle, pending.battle);
assert.deepEqual(recovered.strongholdCards, pending.strongholdCards);
const chosen = await race(f, recovered, f.owner, [copyAction, { ...copyAction, stronghold: 'sietch_tabr' }]);
assert.ok(['arrakeen', 'sietch_tabr'].includes(chosen.battle!.strongholdCopy!));
assert.deepEqual(chosen.strongholdCards, pending.strongholdCards);
await rejected(f, f.owner, copyAction, recovered.version);
let ready = await drive(f, chosen, g => nextStrongholdFactionsNativeStep(g) === null);
const first = f.planActions[0];
ready = await act(f, ready, first.actor, first.action);
assert.equal(ready.battle!.revealed, false);
await privateViews(f, ready);
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(await f.restart().readRoom(f.code), ready);
});
for (const funding of [0, 1]) {
void test(`actual native support with ${funding} CHOAM donor spice, income, casualties, Collection and final Mentat settle once after restart and duplicate resolution`, async t => {
const f = await fixture(t, { ownerDial: 6, support: 3, opponentDial: 1.5, copy: 'arrakeen', counter: true, choamFunding: funding, allyPayment: funding });
let game = await openBattle(f);
assert.ok(f.copyAction);
game = await act(f, game, f.owner, { ...f.copyAction, event: game.battle!.event });
game = await drive(f, game, g => nextStrongholdFactionsNativeStep(g) === null);
for (const plan of f.planActions) game = await act(f, game, plan.actor, plan.action);
assert.equal(game.battle!.revealed, true);
const quote = quoteStrongholdFactionsBattle(game);
const wallets = Object.fromEntries(game.players.map(player => [player.id, player.spice]));
const winnerTanks = game.players.find(player => player.id === f.owner)!.tanks;
const winnerEliteTanks = game.players.find(player => player.id === f.owner)!.elites!.tanks;
const payment = quote.payments.find(leg => leg.player === f.owner)!;
assert.equal(payment.bankSupport, 2);
assert.equal(payment.cost, 1);
assert.equal(payment.ownPayment, 1 - funding);
assert.equal(payment.allyPayment, funding);
assert.equal(payment.donor, funding ? f.choam : null);
assert.equal(quote.choamIncome!.owner, f.choam);
assert.equal(quote.choamIncome!.amount, 1);
const custody = structuredClone(game.strongholdCards);
// Resolve only native response/traitor actions, racing the actual final action
// that retires this battle rather than inserting a fabricated aftermath frame.
for (let count = 0; count < 100 && game.battle; count++) {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next);
const predicted = applyAction(game, next.actor, next.action);
game = predicted.battle === null
? await race(f, game, next.actor, [next.action, next.action])
: await act(f, game, next.actor, next.action);
}
assert.equal(game.battle, null);
assert.deepEqual(game.strongholdCards, custody);
for (const leg of quote.payments) wallets[leg.player] -= leg.ownPayment;
if (quote.bounty) wallets[quote.bounty.player] += quote.bounty.amount;
for (const income of quote.strongholdIncome) wallets[income.player] += income.amount;
game = await drive(f, game, g => g.response?.kind === 'choamBattleIncome');
assert.deepEqual(game.pendingChoamBattleIncome, quote.choamIncome);
assert.deepEqual(Object.fromEntries(game.players.map(player => [player.id, player.spice])), wallets,
'native bank support is not wallet cash and CHOAM income is still deferred');
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(await f.restart().readRoom(f.code), game);
await privateViews(f, game);
// Pass the actual counter suffix; only its final eligible response transfers
// the original native receipt, and concurrent submissions cannot pay twice.
for (let count = 0; count < 20 && game.response?.kind === 'choamBattleIncome'; count++) {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next);
const predicted = applyAction(game, next.actor, next.action);
game = predicted.pendingChoamBattleIncome === null
? await race(f, game, next.actor, [next.action, next.action])
: await act(f, game, next.actor, next.action);
}
assert.equal(game.pendingChoamBattleIncome, null);
wallets[f.choam] += quote.choamIncome!.amount;
assert.deepEqual(Object.fromEntries(game.players.map(player => [player.id, player.spice])), wallets);
game = await drive(f, game, g => g.phase === 8 && !g.response && !g.decision);
assert.ok(quote.casualties);
const losses = quote.casualties.options[0];
const winner = game.players.find(player => player.id === f.owner)!;
assert.equal(winner.tanks, winnerTanks + losses.normal + losses.elite);
assert.equal(winner.elites!.tanks, winnerEliteTanks + losses.elite);
const turn = game.turn;
for (let count = 0; count < 30 && game.turn === turn && game.status === 'playing'; count++) {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next);
const predicted = applyAction(game, next.actor, next.action);
if (predicted.turn !== turn || predicted.status !== 'playing') {
const before = game;
game = await race(f, before, next.actor, [next.action, next.action]);
await rejected(f, next.actor, next.action, before.version);
} else game = await act(f, game, next.actor, next.action);
}
assert.ok(game.turn === turn + 1 || game.status === 'finished');
const settled = game;
await privateViews(f, settled);
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(await f.restart().readRoom(f.code), settled);
assert.deepEqual(protectedRows(f.sqlite), f.credentials);
});
}
void test('actual native Auditor payment retains source, original full price and private projection through reload and a competing payment race', async t => {
const f = await fixture(t, { kind: 'carthag', ownerFaction: 'choam', ownerLeader: 'auditor', opponentWeapon: 'worthless', ownerDial: 3, support: 3, opponentDial: 1.5, counter: true });
let game = await openBattle(f);
game = await drive(f, game, g => nextStrongholdFactionsNativeStep(g) === null);
for (const plan of f.planActions) game = await act(f, game, plan.actor, plan.action);
game = await drive(f, game, g => g.decision?.kind === 'choamAudit');
const event = game.pendingAuditor!.event;
game = await act(f, game, f.choam, { type: 'decision', event, audit: true });
game = await drive(f, game, g => g.decision?.kind === 'choamAuditPayment');
const pending = game.pendingAuditor!;
assert.equal(pending.stage, 'payment');
const payer = game.players.find(player => player.id === pending.opponent)!;
const count = viewGame(game, pending.opponent).auditor!.count;
assert.ok(count > 0);
const payment: Action = { type: 'decision', event, pay: true, count };
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(await f.restart().readRoom(f.code), game);
await privateViews(f, game);
await rejected(f, f.choam, payment);
await rejected(f, pending.opponent, { ...payment, event: 'stale-audit' });
await rejected(f, pending.opponent, { ...payment, count: count + 1 });
const before = game;
const after = await race(f, before, pending.opponent, [payment, payment]);
assert.equal(after.pendingAuditor, null);
assert.equal(after.players.find(player => player.id === pending.opponent)!.spice, payer.spice - count);
assert.equal(after.players.find(player => player.id === f.choam)!.spice,
before.players.find(player => player.id === f.choam)!.spice + count);
assert.deepEqual(after.strongholdCards, before.strongholdCards);
await rejected(f, pending.opponent, payment, before.version);
await privateViews(f, after);
assert.deepEqual(protectedRows(f.sqlite), f.credentials);
});
+373
View File
@@ -0,0 +1,373 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, mobileRoutes, splitLocation, territory } from '../game/board';
import { isAuditorLeader, treacheryDeck } from '../game/cards';
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { createStrongholdFactionsFixture, nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsFixture, type StrongholdFactionsFixtureOptions } from './fixture-stronghold-factions';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
function inventory(game: Game) {
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
}
function custody(game: Game) {
assert.deepEqual(inventory(game), treacheryDeck(['ix', 'choam']).map(c => c.id).sort());
for (const seat of game.players) {
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, n) => sum + n, 0), 20, seat.faction);
if (seat.elites) assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0), seat.faction === 'ixians' ? 7 : seat.faction === 'emperor' ? 5 : 3, seat.faction);
for (const [key, count] of Object.entries(seat.elites?.forces ?? {})) assert.ok(count <= seat.forces[key]);
}
}
function prepared(fixture: StrongholdFactionsFixture): Game {
let game = reload(fixture.game);
if (fixture.copyAction) game = applyAction(game, fixture.owner, fixture.copyAction);
for (let count = 0; count < 120; count++) {
const next = nextStrongholdFactionsNativeStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (game.decision?.kind === 'choamBattleFunding' && fixture.fundingAction) game = applyAction(game, fixture.fundingAction.actor, fixture.fundingAction.action);
else game = applyAction(game, next.actor, next.action);
}
throw Error('Native plan boundary did not open');
}
function reveal(fixture: StrongholdFactionsFixture): Game {
let game = prepared(fixture);
for (const plan of fixture.planActions) {
game = applyAction(game, plan.actor, plan.action);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
assert.ok(game.battle?.revealed);
return game;
}
function finish(fixture: StrongholdFactionsFixture, state = reveal(fixture), callers: string[] = []) {
let game = state;
for (let count = 0; count < 160; count++) {
if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
game = applyAction(reload(game), next.actor, next.action);
custody(game);
}
throw Error('Actual native battle suffix did not finish');
}
/** Verify source money independently. Ix opening must close before native
* Collection pays bank stronghold income or removes any board deposits. */
function money(fixture: StrongholdFactionsFixture, revealed: Game, resolved: Game) {
assert.deepEqual(resolved.strongholdCards, fixture.beforeBattle.strongholdCards, 'battle loss does not transfer retained card custody before final Mentat');
const quote = quoteStrongholdFactionsBattle(revealed);
const collection = resolved.phase === 7 && !resolved.phaseOpening ? quoteSpiceCollection({ ...resolved, players: resolved.players.map(p => ({ ...p, spice: 0 })) }).receipts : [];
for (const seat of revealed.players) {
const ownPayment = quote.payments.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.ownPayment, 0);
const stronghold = quote.strongholdIncome.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.amount, 0);
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
const choam = quote.choamIncome?.owner === seat.id ? quote.choamIncome.amount : 0;
const collected = collection.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.strongholds + p.collected, 0);
assert.equal(player(resolved, seat.id).spice, seat.spice - ownPayment + stronghold + bounty + choam + collected, `${seat.faction}: own debit, module income, native bounty, CHOAM and Collection remain separate`);
}
assert.equal(resolved.lastBattleContext?.winner, quote.winner);
assert.deepEqual(normalizeAutomaticGame(reload(resolved)), resolved, 'JSON normalization cannot repay a resolved native source');
return quote;
}
function effect(options: StrongholdFactionsFixtureOptions) {
const fixture = createStrongholdFactionsFixture(options);
const revealed = reveal(fixture);
const resolved = finish(fixture, revealed);
const quote = money(fixture, revealed, resolved);
return { fixture, revealed, resolved, quote };
}
void test('fresh native 2–6 seats conserve canonical 47 cards, twenty counters, native stars, Auditor and six initially unowned Stronghold Cards', () => {
for (let count = 2; count <= 6; count++) {
const fixture = createStrongholdFactionsFixture({ seatIds: Array.from({ length: count }, (_, index) => `seat-${count}-${index}`) });
custody(fixture.initial); custody(fixture.afterSetup); custody(fixture.beforeBattle);
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'ixians')!.elites!.reserves, 4);
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'ixians')!.elites!.forces[MOBILE_LOCATION], 3);
const choam = fixture.afterSetup.players.find(p => p.faction === 'choam')!;
assert.equal(choam.leaders.filter(isAuditorLeader).length, 1);
assert.equal(choam.leaders.filter(l => !isAuditorLeader(l)).length, 5);
assert.equal(Object.keys(fixture.initial.strongholdCards!.owners).length, 6);
assert.ok(Object.values(fixture.initial.strongholdCards!.owners).every(owner => owner === null));
assert.equal(fixture.initial.strongholdCards!.claimedTurn, 0);
assert.equal(fixture.afterFirstMentat.status, 'playing', 'first custody must not prematurely end a three-hold fixture');
assert.equal(fixture.afterFirstMentat.turn, 2);
}
});
void test('original CLI setup remains immutable and first custody is produced by the actual final Mentat action, not a redeal or assigned owner', () => {
const original = createStrongholdFactionsFixture().initial;
const before = reload(original);
const fixture = createStrongholdFactionsFixture({ initial: original });
assert.deepEqual(original, before);
assert.deepEqual(fixture.initial, before);
assert.equal(fixture.beforeFirstMentat.phase, 8);
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
const settled = applyAction(reload(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(settled.turn, 2);
assert.deepEqual(settled.strongholdCards, fixture.afterFirstMentat.strongholdCards);
assert.deepEqual(settled.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, true));
for (const card of STRONGHOLD_CARDS) assert.equal(fixture.beforeBattle.strongholdCards!.owners[card.id], settled.strongholdCards!.owners[card.id]);
const absent = createStrongholdFactionsFixture({ initial: original, copyChoices: 0 });
assert.equal(absent.beforeBattle.strongholdCards!.owners.arrakeen, absent.owner);
assert.equal(strongholdControllers(absent.beforeBattle.players, true).arrakeen, null);
assert.equal(absent.game.battle!.strongholdCopy, undefined, 'departed card custody alone supplies no mobile copy');
});
void test('Arrakeen subsidy is capped by actual native Cyborg support, never becomes cash, and CHOAM receives the bank-funded share', () => {
for (const support of [0, 1, 2, 3]) {
const { fixture, revealed, quote } = effect({ kind: 'arrakeen', ownerDial: support * 2, support, ownerSpice: Math.max(0, support - 2) });
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.bankSupport, Math.min(2, support));
assert.equal(payment.ownPayment, Math.max(0, support - 2));
assert.equal(quote.choamIncome?.amount ?? 0, Math.floor(support / 2));
assert.equal(player(revealed, fixture.owner).spice, Math.max(0, support - 2), 'the plan seals without crediting two spendable spice');
assert.equal(viewGame(revealed, fixture.owner).battle!.ownForces!.normalFixedHalf, true);
}
const fixture = createStrongholdFactionsFixture({ kind: 'arrakeen', ownerDial: 6, support: 3, ownerSpice: 0 });
const ready = prepared(fixture), before = reload(ready);
assert.throws(() => applyAction(ready, fixture.owner, fixture.planActions[0].action));
assert.deepEqual(ready, before);
});
void test('native CHOAM donor escrow excludes its own donation from income but includes Arrakeen bank support, with no wallet subsidy', () => {
const fixture = createStrongholdFactionsFixture({ kind: 'arrakeen', ownerFaction: 'guild', ownerDial: 4, support: 4, allyPayment: 2, choamFunding: 2, ownerSpice: 0 });
const ready = prepared(fixture);
assert.equal(player(ready, fixture.owner).ally, fixture.choam);
assert.equal(player(ready, fixture.choam).ally, fixture.owner);
assert.equal(player(ready, fixture.choam).spice, 18, 'original native funding action actually paid escrow');
assert.equal(ready.aid[fixture.choam].amount, 2);
const revealed = reveal(fixture), resolved = finish(fixture, revealed), quote = money(fixture, revealed, resolved);
assert.deepEqual(quote.payments.find(p => p.player === fixture.owner), { player: fixture.owner, cost: 2, ownPayment: 0, allyPayment: 2, donor: fixture.choam, bankSupport: 2, freeByTraitor: false });
assert.equal(quote.choamIncome!.amount, 1, 'bank two earns one; native own-donor two earns nothing');
assert.equal(resolved.aid[fixture.choam]?.amount ?? 0, 0);
});
void test('Carthag native physical defense gains poison coverage only without a poison-role weapon; Shield still explodes and Tooth still kills', () => {
for (const scenario of [
{ defense: 'shield', own: undefined, enemy: 'poison', dead: false, explosion: false },
{ defense: 'weirdingWay', own: 'projectile', enemy: 'poison', dead: false, explosion: false },
{ defense: 'shield', own: 'poison', enemy: 'poison', dead: true, explosion: false },
{ defense: 'shield', own: 'chemistry', enemy: 'poison', dead: true, explosion: false },
{ defense: 'worthless', own: undefined, enemy: 'poison', dead: true, explosion: false },
{ defense: 'shield', own: undefined, enemy: 'poisonTooth', dead: true, explosion: false },
{ defense: 'shield', own: undefined, enemy: 'lasgun', dead: true, explosion: true },
] as const) {
const fixture = createStrongholdFactionsFixture({ kind: 'carthag', ownerDefense: scenario.defense, ownerWeapon: scenario.own, opponentWeapon: scenario.enemy });
let revealed = reveal(fixture);
while (revealed.decision || revealed.response) {
const next = nextStrongholdFactionsNativeStep(revealed); assert.ok(next);
revealed = applyAction(revealed, next.actor, next.action);
}
const resolved = finish(fixture, revealed), quote = money(fixture, revealed, resolved);
const leader = String(fixture.planActions[0].action.leader);
assert.equal(player(resolved, fixture.owner).leaders.find(l => l.id === leader)!.dead, scenario.dead, JSON.stringify(scenario));
assert.equal(quote.explosion, scenario.explosion);
}
});
void test('Habbanya native holder wins a printed-supported tie as defender, but ties do not override mutual traitors', () => {
const { fixture, revealed, resolved, quote } = effect({ kind: 'habbanya_ridge_sietch', ownerFaction: 'guild' });
assert.equal(revealed.battle!.defender, fixture.owner);
assert.equal(quote.scores!.attacker, quote.scores!.defender);
assert.equal(quote.winner, fixture.owner);
assert.equal(player(resolved, fixture.opponent).forces[`habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`], undefined);
const mutual = createStrongholdFactionsFixture({ kind: 'habbanya_ridge_sietch', ownerFaction: 'guild', traitors: 'both' });
const ended = finish(mutual, reveal(mutual), [mutual.owner, mutual.opponent]);
assert.equal(ended.lastBattleContext!.winner, null);
custody(ended);
});
void test('Tabr pays floor of opposing declared fractional dial once; native traitor bounty is separate and explosions pay no winner', () => {
const ordinary = effect({ kind: 'sietch_tabr', ownerDial: 6, support: 3, opponentDial: 1.5 });
assert.deepEqual(ordinary.quote.strongholdIncome, [{ player: ordinary.fixture.owner, amount: 1 }]);
const traitor = createStrongholdFactionsFixture({ kind: 'sietch_tabr', ownerDial: 6, support: 3, opponentDial: 1.5, traitors: 'owner' });
const ended = finish(traitor, reveal(traitor), [traitor.owner]);
assert.equal(ended.lastBattleContext!.winner, traitor.owner);
const explosion = effect({ kind: 'sietch_tabr', opponentDial: 1.5, ownerDefense: 'shield', opponentWeapon: 'lasgun' });
assert.equal(explosion.quote.winner, null);
assert.deepEqual(explosion.quote.strongholdIncome, []);
});
void test('Tuek pays each actually played Worthless card on defeat and explosion, but pays neither side for mutual traitors', () => {
const loser = effect({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', opponentDial: 2, opponentSupport: 2 });
assert.equal(loser.quote.winner, loser.fixture.opponent);
assert.deepEqual(loser.quote.strongholdIncome, [{ player: loser.fixture.owner, amount: 4 }]);
for (const field of ['weapon', 'defense'] as const) assert.ok(loser.resolved.discard.some(card => card.id === loser.fixture.planActions[0].action[field]));
const exploded = effect({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'shield', opponentWeapon: 'lasgun' });
assert.equal(exploded.quote.explosion, true);
assert.deepEqual(exploded.quote.strongholdIncome, [{ player: exploded.fixture.owner, amount: 2 }]);
const mutual = createStrongholdFactionsFixture({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'both' });
const ended = finish(mutual, reveal(mutual), [mutual.owner, mutual.opponent]);
assert.equal(ended.lastBattleContext!.winner, null);
assert.equal(ended.log.filter(entry => entry.automatic?.name === 'Stronghold income').length, 0);
const single = createStrongholdFactionsFixture({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'owner', ownerDial: 6, support: 3 });
const won = finish(single, reveal(single), [single.owner]);
assert.equal(won.lastBattleContext!.winner, single.owner);
const nativeCollection = won.phase === 7 ? quoteSpiceCollection(won).receipts.find(receipt => receipt.player === single.owner)!.collected : 0;
assert.equal(player(won, single.owner).spice, 20 + 4 + 5 + nativeCollection, 'four Worthless income plus native five-strength traitor bounty; waived support and separate ordinary Collection');
});
void test('native HMS declaration uses current physical control, not donor card custody; rejected event/actor/choice and early plans leave source unchanged', () => {
const fixture = createStrongholdFactionsFixture();
const game = fixture.game, before = reload(game), decision = game.decision;
assert.equal(decision?.kind, 'strongholdCopy');
if (decision?.kind !== 'strongholdCopy') throw Error('Missing actual native copy decision');
assert.deepEqual(decision.choices, ['arrakeen', 'sietch_tabr']);
assert.notEqual(game.strongholdCards!.owners.sietch_tabr, fixture.owner);
for (const [actor, action] of [
[fixture.opponent, fixture.copyAction!],
[fixture.owner, { ...fixture.copyAction!, event: 'foreign-event' }],
[fixture.owner, { ...fixture.copyAction!, stronghold: 'carthag' }],
[fixture.opponent, fixture.planActions[1].action],
] as const) {
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
const accepted = applyAction(game, fixture.owner, fixture.copyAction!);
assert.equal(accepted.battle!.strongholdCopy, 'arrakeen');
assert.throws(() => applyAction(accepted, fixture.owner, fixture.copyAction!));
const retained = reload(accepted);
// Move the copy-control marker back to reserves after declaration: the
// accepted event remains fixed and does not silently ask again.
const owner = player(retained, fixture.owner);
owner.reserves += owner.forces['arrakeen:10']; delete owner.forces['arrakeen:10'];
const normalized = normalizeAutomaticGame(retained);
assert.equal(normalized.battle!.strongholdCopy, 'arrakeen');
for (const seat of normalized.players) assert.equal(viewGame(normalized, seat.id).battle!.strongholdEffects[fixture.owner], 'arrakeen');
const sole = createStrongholdFactionsFixture({ copyChoices: 1 });
assert.equal(sole.game.battle!.strongholdCopy, 'arrakeen');
assert.notEqual(sole.game.decision?.kind, 'strongholdCopy');
custody(normalized);
});
void test('native HMS copies each existing advantage on its interior without relocating the printed battle site', () => {
for (const copy of ['arrakeen', 'carthag', 'habbanya_ridge_sietch', 'sietch_tabr', 'tueks_sietch'] as const) {
const options: StrongholdFactionsFixtureOptions = { copy, copyChoices: 1 };
if (copy === 'arrakeen') { options.ownerDial = 6; options.support = 3; }
if (copy === 'carthag') { options.ownerDefense = 'shield'; options.opponentWeapon = 'poison'; }
if (copy === 'sietch_tabr') { options.ownerDial = 6; options.support = 3; options.opponentDial = 1.5; }
if (copy === 'tueks_sietch') { options.ownerWeapon = 'worthless'; options.opponentDial = 2; options.opponentSupport = 2; }
const { fixture, revealed, quote } = effect(options);
assert.equal(revealed.battle!.territory, MOBILE_STRONGHOLD);
assert.equal(revealed.battle!.strongholdCopy, copy);
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], copy);
assert.equal(revealed.mobileStronghold!.location, 'polar_sink:0');
assert.equal(player(revealed, fixture.owner).forces['polar_sink:0'], undefined, 'interior army does not leak into the pointing territory');
if (copy === 'arrakeen') assert.equal(quote.payments.find(p => p.player === fixture.owner)!.bankSupport, 2);
if (copy === 'carthag') assert.equal(revealed.battle!.attacker === fixture.owner ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender, false);
if (copy === 'habbanya_ridge_sietch') assert.equal(quote.winner, fixture.owner);
if (copy === 'sietch_tabr') assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
if (copy === 'tueks_sietch') assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 2 }]);
}
});
void test('real HMS relocation, native cyborg casualty/substitution decisions and sealed plans survive JSON without revealing foreign private plans', () => {
const original = createStrongholdFactionsFixture();
const route = mobileRoutes(original.afterFirstMentat, 3).find(route => {
const target = splitLocation(route.at(-1)!);
return route.length > 1 && target.sector >= 8 && territory(target.territory).type !== 'stronghold';
});
assert.ok(route, 'An actual connected native mobile route is available outside this next Storm band');
const fixture = createStrongholdFactionsFixture({ initial: original.initial, mobileRoute: route, ownerDial: 6, support: 3, opponentDial: 1.5 });
assert.equal(fixture.game.mobileStronghold!.location, route.at(-1));
let game = prepared(fixture);
const first = fixture.planActions[0]; game = applyAction(game, first.actor, first.action);
const ownView = viewGame(reload(game), fixture.owner), otherView = viewGame(reload(game), fixture.opponent);
assert.equal(ownView.battle!.plans[fixture.owner].dial, 6);
assert.deepEqual(otherView.battle!.plans, {});
assert.equal(otherView.battle!.revealed, false);
const second = fixture.planActions[1]; game = applyAction(reload(game), second.actor, second.action);
const quote = quoteStrongholdFactionsBattle(game);
assert.equal(quote.casualties!.forces.normalFixedHalf, true);
assert.ok(quote.casualties!.options.every(loss => loss.elite === 3 && loss.normal === 0));
for (let count = 0; game.decision?.kind !== 'ixSubstitution' && count < 100; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
game = applyAction(reload(game), next.actor, next.action);
}
assert.equal(game.decision?.kind, 'ixSubstitution');
assert.equal(player(game, fixture.owner).elites!.tanks, 3);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
const resolved = finish(fixture, game);
custody(resolved);
});
void test('all four existing projected policies choose legal native current-control copies and affordable native plans without bank cash', () => {
const fixture = createStrongholdFactionsFixture({ ownerSpice: 0, ownerDefense: 'shield' });
for (const difficulty of DIFFICULTIES) {
const copyView = viewGame(fixture.game, fixture.owner);
copyView.players.find(p => p.id === fixture.owner)!.bot = difficulty;
const copy = botActions(copyView)[0]; assert.ok(copy);
let game = applyAction(reload(fixture.game), fixture.owner, copy);
while (nextStrongholdFactionsNativeStep(game)) {
const next = nextStrongholdFactionsNativeStep(game)!;
game = applyAction(game, next.actor, next.action);
}
const view = viewGame(game, fixture.owner);
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan'); assert.ok(plan);
const accepted = applyAction(game, fixture.owner, plan);
assert.equal(player(accepted, fixture.owner).spice, 0);
assert.ok((accepted.battle!.plans[fixture.owner].support ?? 0) <= 2, 'free bank support is capped, not unlimited personal cash');
custody(accepted);
}
});
void test('free native Fremen support neither spends Arrakeen allowance nor mints CHOAM income', () => {
const { fixture, quote } = effect({ kind: 'arrakeen', seatIds: ['i', 'c', 'g', 'e', 'f'], ownerFaction: 'fremen', ownerDial: 3, support: 0, ownerSpice: 0 });
assert.deepEqual(quote.payments.find(payment => payment.player === fixture.owner), { player: fixture.owner, cost: 0, ownPayment: 0, allyPayment: 0, donor: null, bankSupport: 0, freeByTraitor: false });
assert.equal(quote.choamIncome, null);
});
void test('native CHOAM aid opens a real Karama response only for an actual conserved eligible hand; cancellation cannot leave an affordable phantom ally payment', () => {
const fixture = createStrongholdFactionsFixture({ ownerFaction: 'guild', copy: 'arrakeen', choamFunding: 2, allyPayment: 2, ownerDial: 4, support: 4, ownerSpice: 0, counter: true });
let game = applyAction(reload(fixture.game), fixture.owner, fixture.copyAction!);
assert.equal(game.decision?.kind, 'choamBattleFunding');
game = applyAction(game, fixture.fundingAction!.actor, fixture.fundingAction!.action);
assert.equal(game.response?.owner, fixture.choam);
const counter = player(game, fixture.opponent).hand.find(card => card.effect === 'karama'); assert.ok(counter);
const before = reload(game);
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
assert.equal(next.actor, fixture.opponent);
assert.equal(next.action.type, 'passResponse');
game = applyAction(game, fixture.opponent, { type: 'card', card: counter.id, mode: 'cancel' });
for (let count = 0; nextStrongholdFactionsNativeStep(game) && count < 120; count++) {
const step = nextStrongholdFactionsNativeStep(game)!; game = applyAction(game, step.actor, step.action);
}
assert.ok(game.battle!.choamAidBlocked);
assert.ok(game.discard.some(card => card.id === counter.id));
assert.ok(before.players.find(p => p.id === fixture.opponent)!.hand.some(card => card.id === counter.id), 'original counter custody remains immutable');
const preserved = reload(game);
assert.throws(() => applyAction(game, fixture.owner, fixture.planActions[0].action));
assert.deepEqual(game, preserved);
const accepted = applyAction(game, fixture.owner, { ...fixture.planActions[0].action, dial: 2, support: 2, allyPayment: 0 });
assert.equal(player(accepted, fixture.owner).spice, 0);
custody(accepted);
});
void test('actual native battle, Collection and final Mentat settle current custody and terminal victory once, including retained departure custody until that checkpoint', () => {
const fixture = createStrongholdFactionsFixture({ ownerDial: 6, support: 3, opponentDial: 1.5 });
let game = finish(fixture);
assert.deepEqual(game.strongholdCards, fixture.afterFirstMentat.strongholdCards);
let finalSource: Game | undefined;
for (let count = 0; game.status !== 'finished' && count < 300; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = reload(game);
game = applyAction(reload(game), next.actor, next.action);
if (game.status === 'finished') finalSource = before;
custody(game);
}
assert.equal(game.status, 'finished');
assert.equal(finalSource!.phase, 8);
assert.equal(finalSource!.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.claimedTurn, 2);
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(finalSource!.players, true));
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assert.equal(nextStrongholdFactionsNativeStep(game), null);
});
+167 -20
View File
@@ -1,9 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import {
cpSync,
mkdtempSync,
mkdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
classifyTest,
discoverTests,
@@ -67,6 +75,7 @@ void test('an unmarked HTTP test cannot silently enter the offline suite', (t) =
void test('each filename filter must match within its suite, including combined filters', () => {
const files = [{ path: 'tests/rules.test.ts', kind: 'unit' as const }];
assert.equal(selectTests(files, 'offline', ['rules']).length, 1);
assert.throws(() => selectTests(files, 'offline', ['']));
assert.throws(
() => selectTests(files, 'offline', ['rules', 'typo']),
/No offline test files match/,
@@ -78,28 +87,166 @@ void test('each filename filter must match within its suite, including combined
assert.throws(() => selectTests([], 'all'), /No test files/);
});
void test('the TypeScript test subprocess returns failure for a failing assertion', (t) => {
const root = mkdtempSync(join(tmpdir(), 'dune-test-failure-'));
function runnerFixture(t: test.TestContext, sources: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), 'dune-test-runner-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const file = join(root, 'failure.test.mts');
writeFileSync(
file,
"import test from 'node:test';\ntest('failure sentinel', () => { throw new Error('expected failure'); });\n",
mkdirSync(join(root, 'tools'));
mkdirSync(join(root, 'tests'));
writeFileSync(join(root, 'package.json'), '{"type":"module"}\n');
for (const tool of ['test.ts', 'test-discovery.ts', 'test-reporter.ts'])
cpSync(new URL(`../tools/${tool}`, import.meta.url), join(root, 'tools', tool));
symlinkSync(
fileURLToPath(new URL('../node_modules', import.meta.url)),
join(root, 'node_modules'),
'dir',
);
for (const [file, source] of Object.entries(sources))
writeFileSync(join(root, 'tests', file), source);
// A nested runner must not inherit the parent test worker's IPC context.
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(
process.execPath,
['--import', 'tsx', '--test', file],
{
cwd: new URL('../', import.meta.url),
env,
timeout: 15_000,
encoding: 'utf8',
},
);
const args = ['--import', import.meta.resolve('tsx'), 'tools/test.ts'];
return {
root,
env,
args,
run: (...options: string[]) =>
spawnSync(process.execPath, [...args, ...options], {
cwd: root,
env,
timeout: 15_000,
encoding: 'utf8',
}),
};
}
void test('concise CLI preserves nested assertion details, output and failure exit', (t) => {
const fixture = runnerFixture(t, {
'failure.test.mjs': `
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
describe('parent suite', () => {
test('passing sentinel', () => {});
test('failure sentinel', t => {
console.log('stdout sentinel');
console.error('stderr sentinel');
t.diagnostic('diagnostic sentinel');
assert.deepEqual({ payment: 7 }, { payment: 11 });
});
});
`,
});
for (const options of [[], ['--verbose']]) {
const result = fixture.run(...options);
assert.ifError(result.error);
assert.equal(result.status, 1);
const output = result.stdout + result.stderr;
assert.match(output, /failure sentinel/);
assert.match(output, /failure\.test\.mjs:\d+:\d+/);
assert.match(output, /payment: 7/);
assert.match(output, /payment: 11/);
for (const channel of ['stdout', 'stderr', 'diagnostic'])
assert.match(output, new RegExp(`${channel} sentinel`));
if (options.length) assert.match(output, /passing sentinel/);
else assert.doesNotMatch(output, /passing sentinel/);
}
});
void test('CLI applies union filename selection and name intersection without green empty runs', (t) => {
const fixture = runnerFixture(t, {
'empty.test.mjs': '',
'skipped.test.mjs': `
import test from 'node:test';
test('pending case', { skip: true }, () => {});
`,
'alpha.test.mjs': `
import test from 'node:test';
test('chosen alpha', () => {});
test('excluded failure', () => { throw new Error('must not execute'); });
`,
'beta.test.mjs': `
import test from 'node:test';
test('chosen beta', () => {});
`,
'other.test.mjs': `
import test from 'node:test';
test('chosen outside selection', () => { throw new Error('must not execute'); });
`,
});
const result = fixture.run('alpha', 'beta', '--name', '^chosen', '--verbose');
assert.ifError(result.error);
assert.equal(result.status, 1);
assert.match(result.stdout + result.stderr, /expected failure/);
assert.equal(result.status, 0);
assert.match(result.stdout, /chosen alpha/);
assert.match(result.stdout, /chosen beta/);
assert.doesNotMatch(result.stdout + result.stderr, /must not execute/);
const listed = fixture.run('alpha', 'beta', '--list');
assert.equal(listed.status, 0);
assert.match(listed.stdout, /tests\/alpha\.test\.mjs/);
assert.match(listed.stdout, /tests\/beta\.test\.mjs/);
assert.doesNotMatch(listed.stdout, /tests\/other\.test\.mjs/);
for (const options of [
['empty'],
['skipped'],
['alpha', '--name', '^no matching tests$'],
['alpha', '--name', '^no matching tests$', '--verbose'],
['alpha', '--name', '['],
['alpha', '--name', ''],
['', '--name', '^chosen'],
['alpha', 'missing'],
['--suite', 'unknown'],
]) {
const invalid = fixture.run(...options);
assert.ifError(invalid.error);
assert.equal(invalid.status, 1, JSON.stringify(options));
}
});
void test('CLI cancellation remains failure when its test driver handles the signal with exit zero', { timeout: 30_000 }, async (t) => {
for (const [signal, expected] of [
['SIGINT', 130],
['SIGTERM', 143],
] as const) {
const fixture = runnerFixture(t, {
'waiting.test.mjs': `
import test from 'node:test';
test('waiting', () => {});
`,
});
// A real Node test driver whose reporter gracefully handles cancellation.
writeFileSync(
join(fixture.root, 'tools/test-reporter.ts'),
`import { MessageChannel } from 'node:worker_threads';
export default async function* reporter(source) {
// Keep the driver alive until the real OS signal, without timer guesses.
const { port1, port2 } = new MessageChannel();
port1.on('message', () => {});
process.on('${signal}', () => {
port1.close();
port2.close();
process.exit(0);
});
console.log('cancellation-ready');
for await (const event of source) yield '';
await Promise.withResolvers().promise;
}\n`,
);
const child = spawn(process.execPath, fixture.args, {
cwd: fixture.root,
env: fixture.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
t.after(() => child.kill('SIGKILL'));
let output = '';
child.stdout.on('data', (chunk: Buffer) => {
output += chunk.toString();
if (output.includes('cancellation-ready')) {
output = '';
child.kill(signal);
}
});
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
child.once('error', reject);
child.once('close', resolve);
assert.equal(await promise, expected);
}
});
+66
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import {
cpSync,
mkdtempSync,
mkdirSync,
writeFileSync,
@@ -13,6 +14,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
privateOutputDirectory,
runVerification,
@@ -39,6 +41,70 @@ void test('focused wrapper cannot turn a filename into a list-only or test-skipp
assert.equal(existsSync(out), false);
});
void test('focused verification executes only matching names and cannot certify an empty selection', (t) => {
const { root, area } = fixture(t);
mkdirSync(join(root, 'tools'));
mkdirSync(join(root, 'tests'));
for (const tool of [
'verify.ts',
'verification.ts',
'test.ts',
'test-discovery.ts',
'test-reporter.ts',
])
cpSync(new URL(`../tools/${tool}`, import.meta.url), join(root, 'tools', tool));
symlinkSync(
fileURLToPath(new URL('../node_modules', import.meta.url)),
join(root, 'node_modules'),
'dir',
);
writeFileSync(
join(root, 'package.json'),
JSON.stringify({
type: 'module',
scripts: { test: 'node --import tsx tools/test.ts' },
}),
);
writeFileSync(
join(root, 'tests/rules.test.mjs'),
`import test from 'node:test';
test('chosen case', () => console.log('selected case executed'));
test('excluded case', () => { throw new Error('excluded failure'); });\n`,
);
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
for (const [name, status] of [
['chosen case', 'passed'],
['no matching case', 'failed'],
]) {
const out = join(area, status);
const result = spawnSync(
process.execPath,
[
'--import',
import.meta.resolve('tsx'),
'tools/verify.ts',
'--out',
out,
'focused',
'--focus',
'rules',
'--name',
name,
],
{ cwd: root, env, encoding: 'utf8', timeout: 15_000 },
);
assert.ifError(result.error);
assert.equal(result.status, status === 'passed' ? 0 : 1);
const report = JSON.parse(readFileSync(join(out, 'report.json'), 'utf8'));
assert.equal(report.status, status);
const log = readFileSync(join(out, 'focused.log'), 'utf8');
if (status === 'passed') assert.match(log, /selected case executed/);
else assert.doesNotMatch(log, /selected case executed/);
assert.doesNotMatch(log, /excluded failure/);
}
});
function fixture(t: test.TestContext) {
const area = mkdtempSync(join(tmpdir(), 'dune-verification-'));
t.after(() => rmSync(area, { recursive: true, force: true }));