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

+44 -10
View File
@@ -12,6 +12,7 @@ import {
initializeBaseGameForAudit,
initializeFactionExpansionsGameForAudit,
initializeHomeworldGameForAudit,
initializeStrongholdFactionsGameForAudit,
initializeNexusGameForAudit,
initializePairedNexusGameForAudit,
initializeEcazTreacheryGameForAudit,
@@ -32,7 +33,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'moritani-assassinate' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -216,6 +217,16 @@ const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players
expansions: ['choam'],
roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
}));
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
];
const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
ordinal: 151 + players - 2,
profile: 'stronghold-factions',
rules: 'advanced',
expansions: ['ix', 'choam'],
roster: STRONGHOLD_FACTIONS_ROSTER.slice(0, players),
}));
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills';
type TraceEntry = {
@@ -254,10 +265,10 @@ type Result = {
function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|moritani-assassinate|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. --players requires a supported profile. Output must be a new private directory outside the checkout.'
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: native Ixians + CHOAM with classic opponents, two through six seats, the canonical 47-card Ix+CHOAM deck and six separate Stronghold Cards, without other modules. --players requires a supported profile. Output must be a new private directory outside the checkout.'
);
}
@@ -289,15 +300,15 @@ function positive(value: string | undefined) {
function scenarioName(scenario: Scenario) {
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'stronghold-factions' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`;
}
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'moritani-assassinate', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, moritani-assassinate, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, moritani-assassinate, stronghold-factions, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
return profile as Profile | 'all';
}
@@ -353,6 +364,8 @@ function freshGame(scenario: Scenario) {
return initializeEcazTreacheryGameForAudit(game);
if (scenario.profile === 'moritani-assassinate')
return initializeMoritaniAssassinateGameForAudit(game);
if (scenario.profile === 'stronghold-factions')
return initializeStrongholdFactionsGameForAudit(game);
return skillProfile(scenario.profile)
? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'homeworld'
@@ -402,15 +415,15 @@ function resumedGame(path: string) {
game.discoveryStash ||
game.greatMaker ||
game.techTokens ||
game.strongholdCards ||
game.mentatQuestionPreview
)
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
(candidate) =>
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate &&
@@ -426,6 +439,8 @@ function resumedGame(path: string) {
);
// Projection validates the engine-facing shape and every private seat boundary.
for (const player of game.players) viewGame(game, player.id);
if (scenario.profile === 'stronghold-factions')
verifySampleCustody(game, sampleInventory(game));
return { game, scenario, hash, path: resolve(path) };
}
@@ -438,6 +453,11 @@ function simulate(
) {
let game = initial;
const expected = sampleInventory(game);
if (scenario.profile === 'stronghold-factions') {
assert.equal(expected.cards.length, 47, 'canonical Ix+CHOAM Treachery census');
assert.equal(new Set(expected.cards).size, 47, 'unique Ix+CHOAM Treachery identities');
assert.equal(expected.strongholds?.length, 6, 'six physical Stronghold Cards');
}
const trace: TraceEntry[] = [];
const used: Record<string, number> = {};
const rejected: Record<string, number> = {};
@@ -498,8 +518,9 @@ function simulate(
preparation: game.battle?.preparation,
})}`,
);
const previous = game;
game = next;
verifySampleCustody(game, expected);
verifySampleCustody(game, expected, previous);
if (actions % 37 === 0) {
const restored = JSON.parse(JSON.stringify(game)) as Game;
for (const player of game.players)
@@ -520,6 +541,7 @@ function simulate(
`private rival field ${field}`,
);
}
verifySampleCustody(restored, expected, game);
game = restored;
restores++;
}
@@ -606,7 +628,7 @@ async function main() {
const profile = parseProfile(values.profile);
const rules = parseRules(values.rules);
const players = parsePlayers(values.players);
if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'moritani-assassinate', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
throw new Error('--players requires a roster or optional-module profile.');
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
throw new Error(`--profile ${profile} supports only five or six players.`);
@@ -614,12 +636,15 @@ async function main() {
throw new Error('Expansion Leader Skills profiles currently support only Basic samples.');
if (profile === 'moritani-assassinate' && rules === 'basic')
throw new Error('Moritani assassination samples require Advanced rules.');
if (profile === 'stronghold-factions' && rules === 'basic')
throw new Error('Stronghold factions samples require Advanced rules.');
const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
: profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS
: profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS
: profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS
: profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS
: profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS
@@ -721,6 +746,15 @@ async function main() {
randomness: resume
? 'Continuation restarts the random stream at seed plus scenario ordinal; it does not reconstruct the pre-snapshot random stream.'
: 'Each scenario starts its random stream at seed plus scenario ordinal.',
...(selected.some(scenario => scenario.profile === 'stronghold-factions') ? {
strongholdFactions: {
expansions: ['ix', 'choam'],
treacheryCards: 47,
strongholdCards: 6,
custody: 'Six separate physical cards checked after every accepted action and JSON restore; end-Mentat holders persist during the next turn, independently of current board control.',
scope: 'Advanced native Ixians and CHOAM with classic opponents; Stronghold Cards alone. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.',
},
} : {}),
options: {
seed,
profile: resume ? resume.scenario.profile : profile,
+4
View File
@@ -5,6 +5,7 @@ import {
initializeLeaderSkillsGameForAudit,
initializeSpiceBankerIncomeGameForAudit,
initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit,
initializeNexusGameForAudit,
initializeMoritaniAssassinateGameForAudit,
initializeEcazTreacheryGameForAudit,
@@ -26,6 +27,7 @@ export const PROTOTYPE_PROFILES = [
'leader-skills',
'banker-income',
'factions',
'stronghold-factions',
'nexus',
'moritani-assassinate',
'ecaz-treachery',
@@ -79,6 +81,8 @@ export function startPrototypeRoom(
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
: profile === 'stronghold-factions'
? initializeStrongholdFactionsGameForAudit(initial)
: profile === 'ecaz-treachery'
? initializeEcazTreacheryGameForAudit(initial)
: profile === 'moritani-assassinate'
+30
View File
@@ -6,11 +6,15 @@ import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors';
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
import { ownedStrongholdCards, STRONGHOLD_CARDS } from '../game/stronghold-cards';
/** Fixed inventory captured after genuine setup initialization, before any choices. */
export function sampleInventory(game: Game) {
return {
leaderSkills: !!game.leaderSkills,
strongholds: game.strongholdCards
? STRONGHOLD_CARDS.map(card => card.id).sort()
: null,
cards: [
...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : []),
@@ -34,9 +38,35 @@ export function sampleInventory(game: Game) {
export function verifySampleCustody(
game: Game,
inventory: ReturnType<typeof sampleInventory>,
previous?: Game,
) {
assert.equal(!!game.leaderSkills, inventory.leaderSkills, 'Leader Skills module custody');
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
assert.equal(!!game.strongholdCards, !!inventory.strongholds, 'Stronghold Cards module custody');
if (game.strongholdCards) {
const state = game.strongholdCards;
// Reuse the module's physical map and acquisition-turn validation. Custody
// is retained during play; current board control is not a custody census.
ownedStrongholdCards(state, game.players[0].id);
assert.deepEqual(Object.keys(state.owners).sort(), inventory.strongholds,
'physical Stronghold Card custody');
assert.ok(state.claimedTurn <= game.turn, 'Stronghold acquisition cannot be in the future');
for (const owner of Object.values(state.owners))
assert.ok(owner === null || game.players.some(player => player.id === owner),
'Stronghold owner must be a current seat');
if (state.claimedTurn === 0)
assert.ok(Object.values(state.owners).every(owner => owner === null),
'unclaimed Stronghold Cards must be unowned');
if (game.status === 'setup')
assert.equal(state.claimedTurn, 0, 'Stronghold setup cannot have settled custody');
if (previous?.strongholdCards) {
assert.ok(state.claimedTurn >= previous.strongholdCards.claimedTurn,
'Stronghold acquisition cannot move backward');
if (state.claimedTurn === previous.strongholdCards.claimedTurn)
assert.deepEqual(state.owners, previous.strongholdCards.owners,
'Stronghold holders must persist until the next end-Mentat settlement');
}
}
const cards = [
...game.players.flatMap((player) => player.hand),
...game.deck,
+1 -1
View File
@@ -20,7 +20,7 @@ async function main() {
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced native Ixians plus CHOAM and otherwise classic roster into Stronghold Cards alone, with exactly the Ix and CHOAM decks and all 47 canonical physical Treachery Cards. Native Ix setup choices, HMS placement and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}
+2
View File
@@ -37,6 +37,8 @@ export function selectTests(
suite: TestSuite,
filters: string[] = [],
): TestFile[] {
if (filters.some((filter) => !filter))
throw new Error('Filename filters must not be empty.');
const selected = files.filter(({ path, kind }) => {
const included =
suite === 'all' ||
+40
View File
@@ -0,0 +1,40 @@
import { Readable } from 'node:stream';
import { spec, type TestEvent } from 'node:test/reporters';
// Keep Node's assertion diffs, locations, diagnostics, counts and duration output.
// Omitting starts as well as passes keeps the spec reporter's nesting stack valid.
export default async function* reporter(source: AsyncIterable<TestEvent>) {
async function* events(): AsyncGenerator<TestEvent> {
let executed = 0;
for await (const event of source) {
// Aggregate counters can include a synthetic pass for an empty test file.
// Worker summaries distinguish registered cases from that file-level result.
if (event.type === 'test:summary' && event.data.file !== undefined)
executed += event.data.counts.tests - event.data.counts.skipped;
if (
event.type === 'test:summary' &&
event.data.file === undefined &&
event.data.success &&
executed === 0
) {
// Node otherwise exits successfully when a name pattern matches no tests.
process.exitCode = 1;
yield {
type: 'test:diagnostic' as const,
data: {
nesting: 0,
level: 'error',
message: 'No tests executed. Check --name and skipped tests.',
},
};
}
if (
process.env.DUNE_TEST_VERBOSE !== '1' &&
(event.type === 'test:pass' || event.type === 'test:start')
)
continue;
yield event;
}
}
yield* Readable.from(events()).pipe(new spec());
}
+34 -11
View File
@@ -14,6 +14,7 @@ async function main() {
list: { type: 'boolean', default: false },
name: { type: 'string' },
concurrency: { type: 'string' },
verbose: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h' },
},
});
@@ -24,6 +25,7 @@ async function main() {
--list Print selected files without running tests
--name REGEX Run matching test names within selected files
--concurrency NUMBER Parallel test files (default: up to 4; HTTP: 1)
--verbose Include passing test rows (default: failures + summary)
Examples:
npm test -- ecaz-collection ecaz-spice
@@ -46,12 +48,13 @@ Examples:
values.suite as TestSuite,
positionals,
);
if (values.name !== undefined) new RegExp(values.name);
if (values.name === '') throw new Error('--name must not be empty.');
if (values.list) {
for (const file of selected) console.log(`${file.kind}\t${file.path}`);
console.log(`${selected.length} test files`);
return;
}
if (values.name) new RegExp(values.name);
const network = selected.some(({ kind }) => kind === 'integration');
const concurrency =
values.concurrency === undefined
@@ -85,23 +88,43 @@ Examples:
'tsx',
'--test',
`--test-concurrency=${concurrency}`,
...(values.name ? [`--test-name-pattern=${values.name}`] : []),
'--test-reporter=./tools/test-reporter.ts',
...(values.name !== undefined
? [`--test-name-pattern=${values.name}`]
: []),
...selected.map(({ path }) => path),
],
{ cwd: root, stdio: 'inherit' },
{
cwd: root,
stdio: 'inherit',
env: {
...process.env,
DUNE_TEST_VERBOSE: values.verbose ? '1' : '0',
},
},
);
// Forward cancellation so a stopped check does not leave test workers behind.
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
let interrupted = 0;
const interrupt = () => {
interrupted = 130;
child.kill('SIGINT');
};
const terminate = () => {
interrupted = 143;
child.kill('SIGTERM');
};
process.on('SIGINT', interrupt);
process.on('SIGTERM', terminate);
try {
process.exitCode = await new Promise<number>((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) =>
resolve(code ?? (signal === 'SIGINT' ? 130 : 1)),
);
});
const { promise, resolve, reject } = Promise.withResolvers<number>();
child.once('error', reject);
child.once('close', (code, signal) =>
resolve(
interrupted ||
(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)),
),
);
process.exitCode = await promise;
} finally {
process.off('SIGINT', interrupt);
process.off('SIGTERM', terminate);
+18 -2
View File
@@ -9,6 +9,7 @@ async function main() {
options: {
out: { type: 'string' },
focus: { type: 'string', multiple: true },
name: { type: 'string' },
games: { type: 'string' },
seed: { type: 'string' },
help: { type: 'boolean' },
@@ -16,7 +17,7 @@ async function main() {
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/verify.ts --out /tmp/new-run check [build] [integration]\nOr: --out /tmp/new-run focused --focus fragment [--focus fragment]\nOr: --out /tmp/new-run base-games --games 20 --seed 20260913\nLogs and source-bound report.json stay outside the checkout. Commands stop on failure; changed source invalidates success.',
'Usage: node --import tsx tools/verify.ts --out /tmp/new-run check [build] [integration]\nOr: --out /tmp/new-run focused --focus fragment [--focus fragment] [--name REGEX]\nOr: --out /tmp/new-run base-games --games 20 --seed 20260913\nLogs and source-bound report.json stay outside the checkout. Commands stop on failure; changed source invalidates success.',
);
return;
}
@@ -24,6 +25,12 @@ async function main() {
throw new Error('Provide --out and verification steps; see --help.');
if (values.focus?.some((fragment) => !fragment || fragment.startsWith('-')))
throw new Error('--focus accepts filename fragments, not runner options.');
if (values.name !== undefined) {
if (!values.name) throw new Error('--name must not be empty.');
new RegExp(values.name);
if (!positionals.includes('focused'))
throw new Error('--name requires the focused step.');
}
const root = fileURLToPath(new URL('../', import.meta.url));
const steps: VerificationStep[] = positionals.map((name) => {
if (['check', 'build', 'integration'].includes(name))
@@ -33,7 +40,16 @@ async function main() {
args: ['run', name === 'integration' ? 'test:integration' : name],
};
if (name === 'focused' && values.focus?.length)
return { name, command: 'npm', args: ['test', '--', ...values.focus] };
return {
name,
command: 'npm',
args: [
'test',
'--',
...values.focus,
...(values.name !== undefined ? ['--name', values.name] : []),
],
};
if (name === 'base-games') {
const games = Number(values.games),
seed = Number(values.seed);