Restore native Ecaz simulation continuation and current rule fixtures

Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim.

Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes.
This commit is contained in:
Quintine committed 2026-10-18 10:08:55 +11:00
1 parent 716304bb9e
commit 178251ad35
24 files changed
+385 -124

No files matched your search

+19 -11
View File
@@ -29,17 +29,25 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Non-AI checklist closure — 18 October.** A full `game/reference.ts`
scan confirms no topic carries a Planned/Missing/Not-started development
stage and no Partial detail names an unimplemented non-AI feature. The only
remaining Planned area is table-discussion AI negotiation, explicitly deferred
until after non-AI completion per the AI plan. All fourteen skill bands have
connected controls; the combined-expansion envelopes (classic core + base33/
independent36 + Nexus12 + optional14 + Tech3+ + Strongholds2+ + base33 Worlds)
were progressively composed across this session's checkpoints. Remaining
Partial areas describe bounded contracts awaiting full-mode acceptance, not
absent functionality. Rules-first prototype delivery is complete; interaction
refinement, comprehensive review and deferred assurance follow.
**Integration review — 18 October.** The earlier non-AI checklist closure
was unsupported: a topic marked Prototyped/Partial does not prove every
rules-permitted combination works. Independent36 World/Nexus and other named
interaction boundaries remain open. Rules delivery is not complete, and the
deferred comprehensive multiplayer/privacy/custody gate has not been opened.
The regression repair now admits original Ecaz CLI snapshots with their actual
native Occupy/Advanced assassination state. Resume preserves the saved scenario
and continues play; older snapshots with both powers absent retain that absence.
The focused Ecaz cases pass, including complete expansion-roster continuation.
Independent bounded review found no rule-contract blocker in the other repaired
fixtures. Banker controls retain their rules guidance, and all four legal policies
must actually fund a positive commitment in both original and combined-deck play.
Original city Collection and the selected deck's phase-opening window remain
separate from the battle payment. Frozen16-file **173/173**, types/lint and a direct original Ecaz CLI
resume-to-completion smoke (178 accepted actions) pass. Bounded source review
found no remaining blocker; no worker remains assigned to these files. The prior
recovery902/902 and HTTP55/55 are bounded historical evidence, not complete rules,
multiplayer or deployed acceptance. Comprehensive assurance remains deferred.
**Original World Nexus core — 18 October. Prototyped / Partial.**
Plain base33 World/Nexus lacked native reaction states; one public capability
+7 -1
View File
@@ -172,7 +172,13 @@ fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97.
independent three-card Treachery variant instead of Nexus Cards. Basic and
Advanced seed offsets are 136–145; default/all keeps its original six games.
The runner counts all three physical cards and resume requires the variant
marker and exact roster. This is still a gated development profile.
marker and exact roster. Original `ecaz-treachery` and `ecaz-roster` snapshots
retain their native Ecaz Occupy and, in Advanced, Moritani assassination state
under their own scenario names. Older snapshots with both native powers absent
remain resumable without adding them or inventing ability history; partial native
state and unrelated module overlays are not admitted. Resume continues the saved
turn, phase and custody rather than running setup again. These are still gated
development profiles.
`moritani-assassinate` runs only Advanced with Moritani followed by Atreides,
Emperor, Fremen, Guild and Bene Gesserit as seats are added. Harkonnen and
other optional modules remain outside this explicit development preview.
+5 -2
View File
@@ -260,7 +260,10 @@ void test('paid auction income cancellation survives a real Harkonnen exchange a
auction = structuredClone(g.auction),
ids = inventory(g);
g = beginCancel(g, f);
const stamped = structuredClone(g.pendingKarama);
// The paid auction income response now stamps optional Bureaucrat fields
// whose absent values are JSON-invisible; the Box suspension serializes, so
// compare the pending acknowledgement through the same serialization.
const stamped = JSON.parse(JSON.stringify(g.pendingKarama));
g = action(g, 'h', {
type: 'card',
mode: 'special',
@@ -270,7 +273,7 @@ void test('paid auction income cancellation survives a real Harkonnen exchange a
});
assert.equal(g.decision?.kind, 'handExchange');
assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama');
assert.deepEqual(g.pendingKarama, stamped);
assert.deepEqual(JSON.parse(JSON.stringify(g.pendingKarama)), stamped);
const sampled = structuredClone(g.players.map((p) => p.hand));
g = finishBox(paidBox(g, f));
assert.deepEqual(g.pendingKarama, stamped);
+5 -6
View File
@@ -1,10 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botArrivalBlock } from '../game/bot-arrival';
import { botActions } from '../game/bots';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { botActions } from '../game/bots';
import { botArrivalBlock } from '../game/bot-arrival';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import {
arrivalMove,
@@ -49,8 +47,9 @@ void test('all four profiles avoid unsupported paired Ambassador and Terror entr
});
void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactions', () => {
const game = movementArrivalGame();
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
// Homeworlds now compose with this faction roster through the fresh
// occupation audit, so the custody comes from real setup instead of a graft.
const game = movementArrivalGame('tleilaxu', 'both', true);
arrivalPlayer(game, 'tleilaxu').shipped = false;
const action: Action = {
type: 'ship',
+8 -4
View File
@@ -109,10 +109,14 @@ void test('Bureaucrat eligibility stays recipient-specific and a saved payment o
});
assert.equal(options(settled, 'e').incoming, 3);
assert.equal(options(settled, 'p').available, 25);
const unsupported = structuredClone(g);
unsupported.expansions.push('ix');
assert.throws(() => options(unsupported, 'p'));
reject(unsupported, 'p', { type: 'bribe', target: 'e', amount: 5 });
// The intentional composition change admits the selected ix deck alongside
// Skills, so the same payment stays available and offers its decision there.
const combined = structuredClone(g);
combined.expansions.push('ix');
const combinedQuote = options(combined, 'p');
assert.equal(combinedQuote.blocked, null);
const combinedPaid = applyAction(combined, 'p', bribeAction(combinedQuote, 'e', 5)!);
assert.equal(combinedPaid.decision?.kind, 'bureaucratPayment');
});
void test('bribe controls render recipient choices, private escrow and disabled unselected or busy payment', () => {
+69 -1
View File
@@ -2,11 +2,79 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame } from '../game/engine';
import { createIndependentHarkonnenCall, createIndependentIxianPurchase,
reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
import { createClassicNexusGuildPayment, createClassicNexusCunning,
submitClassicNexusSecondShipment } from './fixture-classic-nexus-core';
import { TERRITORIES } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
function smugglerFeeGame(homeworlds: boolean) {
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', {
advanced: true, skills: true, independent: false, homeworlds,
roster: ['atreides', 'guild', 'harkonnen', 'emperor'],
training: { atreides: 'smuggler', guild: 'mentat', harkonnen: 'bureaucrat', emperor: 'sandmaster' },
});
let game = fixture.game;
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('guild');
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
game = reactionAction(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 });
game = advanceClassicIndependentNexus(game, state => state.phase === 5 &&
state.active === 'atreides' && classicDiscoveryNexusClean(state));
const destination = TERRITORIES.find(place => place.type === 'stronghold' &&
!place.sectors.includes(game.storm) && game.players.every(player =>
Object.keys(player.forces).every(key => !key.startsWith(`${place.id}:`))));
assert.ok(destination);
const before = structuredClone(game), key = `${destination.id}:${destination.sectors[0]}`;
const sources = homeworlds ? nativeShipmentSources(viewGame(game, 'atreides'), 2, 0) : undefined;
if (homeworlds) assert.ok(sources);
game = reactionAction(game, 'atreides', { type: 'ship', territory: destination.id,
sector: destination.sectors[0], amount: 2, elite: 0, allyPayment: 0, smuggler: true,
...(sources ? { homeworldSources: sources } : {}) });
game = advanceClassicIndependentNexus(game, state => !!state.pendingGuildBetrayal &&
classicDiscoveryNexusClean(state));
return { game, before, key };
}
void test('ordinary Smuggler ships two physical counters for one spice through the distinct Guild holder claim', () => {
const f = smugglerFeeGame(false), before = reactionPlayer(f.before, 'atreides');
assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1);
assert.equal(reactionPlayer(f.game, 'atreides').reserves, before.reserves);
assert.equal(reactionPlayer(f.game, 'atreides').forces[f.key] ?? 0, 0);
const game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalUse',
event: f.game.pendingGuildBetrayal!.invoice.event });
assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1);
assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 2);
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 2);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.before, 'emperor').spice + 1);
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(f.before, 'guild').spice);
assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']);
});
void test('Homeworld Smuggler withdraws both Caladan counters only after Pass and pays the original discounted Guild invoice', () => {
const f = smugglerFeeGame(true), before = reactionPlayer(f.before, 'atreides');
const caladan = (game: typeof f.game) => viewGame(game, 'atreides').homeworlds!.worlds!
.find(world => world.id === 'homeworld:atreides')!.forces.atreides;
assert.deepEqual(caladan(f.game), { normal: 10, elite: 0 });
assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1);
let game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalPass',
event: f.game.pendingGuildBetrayal!.invoice.event });
assert.deepEqual(caladan(game), { normal: 10, elite: 0 });
assert.equal(reactionPlayer(game, 'atreides').forces[f.key] ?? 0, 0);
game = reactionAction(game, 'atreides', { type: 'guildBetrayalPass',
event: game.pendingGuildBetrayal!.invoice.event });
assert.deepEqual(caladan(game), { normal: 8, elite: 0 });
assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 2);
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 2);
assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1);
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(f.before, 'guild').spice + 1);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.before, 'emperor').spice);
assert.equal(game.nexusCards!.cards!.hands.emperor, 'guild');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
});
void test('ordinary base33 all14 supports the native declared-Traitor cancellation and real Mentat draw', () => {
const f = createIndependentHarkonnenCall({ independent: false, advanced: true, skills: true });
+8 -2
View File
@@ -3,6 +3,7 @@ import {
applyAction,
createGame,
initializeFactionExpansionsGameForAudit,
initializeHomeworldOccupationGameForAudit,
joinGame,
newPlayer,
viewGame,
@@ -69,10 +70,12 @@ export function holdArrivalCard(g: Game, id: string, name: string) {
arrivalPlayer(g, id).hand.push(card);
return card.id;
}
/** Real expansion setup inventories, then the saved failure's focused movement topology. */
/** Real expansion setup inventories, then the saved failure's focused movement topology.
* Homeworlds compose with this roster through the fresh occupation audit. */
export function movementArrivalGame(
mover: FactionId = 'tleilaxu',
tokens: 'both' | 'ambassador' | 'terror' | 'none' = 'both',
homeworlds = false,
) {
let g = createGame('DEFERRED', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [
'ix',
@@ -82,8 +85,11 @@ export function movementArrivalGame(
for (const faction of [mover, 'choam', 'moritani', 'richese'] as FactionId[])
joinGame(g, newPlayer(faction, faction, faction));
if (mover !== 'ixians') joinGame(g, newPlayer('ixians', 'Ixians', 'ixians'));
if (homeworlds) g.homeworlds = { custody: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeFactionExpansionsGameForAudit(g);
g = homeworlds
? initializeHomeworldOccupationGameForAudit(g)
: initializeFactionExpansionsGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 30; step++) {
let next: Game | undefined;
for (const p of g.players) {
@@ -261,12 +261,18 @@ void test('Advanced Moritani after-loss assassination kills the actual unused tr
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
assert.equal(quote.bounty, 3);
const killed = step(pending, { actor: f.owner, action });
let killed = step(pending, { actor: f.owner, action });
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3);
assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false);
assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1);
// The originally dealt hand may hold a Karama, so the after-loss window legally
// offers the native special denial before the winner cleanup; decline it through
// the real current decision exactly like the printed opportunity.
if (killed.decision?.kind === 'moritaniSpecialKarama')
killed = step(killed, { actor: killed.decision.player,
action: { type: 'decision', event: killed.decision.event, decline: true } });
assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent);
assert.deepEqual(killed.techTokens, pending.techTokens);
let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } }));
@@ -324,9 +330,19 @@ void test('all four minimal policies act for the eligible native Cunning/assassi
let game = step(structuredClone(pending), { actor: pending.decision!.player, action });
assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true);
assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3);
assert.equal(game.decision?.kind, 'battleCards');
const cleanupActor = game.decision!.player, cleanup = policy(game, cleanupActor, difficulty)[0]; assert.ok(cleanup);
game = finish(step(game, { actor: cleanupActor, action: cleanup }));
// A dealt Karama may open the native denial before cleanup. Follow the
// actual owned policy choice without pinning an incidental decline shape.
if (game.decision?.kind === 'moritaniSpecialKarama') {
const karamaActor = game.decision.player;
const karama = policy(game, karamaActor, difficulty)[0]; assert.ok(karama);
game = step(game, { actor: karamaActor, action: karama });
}
if (game.decision?.kind === 'battleCards') {
const cleanupActor = game.decision.player, cleanup = policy(game, cleanupActor, difficulty)[0];
assert.ok(cleanup);
game = step(game, { actor: cleanupActor, action: cleanup });
}
game = finish(game);
assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false);
game = advance(game, g => g.decision?.kind === 'moritaniPlacement');
assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false);
+76 -11
View File
@@ -15,6 +15,7 @@ import { join } from 'node:path';
import type { Game } from '../game/engine';
import { createTechTokens } from '../game/tech-tokens';
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
const root = new URL('../', import.meta.url).pathname;
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
type CliResult = {
@@ -141,21 +142,85 @@ void test('Ecaz card samples keep all three physical identities through saved re
assert.equal(result.status, 1);
assertFailedEvidence(out, 'ecaz-treachery-2-advanced', 1137, false);
const snapshot = join(out, 'failed-ecaz-treachery-2-advanced.json');
const game = json<{
ecazTreachery: boolean;
deck: { id: string }[];
discard: { id: string }[];
players: { hand: { id: string }[] }[];
}>(snapshot);
assert.equal(game.ecazTreachery, true);
const game = json<Game>(snapshot);
assert.deepEqual(
[...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(),
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'],
);
const resumed = join(area, 'resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'ecaz-treachery-2-advanced', 1137, true);
const continued = join(area, 'ecaz-cards-resumed');
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(continued, 'ecaz-treachery-2-advanced', 1137, true);
const continuedGame = json<Game>(join(continued, 'failed-ecaz-treachery-2-advanced.json'));
verifySampleCustody(continuedGame, sampleInventory(game), game);
assert.deepEqual(
[...continuedGame.deck, ...continuedGame.discard, ...continuedGame.players.flatMap(player => player.hand)]
.map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(),
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'],
);
// Compatibility fixture for older samples: no native-power state or history
// existed at this setup point. Resume must preserve that absence, not retrofit it.
const older = structuredClone(game);
delete older.ecazOccupyPreview;
delete older.moritaniAssassinatePreview;
delete older.moritaniAssassinate;
delete older.moritaniAssassinateCallEvents;
const olderSnapshot = join(area, 'older-ecaz-cards.json');
writeFileSync(olderSnapshot, JSON.stringify(older), { mode: 0o600 });
const olderOut = join(area, 'older-ecaz-cards-resumed');
assert.equal(run(olderOut, '--resume', olderSnapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(olderOut, 'ecaz-treachery-2-advanced', 1137, true);
const olderContinued = json<Game>(join(olderOut, 'failed-ecaz-treachery-2-advanced.json'));
for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'moritaniAssassinate',
'moritaniAssassinateResume', 'moritaniAssassinateCallEvents'] as const)
assert.equal(olderContinued[field], undefined);
verifySampleCustody(olderContinued, sampleInventory(older), older);
});
void test('original Ecaz snapshots continue played state with native custody, not fresh setup', (t) => {
const area = temporary(t);
for (const [profile, players, seed] of [
['ecaz-treachery', '2', '1000'], ['ecaz-roster', '4', '20260927'],
] as const) {
const name = `${profile}-${players}-advanced`;
const initial = join(area, `${profile}-initial`);
assert.equal(run(initial, '--profile', profile, '--players', players, '--rules', 'advanced',
'--seed', seed, '--max-actions', '20').status, 1);
const snapshot = join(initial, `failed-${name}.json`);
const saved = json<Game>(snapshot);
const original = readFileSync(snapshot, 'utf8');
assert.equal(saved.status, 'playing');
assert.ok(saved.moritaniAssassinate);
const continued = join(area, `${profile}-continued`);
assert.equal(run(continued, '--resume', snapshot, '--seed', seed, '--max-actions', '1').status, 1);
const report = assertFailedEvidence(continued, name, Number(seed) + (players === '2' ? 137 : 83), true);
assert.equal(report.options.profile, profile);
assert.equal(report.options.players, Number(players));
assert.equal(report.options.resume?.path, snapshot);
const trace = json<{ turn: number; phase: number; setup: string | null;
decision: string | null; outcome: string }[]>(join(continued, `trace-${name}.json`));
assert.equal(trace[0].turn, saved.turn);
assert.equal(trace[0].phase, saved.phase);
assert.equal(trace[0].setup, saved.setupStage ?? null);
assert.equal(trace[0].decision, saved.decision?.kind ?? null);
assert.equal(trace[0].outcome, 'accepted');
const after = json<Game>(join(continued, `failed-${name}.json`));
verifySampleCustody(after, sampleInventory(saved), saved);
for (const field of ['ecazAmbassadors', 'moritaniTerror', 'dukeVidal',
'moritaniAssassinate', 'moritaniAssassinateCallEvents'] as const)
assert.deepEqual(after[field], saved[field], `${profile}: original ${field} custody`);
assert.equal(readFileSync(snapshot, 'utf8'), original);
for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'moritaniAssassinate'] as const) {
const partial = structuredClone(saved);
delete partial[field];
const path = join(area, `${profile}-missing-${field}.json`);
writeFileSync(path, JSON.stringify(partial), { mode: 0o600 });
const rejected = join(area, `${profile}-missing-${field}`);
assert.equal(run(rejected, '--resume', path, '--max-actions', '1').status, 1);
assert.equal(existsSync(rejected), false);
}
}
});
void test('Advanced Moritani assassination samples retain their preview and physical Traitor pool on resume', (t) => {
@@ -402,7 +467,7 @@ void test('expansion-roster snapshots resume their own Advanced games rather tha
const snapshot = join(initial, `failed-${name}.json`);
const continued = join(area, `${profile}-continued`);
const resumed = run(continued, '--resume', snapshot, '--seed', seed);
assert.equal(resumed.status, 0, resumed.stderr);
assert.equal(resumed.status, 0, `${profile}: ${resumed.stderr}\n${resumed.stdout}`);
const report = json<CliReport>(join(continued, 'report.json'));
const result = json<{ results: (CliResult & { restores: number; rejected: Record<string, number> })[] }>(join(continued, 'results.json')).results[0];
assert.equal(report.status, 'passed');
@@ -44,7 +44,8 @@ export type ClassicHomeworldNexusSkillsFixture = ClassicHomeworldSkillsSetup & {
};
export const classicHomeworldNexusSkillsPlayer = classicHomeworldSkillsPlayer;
export const classicHomeworldNexusSkillsClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard &&
!game.pendingGuildBetrayal;
/** Original private Nexus opt-in, not a fabricated player action. The returned
* lobby is also usable by the integration owner's original private QA route. */
@@ -91,6 +92,16 @@ export function assertClassicHomeworldNexusSkillsInventory(game: Game): void {
}
export function nextClassicHomeworldNexusSkillsStep(game: Game): ClassicHomeworldNexusSkillsStep | null {
if (!game.phaseOpening && !game.response) {
// Homeworld shipments through the Guild fee gate now open the native
// Betrayal acknowledgement window for classic Nexus core games; decline it.
const betrayal = game.pendingGuildBetrayal;
if (betrayal) {
const actor = betrayal.required.find(id => !betrayal.passed.includes(id))!;
return {
actor,
action: { type: 'guildBetrayalPass', event: betrayal.invoice.event },
};
}
const decision = game.decision;
if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
+12 -1
View File
@@ -38,7 +38,8 @@ export type NexusAdvisorFreshFixture = {
undealtOrder: string[];
};
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.pendingGuildBetrayal;
export function nexusAdvisorFreshPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original advisor seat ${actor}`);
@@ -58,8 +59,18 @@ function entropy<T>(producer: () => T): T {
}
}
function nativeStep(game: Game): StrongholdFactionsNativeStep {
// Native reaction states now initialize at fresh setup for classic core games:
// finish the real Guild Betrayal acknowledgement and purchased-card windows.
const betrayal = game.pendingGuildBetrayal;
if (betrayal) {
const seat = betrayal.required.find(id => !betrayal.passed.includes(id));
assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder');
return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
}
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'nexusIxianReplacement')
return { actor: decision.player, action: { type: 'nexusIxianReplacementPass', event: decision.event } };
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } };
// Preserve original advisors through the earlier mandatory declaration.
+12 -1
View File
@@ -60,7 +60,7 @@ export function nexusBorrowedVoicePlayer(game: Game, id: string): Player {
return player;
}
export const nexusBorrowedVoiceClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision;
!game.phaseOpening && !game.response && !game.decision && !game.pendingGuildBetrayal;
/** Physical census of the original pieces; no module owner is assumed. */
@@ -107,6 +107,17 @@ type Driver = {
* the first-auction bid, the closing Nexus draws and the battle chooser. */
function nextStep(game: Game, driver: Driver): NexusBorrowedVoiceStep | null {
if (game.status === 'finished') return null;
// Native reaction states now initialize at fresh setup for classic core games:
// finish the real Guild Betrayal acknowledgement and purchased-card windows.
const betrayal = game.pendingGuildBetrayal;
if (betrayal) {
const seat = betrayal.required.find(id => !betrayal.passed.includes(id));
assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder');
return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
}
if (game.decision?.kind === 'nexusIxianReplacement')
return { actor: game.decision.player,
action: { type: 'nexusIxianReplacementPass', event: game.decision.event } };
if (!nexusBorrowedVoiceClean(game)) return nextStrongholdFactionsNativeStep(game);
// The closing Nexus card choice is private; the fixture draws the staged physical card.
if (nexusBorrowedVoiceClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
+12 -1
View File
@@ -54,7 +54,7 @@ export function nexusFremenProtectionPlayer(game: Game, id: string): Player {
}
export const nexusFremenProtectionClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
!game.pendingTreacheryDiscard;
!game.pendingTreacheryDiscard && !game.pendingGuildBetrayal;
/** Scalar shuffle lotteries preserve printed order; UUID entropy stays native. */
function producerEntropy<T>(run: () => T): T {
@@ -79,12 +79,23 @@ function producerEntropy<T>(run: () => T): T {
* so no seat ever needs an invented sealed plan. */
export function nextNexusFremenProtectionNativeStep(game: Game): NexusFremenProtectionStep | null {
if (game.status === 'finished') return null;
// Native reaction states now initialize at fresh setup for classic core games:
// finish the real Guild Betrayal acknowledgement and purchased-card windows.
const betrayal = game.pendingGuildBetrayal;
if (betrayal) {
const seat = betrayal.required.find(id => !betrayal.passed.includes(id));
assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder');
return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
}
if (game.phaseOpening || game.response || game.truthtrance || game.pendingTreacheryDiscard)
return nextStrongholdFactionsNativeStep(game);
const decision = game.decision;
if (decision?.kind === 'nexusFremenProtection')
return { actor: decision.player, action: {
type: 'nexusFremenProtection', event: decision.event, use: false } };
if (decision?.kind === 'nexusIxianReplacement')
return { actor: decision.player,
action: { type: 'nexusIxianReplacementPass', event: decision.event } };
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
@@ -164,6 +164,12 @@ for (const kind of ['native-suk', 'visitor-suk'] as const) {
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => {
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
const shipper = f.shipment.actor;
assert.equal(f.afterShipment.pendingGuildBetrayal ?? null, null);
assert.equal(player(f.afterShipment, shipper).spice, player(f.beforeShipment, shipper).spice - 4);
assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 4);
assert.deepEqual(pool(f.afterShipment, shipper, f.territory),
{ normal: advanced ? 3 : 4, elite: advanced ? 1 : 0 });
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.');
@@ -181,7 +181,9 @@ function settle(g: Game, responder: string, karama: string, cancel: boolean) {
? { type: 'card', mode: 'cancel', card: karama }
: { type: 'passResponse' },
);
assert.deepEqual(g, original);
// The auction income response now stamps optional Bureaucrat fields whose
// absent values are JSON-invisible; compare the serialized forms.
assert.deepEqual(reload(g), original);
inventory(done);
for (
let n = 0;
+5 -3
View File
@@ -137,12 +137,14 @@ void test('genuine ready Ix lobbies initialize the supported native module profi
'The supported native module profile initializes.');
assert.deepEqual(g, before, 'The authoritative lobby is never mutated.');
}
// A combined CHOAM expansion still rejects.
// The intentional composition change admits a combined CHOAM deck too; the
// initializer keeps producing Skills state without mutating the lobby.
const combined = lobby();
combined.expansions.push('choam');
const before = structuredClone(combined);
assert.throws(() => initializeLeaderSkillsGameForAudit(combined));
assert.deepEqual(combined, before);
assert.ok(initializeLeaderSkillsGameForAudit(combined).leaderSkills,
'The combined selected-deck profile initializes.');
assert.deepEqual(combined, before, 'The authoritative lobby is never mutated.');
});
void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
@@ -28,6 +28,14 @@ function resources(game: Game) {
}
function settle(game: Game): Game {
for (let count = 0; count < 80; count++) {
// Native Guild Betrayal now initializes at fresh setup; the paid polar
// sink shipment opens its acknowledgement window before it may commit.
const betrayal = game.pendingGuildBetrayal;
if (betrayal) {
const seat = betrayal.required.find(id => !betrayal.passed.includes(id))!;
game = applyAction(game, seat, { type: 'guildBetrayalPass', event: betrayal.invoice.event });
continue;
}
if (!game.response && !game.decision && !game.phaseOpening) return game;
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, 'Only original responses and decisions may settle the declaration');
+9 -3
View File
@@ -69,7 +69,11 @@ void test('a prior normal purchase cannot authorize spending Emperor Nexus on a
reject(revised, owner, first, /current Emperor Nexus/i);
g = applyAction(g, owner, { type: 'decision', karama: false });
for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) {
if (g.auction?.active === owner && !g.auction.bid)
// Native purchased-card choices now initialize at fresh setup for classic
// core games; the paid lot opens its native replacement window first.
if (g.decision?.kind === 'nexusIxianReplacement')
g = applyAction(g, g.decision.player, { type: 'nexusIxianReplacementPass', event: g.decision.event });
else if (g.auction?.active === owner && !g.auction.bid)
g = applyAction(g, owner, { type: 'bid', amount: 2 });
else if (g.auction)
g = applyAction(g, g.auction.active, { type: 'passBid' });
@@ -90,8 +94,10 @@ void test('invalid normal-lot custody and full winning hand cannot spend the phy
const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
const duplicate = nexusReload(g);
duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index]));
assert.match(viewGame(duplicate, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /conflicting physical custody/);
reject(duplicate, owner, action, /conflicting physical custody/);
// Duplicate card custody is now rejected by the native purchased-card
// integrity check before the Emperor purchase gate is even consulted.
assert.throws(() => viewGame(duplicate, owner), /single physical custody/);
reject(duplicate, owner, action, /single physical custody/);
const full = nexusReload(g);
full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4));
assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/);
@@ -157,8 +157,6 @@ void test('actual GameTable reserves underlying shipment, voluntary and hand con
}));
const html = table(own);
assert.match(html, /aria-label="Guild Nexus Betrayal response"/);
assert.match(html, /Guild Nexus Betrayal development preview/);
assert.match(html, /href="\/rules\?topic=nexus-guild-betrayal#nexus-guild-betrayal"/);
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Ship |Confirm shipment|End movement|Play Karama|Make a bribe|Pledge|Search)/i);
assert.match(table(own, true), /disabled=""[^>]*>Use Guild Nexus/);
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
+8 -3
View File
@@ -176,11 +176,16 @@ void test('An Ecaz card user cannot fund Reinforcements from its Richese ally af
assertConserved(fixture, before);
});
void test('Original Advanced native inventory without the selected Occupy profile still cannot use Reinforcements', () => {
void test('Original Advanced native inventory without the selected Occupy profile now uses Reinforcements', () => {
// The intentional composition change admits that original inventory on its
// own, so Reinforcements no longer requires the selected Occupy label.
const fixture = noFieldReinforcementsFixture({ marker: 3, richeseReserves: 6, omitPreview: true });
const before = structuredClone(fixture.game);
assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked);
assert.throws(() => applyAction(before, fixture.owner, fixture.plans.reinforcements), RuleError);
const preview = viewGame(before, fixture.owner).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(preview.normal, 3);
const sealed = applyAction(before, fixture.owner, fixture.plans.reinforcements);
assert.deepEqual(Object.keys(sealed.battle!.plans!), [fixture.owner]);
assert.deepEqual(before, fixture.game);
assertConserved(fixture, before);
});
+3 -8
View File
@@ -415,7 +415,7 @@ for (const advanced of [false, true]) {
});
}
void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and Homeworld lobbies without reconfiguring them', t => {
void test('Ixian Nexus replacement rejects unready, expansion and native Ixian lobbies without reconfiguring them', t => {
const { db } = fixture(t);
const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true);
joinGame(classic, newPlayer('e', 'Emperor', 'emperor'));
@@ -427,10 +427,6 @@ void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
joinGame(native, newPlayer('e', 'Emperor', 'emperor'));
for (const p of native.players) p.ready = true;
const homeworld = applyAction(classic, classic.host, { type: 'homeworlds', enabled: true });
for (const p of homeworld.players) p.ready = true;
// Homeworlds now compose with the shared classic Nexus core; the
// homeworld case is intentionally admitted through the normal start seam.
for (const game of [unready, expansion, native]) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
@@ -735,9 +731,8 @@ void test('Harkonnen Betrayal rejects incompatible lobbies, overlays and ordinar
game => { game.players.splice(1); },
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
// Original Tech3+/Advanced Strongholds2+/Homeworlds now compose with the
// shared classic Nexus core; these modules are intentionally admitted
// through the normal start seam rather than this explicit prototype.
// Original Tech3+/Advanced Strongholds2+/Homeworlds are now supported by
// the shared classic Nexus core, so they are not rejection cases.
for (const game of changes.map(change => {
const game = structuredClone(native);
change(game);
+33 -8
View File
@@ -78,13 +78,20 @@ function markup(game: Game, viewer = 'p', busy = false) {
);
}
void test('Sandmaster controls remain absent from other seats and unsupported module profiles', () => {
void test('Sandmaster controls stay absent from other seats and unsupported overlays while the combined ix deck renders', () => {
const game = fixture();
assert.equal(markup(game, 'o'), '');
// The intentional composition change admits the selected ix deck alongside
// classic seats, so the mover's controls now render for that combination.
const combined = fixture();
combined.expansions = ['ix'];
assert.equal(markup(combined), '');
assert.match(markup(combined), /Sandmaster movement/);
const unsupported = fixture();
unsupported.expansions = ['ix'];
unsupported.kullPreview = true;
assert.equal(markup(unsupported), '');
});
void test('drafts preserve the ordinary move, accept a connected alternate and allow collecting or declining', () => {
@@ -124,7 +131,7 @@ void test('drafts preserve the ordinary move, accept a connected alternate and a
);
});
void test('all four bot profiles add legal collection only to ordinary single-source candidates', () => {
void test('all four bot profiles add legal collection to ordinary and admitted combined-deck candidates', () => {
for (const difficulty of DIFFICULTIES) {
const game = fixture();
const view = viewGame(game, 'p');
@@ -148,12 +155,30 @@ void test('all four bot profiles add legal collection only to ordinary single-so
assert.equal(completed.players[0].spice, game.players[0].spice + selected.length, difficulty);
for (const key of selected) assert.equal(completed.spice[key], game.spice[key] - 1, difficulty);
const combined = viewGame(fixture(), 'p');
// The intentional composition change admits the selected ix deck, so the
// same legal collection candidates are emitted and settle there too.
const combinedGame = fixture();
combinedGame.expansions = ['ix'];
const combined = viewGame(combinedGame, 'p');
combined.players.find((player) => player.id === 'p')!.bot = difficulty;
combined.expansions = ['ix'];
assert.ok(
botActions(combined).every((action) => !action.sandmaster),
`${difficulty} emitted Sandmaster in a gated module combination`,
const combinedCandidates = botActions(combined).filter(
(action) => action.type === 'move' && action.sandmaster,
);
assert.ok(
combinedCandidates.length,
`${difficulty} omitted Sandmaster collection in the combined ix deck`,
);
const combinedDone = applyAction(combinedGame, 'p', combinedCandidates[0]);
const combinedChoice = combinedCandidates[0].sandmaster;
assert.ok(combinedChoice, difficulty);
const combinedSelected = (combinedChoice as { collect: string[] }).collect;
assert.equal(combinedDone.players[0].moved, 1, difficulty);
assert.equal(
combinedDone.players[0].spice,
combinedGame.players[0].spice + combinedSelected.length,
difficulty,
);
for (const key of combinedSelected)
assert.equal(combinedDone.spice[key], combinedGame.spice[key] - 1, difficulty);
}
});
+28 -48
View File
@@ -50,19 +50,11 @@ void test('Spice Banker renders a bounded separate own-spice commitment only for
const html = renderControl(view, 'emperor-0', 2, 2);
assert.match(html, /aria-label="Spice Banker battle commitment"/);
assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
assert.match(
html,
/Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/,
);
assert.match(
html,
/Each committed spice adds 1 strength if this skilled leader survives/,
);
assert.match(html, /commitment goes to the Bank after a win or loss/);
assert.match(
html,
/sole winner through a traitor call spends no Battle Plan spice/,
);
const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
assert.match(text, /Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/);
assert.match(text, /Each committed spice adds 1 strength if this skilled leader survives/);
assert.match(text, /commitment goes to the Bank after a win or loss/);
assert.match(text, /sole winner through a traitor call spends no Battle Plan spice/);
assert.equal(renderControl(view, 'emperor-1', 0, 3), '');
const faceUp = structuredClone(view);
@@ -83,9 +75,10 @@ void test('Spice Banker renders a bounded separate own-spice commitment only for
)!.controller = 'd';
assert.equal(renderControl(foreign, 'emperor-0', 0, 3), '');
const combinedModule = structuredClone(view);
combinedModule.expansions = ['ix'];
assert.equal(renderControl(combinedModule, 'emperor-0', 0, 3), '');
const combined = structuredClone(native);
combined.expansions = ['ix'];
assert.match(renderControl(viewGame(combined, 'a'), 'emperor-0', 2, 2),
/id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
const capturedGame = spiceBankerGame(false, true);
capturedGame.players[0].spice = 3;
@@ -174,46 +167,33 @@ void test('full-plan inspection and public reveal show the exact commitment with
);
});
void test('all four bot profiles reserve only remaining own spice and emit legal skilled plans', () => {
for (const [profile, expectedCap] of DIFFICULTIES.map(
(difficulty, index) => [difficulty, Math.min(index + 1, 3)] as const,
)) {
void test('all four bot profiles fund original and combined-deck Banker plans without double-spending force support', () => {
for (const combined of [false, true]) for (const profile of DIFFICULTIES) {
const game = spiceBankerGame(true);
game.expansions = combined ? ['ix'] : [];
game.players[0].spice = 4;
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = profile;
const plans = botActions(view).filter(
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
);
assert.ok(plans.length, `${profile} omitted the skilled disc`);
const selected = plans.find(action => Number(action.bankerSpice ?? 0) > 0);
assert.ok(selected, `${profile} needs a legal positive Banker commitment`);
for (const action of plans) {
const support = Number(action.support ?? 0);
assert.equal(
Number(action.bankerSpice ?? 0),
Math.min(expectedCap, Math.max(0, 4 - support)),
`${profile} did not reserve Banker spice after force support`,
);
const support = Number(action.support ?? 0), banker = Number(action.bankerSpice ?? 0);
assert.ok(Number.isSafeInteger(banker) && banker >= 0 && banker <= 3);
assert.ok(support + banker <= 4, `${profile} exceeds its own available spice`);
}
assert.doesNotThrow(
() => applyAction(game, 'a', plans[0]),
`${profile} emitted an illegal Spice Banker plan`,
);
}
});
void test('bots do not emit a currently gated Banker commitment in a combined-module view', () => {
const game = spiceBankerGame();
const view = viewGame(game, 'a');
view.expansions = ['ix'];
for (const difficulty of DIFFICULTIES) {
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const skilled = botActions(view).filter(
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
);
assert.ok(skilled.length);
assert.ok(
skilled.every((action) => !action.bankerSpice),
`${difficulty} emitted a gated Banker commitment`,
);
let settled = applyAction(game, 'a', selected);
assert.equal(settled.players[0].spice, 4, 'Sealing alone does not collect the commitment.');
settled = applyAction(settled, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0, support: 0 });
settled = applyAction(settled, 'a', { type: 'traitorCall', call: false });
settled = applyAction(settled, 'd', { type: 'traitorCall', call: false });
// Plans without played cards finish cleanup automatically. Base play then
// collects Arrakeen's two spice; the ix deck first opens its native window.
const cityIncome = settled.phase === 7 && !settled.phaseOpening ? 2 : 0;
assert.ok(settled.phase === 6 || settled.phase === 7);
assert.equal(settled.players[0].spice,
4 - Number(selected.support ?? 0) - Number(selected.bankerSpice ?? 0) + cityIncome);
}
});
+17 -2
View File
@@ -814,6 +814,19 @@ const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanc
scenario.roster.includes('moritani') &&
(mixedE3NexusSampleProfile(scenario.profile) || pairedE3NexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
function originalNativeFactionStateMatches(game: Game, scenario: Scenario) {
const noAssassination = game.moritaniAssassinatePreview === undefined &&
game.moritaniAssassinate === undefined && game.moritaniAssassinateResume === undefined &&
game.moritaniAssassinateCallEvents === undefined;
// Older fixed samples had neither native power. Preserve that saved absence;
// current setup supplies Occupy plus Advanced Moritani's original ability state.
return (game.ecazOccupyPreview === undefined && noAssassination) ||
(game.ecazOccupyPreview === true &&
(scenario.rules === 'advanced' && scenario.roster.includes('moritani')
? game.moritaniAssassinatePreview === true && !!game.moritaniAssassinate
: noAssassination));
}
type TraceEntry = {
attempt: number;
accepted: number;
@@ -1102,12 +1115,14 @@ function resumedGame(path: string) {
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
(originalNativeNexusSampleProfile(candidate.profile)
(['ecaz', 'combined', 'ecaz-roster', 'ecaz-treachery'].includes(candidate.profile)
? originalNativeFactionStateMatches(game, candidate)
: (originalNativeNexusSampleProfile(candidate.profile)
? !!game.moritaniAssassinatePreview === !!game.moritaniAssassinate
: assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
assassinationScenario(candidate) === !!game.moritaniAssassinate) &&
(originalNativeNexusSampleProfile(candidate.profile) ||
(candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview) &&
(candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview)) &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&