Exercise Ecaz card variant and canonicalize advisor stances
This commit is contained in:
1 parent
6ac5dabdee
commit
c961a93f62
10 files changed
+185
-40
No files matched your search
@@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
The independent Ecaz three-card variant now has a repeatable
|
||||
[genuine-game sample profile](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games)
|
||||
for paired Ecaz/Moritani rosters from two to six seats. Ten Basic/Advanced
|
||||
games at base seed `20260929` completed with 3,768 accepted actions, zero
|
||||
rejected candidates and 95 JSON continuations; the six-seat Advanced game
|
||||
actually played Recruits, Reinforcements and Harass & Withdraw. That run
|
||||
exposed and confirmed a saved-view drift from unlocked Bene Gesserit advisor
|
||||
stances, now corrected at four arrival/movement writers. These samples do
|
||||
not certify all card interactions, full expansion modes or deployment.
|
||||
|
||||
The separate [Reinforcements card prototype](REINFORCEMENTS_RUNTIME.md)
|
||||
now works in clean Ecaz/Moritani expansion battles as well as classic-faction
|
||||
tables in Basic and Advanced. Genuine setup, owner-only two-slot controls,
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 29 September 2026 — Ecaz card variant full-game integration samples
|
||||
|
||||
The reusable offline runner now has `--profile ecaz-treachery`: two through
|
||||
six genuine paired Ecaz/Moritani seats, each Basic and Advanced, with the
|
||||
independent three-card deck, physical custody checks, all four saved AI
|
||||
profiles and explicit JSON continuation. Its default six historical games
|
||||
and seed offsets remain unchanged; incomplete snapshots can resume only
|
||||
under the exact saved variant/roster. Public starts remain gated.
|
||||
|
||||
The first same-seed matrix completed eight of ten games and caught an actual
|
||||
saved-view mismatch: an unlocked Bene Gesserit advisor stance was stored as
|
||||
`{lockedTurn:undefined}` by movement, Guild transport, Junction transport or
|
||||
Fremen Ambassador relocation, but JSON restored it as `{}`. The four writers
|
||||
now store a canonical empty stance or a numeric lock. Focused Guild,
|
||||
Junction, ordinary move and Fremen relocation cases preserve view parity
|
||||
after JSON; locked arrivals still retain their original turn. No saved room
|
||||
or credential was altered.
|
||||
|
||||
The final source-bound run at base seed `20260929` completed all ten games:
|
||||
3,768 accepted actions, no rejected candidates, 95 JSON continuations and
|
||||
unchanged source fingerprint. The six-seat Advanced game used all three
|
||||
physical Ecaz card effects. Report:
|
||||
`/tmp/dune-ecaz-treachery-matrix-final-20260929/report.json`, based on
|
||||
`6ac5dab` plus the uncommitted source changes of this checkpoint (source
|
||||
fingerprint `466f801d36e8dc9aa1b14d2c9215813c8b4561926cc2ba43664d38e03ebbb081`).
|
||||
This samples playable continuation, not complete card interactions,
|
||||
combined-module acceptance, calibrated AI or deployed behavior.
|
||||
|
||||
The focused runner/advisor suites pass 101/101 cases. An independent
|
||||
read-only review of the four writer changes, source custody and resume gates
|
||||
found no concrete defect; it did not run tests or certify release readiness.
|
||||
|
||||
`npm run check` passes typecheck, lint and 5,765/5,765 offline cases;
|
||||
`npm run build` passes. No database schema or HTTP route changed.
|
||||
The last verified live image predates these changes.
|
||||
|
||||
## 29 September 2026 — Reinforcements in paired Ecaz/Moritani battles
|
||||
|
||||
The independently selected three-card Ecaz variant now allows its physical
|
||||
|
||||
@@ -111,6 +111,7 @@ node --import tsx tools/faction-games.ts --out /tmp/dune-new-homeworld-nexus --p
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-roster --profile choam-roster
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-roster --profile ecaz-roster
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-roster --profile ix-roster
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-cards --profile ecaz-treachery
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-choam-nexus --profile choam-nexus
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ecaz-nexus --profile ecaz-nexus
|
||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-ix-nexus --profile ix-nexus
|
||||
@@ -124,8 +125,9 @@ Default and `--profile all` retain the original six fixed CHOAM/Richese,
|
||||
Ecaz/Moritani and combined Basic/Advanced samples. `--profile base` selects ten
|
||||
additional samples: Basic and Advanced with two through six players.
|
||||
`--players 2..6` narrows the base, Homeworld, Nexus, combined
|
||||
`homeworld-nexus`, expansion-roster or paired-Nexus profiles. The
|
||||
`combined-nexus` and `combined-homeworld-nexus` profiles support `--players 5`
|
||||
`homeworld-nexus`, expansion-roster, paired-Nexus, independent Ecaz-card or
|
||||
Leader Skills profiles. The `combined-nexus` and
|
||||
`combined-homeworld-nexus` profiles support `--players 5`
|
||||
or `6` only. Omission or `all` retains every roster in a selected profile. The fixed
|
||||
base roster adds Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit
|
||||
in that order. These are sample rosters, not the only rules-permitted
|
||||
@@ -136,6 +138,11 @@ respective physical expansion enabled: CHOAM/Richese start with CHOAM then
|
||||
Richese, Ecaz/Moritani with Moritani then Ecaz, Ixian/Tleilaxu with Ixians
|
||||
then Tleilaxu. Subsequent seats extend each to six without changing existing
|
||||
fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97.
|
||||
`ecaz-treachery` reuses the two-through-six-seat Ecaz/Moritani roster with the
|
||||
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.
|
||||
`choam-nexus`, `ecaz-nexus` and `ix-nexus` reuse the respective expansion
|
||||
rosters with Nexus Cards enabled at seed offsets 98–107, 108–117 and 118–127.
|
||||
`combined-nexus` selects five or six actual expansion factions and all three
|
||||
@@ -153,6 +160,17 @@ rosters use 40–47. The combined module uses offsets 58–67. These samples
|
||||
do not open public module or expansion starts or assert complete interaction
|
||||
coverage.
|
||||
|
||||
At base seed `20260929`, the Ecaz-card profile completed all ten
|
||||
two-to-six-seat Basic/Advanced samples with 3,768 accepted actions, no
|
||||
rejected candidates and 95 JSON continuations. The six-seat Advanced trace
|
||||
includes Recruits, Reinforcements and Harass & Withdraw actual uses. The
|
||||
private source-bound report is
|
||||
`/tmp/dune-ecaz-treachery-matrix-final-20260929/report.json`. The first
|
||||
matrix exposed a non-serializable unlocked advisor stance; its corrected
|
||||
writer paths are documented in
|
||||
[the implementation log](IMPLEMENTATION_STATUS.md#29-september-2026--ecaz-card-variant-full-game-integration-samples).
|
||||
This is not expansion release evidence.
|
||||
|
||||
All-AI Nexus samples now make public reciprocal offers as well as accepting
|
||||
them, so an actual alliance can form without a human proposer. At the
|
||||
20260927 seed, each six-seat sample made six alliance actions and completed;
|
||||
@@ -181,11 +199,11 @@ Advanced requests fail before running. Its seed offsets are 16–20, leaving
|
||||
existing samples unchanged. The full fourteen-card skill census and ordinary
|
||||
Moritani/base Traitor census are checked throughout. `--resume` preserves this
|
||||
explicit profile; other optional modules still fail its admission checks.
|
||||
Homeworld, Nexus and combined snapshots resume only when their saved modules
|
||||
and fixed rosters match a supported profile; other optional module
|
||||
combinations remain excluded. An edited snapshot with a removed module can
|
||||
match a different fixed profile, so verify its original private report
|
||||
before relying on resumed provenance.
|
||||
Homeworld, Nexus, independent Ecaz-card and combined snapshots resume only
|
||||
when their saved modules and fixed rosters match a supported profile; other
|
||||
optional-module combinations remain excluded. An edited snapshot with a
|
||||
removed module can match a different fixed profile, so verify its original
|
||||
private report before relying on resumed provenance.
|
||||
|
||||
All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
|
||||
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
|
||||
|
||||
+16
-24
@@ -11237,13 +11237,10 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
removeGroup(p, group, eliteGroup);
|
||||
if (n > (move.noField ? 1 : 0))
|
||||
place(p, to, s, n - (move.noField ? 1 : 0), elite);
|
||||
if (advisors)
|
||||
(p.advisors ??= {})[to] = {
|
||||
lockedTurn:
|
||||
Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) ||
|
||||
undefined,
|
||||
};
|
||||
else if (p.advisors) delete p.advisors[to];
|
||||
if (advisors) {
|
||||
const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {};
|
||||
} else if (p.advisors) delete p.advisors[to];
|
||||
if (wantsFighters)
|
||||
g.response = {
|
||||
kind: 'advisorFlip',
|
||||
@@ -12451,15 +12448,10 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
|
||||
removeGroup(p, move.group, move.eliteGroup);
|
||||
const physical = move.total - (move.noField ? 1 : 0);
|
||||
if (physical) place(p, move.to, move.sector, physical, move.elite);
|
||||
if (move.advisors)
|
||||
(p.advisors ??= {})[move.to] = {
|
||||
lockedTurn:
|
||||
Math.max(
|
||||
move.lockedTurn ?? 0,
|
||||
p.advisors?.[move.to]?.lockedTurn ?? 0,
|
||||
) || undefined,
|
||||
};
|
||||
else if (p.advisors) delete p.advisors[move.to];
|
||||
if (move.advisors) {
|
||||
const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {};
|
||||
} else if (p.advisors) delete p.advisors[move.to];
|
||||
entry.stage = 'arrival';
|
||||
entry.relocation = { order: move, next: 'flip' };
|
||||
log(
|
||||
@@ -20454,9 +20446,10 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
|
||||
observeOccupation(g);
|
||||
if (arrival) {
|
||||
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
|
||||
if (advisors) (p.advisors ??= {})[arrival.territory] = {
|
||||
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0) || undefined};
|
||||
else if (p.advisors) delete p.advisors[arrival.territory];
|
||||
if (advisors) {
|
||||
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {};
|
||||
} else if (p.advisors) delete p.advisors[arrival.territory];
|
||||
}
|
||||
p.shipped = true;
|
||||
g.junctionOffer = null;
|
||||
@@ -21043,11 +21036,10 @@ function commitGuildTransport(
|
||||
if (p.elites) p.elites.reserves += elite;
|
||||
} else {
|
||||
place(p, to, sector, amount, elite);
|
||||
if (advisors)
|
||||
(p.advisors ??= {})[to] = {
|
||||
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined,
|
||||
};
|
||||
else if (p.advisors) delete p.advisors[to];
|
||||
if (advisors) {
|
||||
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {};
|
||||
} else if (p.advisors) delete p.advisors[to];
|
||||
}
|
||||
observeOccupation(g);
|
||||
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
|
||||
|
||||
@@ -387,6 +387,19 @@ void test('new advisors cannot flip into occupied fighters during their arrival
|
||||
/cannot become fighters/,
|
||||
);
|
||||
});
|
||||
void test('an unlocked ordinary advisor move keeps the same private and public views after JSON', () => {
|
||||
let g = fixture();
|
||||
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
|
||||
army(g, 'b', { 'arrakeen:10': 2, 'imperial_basin:10': 1 }, [
|
||||
'arrakeen', 'imperial_basin',
|
||||
]);
|
||||
g.active = 'b';
|
||||
g = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1);
|
||||
assert.deepEqual(g.players[1].advisors?.imperial_basin, {});
|
||||
for (const player of g.players)
|
||||
assert.deepEqual(viewGame(g, player.id), viewGame(JSON.parse(JSON.stringify(g)) as Game, player.id));
|
||||
});
|
||||
|
||||
void test('advisor setup waits for Fremen, follows their placement, and becomes fighters only when alone', () => {
|
||||
for (const occupied of [false, true]) {
|
||||
let g = createGame('ADSETUP2', newPlayer('a', 'Atreides', 'atreides'));
|
||||
@@ -506,6 +519,22 @@ void test('Guild cross-shipping matches existing advisors and preserves their ar
|
||||
);
|
||||
});
|
||||
|
||||
void test('an unlocked Guild advisor arrival projects identically after JSON continuation', () => {
|
||||
let g = fixture();
|
||||
g.players[2].faction = 'guild';
|
||||
g.players[2].ally = 'b';
|
||||
g.players[1].ally = 'h';
|
||||
army(g, 'a', { 'red_chasm:7': 1 });
|
||||
army(g, 'b', { 'arrakeen:10': 2, 'red_chasm:7': 1 }, ['arrakeen', 'red_chasm']);
|
||||
g.active = 'b';
|
||||
g = allow(applyAction(g, 'b', {
|
||||
type: 'guildShip', from: 'arrakeen:10', territory: 'red_chasm',
|
||||
sector: 7, amount: 2,
|
||||
}));
|
||||
assert.deepEqual(g.players[1].advisors?.red_chasm, {});
|
||||
assert.deepEqual(viewGame(g, 'a'), viewGame(JSON.parse(JSON.stringify(g)) as Game, 'a'));
|
||||
});
|
||||
|
||||
void test('accompanying advisors may select a valid sector of the shipment territory, with no reserve loss on a rejected choice', () => {
|
||||
let g = fixture();
|
||||
g = applyAction(g, 'a', {
|
||||
|
||||
@@ -94,6 +94,31 @@ function assertFailedEvidence(
|
||||
return report;
|
||||
}
|
||||
|
||||
void test('Ecaz card samples keep all three physical identities through saved resume', (t) => {
|
||||
const area = temporary(t);
|
||||
const out = join(area, 'ecaz-cards');
|
||||
const result = run(out, '--profile', 'ecaz-treachery', '--players', '2',
|
||||
'--rules', 'advanced', '--seed', '1000', '--max-actions', '1');
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
|
||||
const area = temporary(t);
|
||||
const out = join(area, 'skills');
|
||||
|
||||
@@ -727,6 +727,9 @@ void test('Ecaz Occupy permits its unlocked BG ally to choose fighters beside Ec
|
||||
settled(advisors);
|
||||
continue;
|
||||
}
|
||||
const unlockedAdvisors = move(g, { ...action, fighters: false });
|
||||
assert.deepEqual(p(unlockedAdvisors, 'a').advisors?.sietch_tabr, {});
|
||||
assert.deepEqual(viewGame(unlockedAdvisors, 'a'), viewGame(reload(unlockedAdvisors), 'a'));
|
||||
let done = applyAction(reload(g), 'a', action);
|
||||
assert.equal(done.response?.kind, 'advisorFlip');
|
||||
done = allow(reload(done));
|
||||
|
||||
@@ -206,6 +206,26 @@ void test('Junction cross-transport preserves BG advisor locks and forbids mergi
|
||||
homeworldGameIntegrity(g);
|
||||
});
|
||||
|
||||
void test('an unlocked Junction advisor group has identical views across JSON recovery', () => {
|
||||
let g = fixture();
|
||||
g.active = 'beneGesserit';
|
||||
g.movementRemaining = ['beneGesserit', 'atreides', 'guild'];
|
||||
const bg = player(g, 'beneGesserit');
|
||||
bg.forces = { 'arrakeen:10': 2, 'tueks_sietch:5': 1 };
|
||||
bg.reserves = 17;
|
||||
bg.advisors = { arrakeen: {}, tueks_sietch: {} };
|
||||
g = offered(g);
|
||||
const option = viewGame(g, bg.id).junctionTransport!;
|
||||
g = applyAction(g, bg.id, {
|
||||
type: 'junctionShip', event: option.event, offer: option.offer!.event,
|
||||
destination: 'tueks_sietch:5',
|
||||
sources: { 'arrakeen:10': { normal: 1, elite: 0 } },
|
||||
});
|
||||
assert.deepEqual(player(g, bg.id).advisors!.tueks_sietch, {});
|
||||
assert.deepEqual(viewGame(g, bg.id), viewGame(JSON.parse(JSON.stringify(g)) as Game, bg.id));
|
||||
homeworldGameIntegrity(g);
|
||||
});
|
||||
|
||||
void test('Junction distinguishes native Fremen Arrakis arrival from a foreign Homeworld departure for BG and technology', () => {
|
||||
let native = fixture(true);
|
||||
native.active = 'fremen';
|
||||
|
||||
+19
-9
@@ -14,6 +14,7 @@ import {
|
||||
initializeHomeworldGameForAudit,
|
||||
initializeNexusGameForAudit,
|
||||
initializePairedNexusGameForAudit,
|
||||
initializeEcazTreacheryGameForAudit,
|
||||
initializeCombinedNexusGameForAudit,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
joinGame,
|
||||
@@ -30,7 +31,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' | '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' | '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 = {
|
||||
@@ -145,6 +146,12 @@ const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [
|
||||
})),
|
||||
),
|
||||
);
|
||||
/** Same genuine paired roster, with the independent three-card variant enabled. */
|
||||
const ECAZ_TREACHERY_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
|
||||
.filter(scenario => scenario.profile === 'ecaz-roster')
|
||||
.map(scenario => ({
|
||||
...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58,
|
||||
}));
|
||||
const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
|
||||
.map(scenario => ({
|
||||
...scenario,
|
||||
@@ -236,10 +243,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|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|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 and skill profiles default to their 2–6-player samples. --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 and skill profiles default to their 2–6-player samples. --players requires a supported profile. Output must be a new private directory outside the checkout.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,15 +278,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 === '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 === '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', '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, 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', '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, 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';
|
||||
}
|
||||
|
||||
@@ -331,6 +338,8 @@ function freshGame(scenario: Scenario) {
|
||||
return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus');
|
||||
if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus')
|
||||
return initializePairedNexusGameForAudit(game);
|
||||
if (scenario.profile === 'ecaz-treachery')
|
||||
return initializeEcazTreacheryGameForAudit(game);
|
||||
return skillProfile(scenario.profile)
|
||||
? initializeLeaderSkillsGameForAudit(game)
|
||||
: scenario.profile === 'homeworld'
|
||||
@@ -381,7 +390,6 @@ function resumedGame(path: string) {
|
||||
game.greatMaker ||
|
||||
game.techTokens ||
|
||||
game.strongholdCards ||
|
||||
game.ecazTreachery ||
|
||||
game.mentatQuestionPreview ||
|
||||
game.moritaniAssassinatePreview ||
|
||||
game.moritaniAssassinate ||
|
||||
@@ -389,11 +397,12 @@ function resumedGame(path: string) {
|
||||
game.moritaniAssassinateCallEvents
|
||||
)
|
||||
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, ...PAIRED_NEXUS_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_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 === 'ecaz-treachery') === !!game.ecazTreachery &&
|
||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||
JSON.stringify(candidate.expansions) ===
|
||||
JSON.stringify(game.expansions) &&
|
||||
@@ -586,7 +595,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', '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', '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.`);
|
||||
@@ -596,6 +605,7 @@ async function main() {
|
||||
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 === '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
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import { leaders, treacheryDeck } from '../game/cards';
|
||||
import type { Game } from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
@@ -12,6 +13,7 @@ export function sampleInventory(game: Game) {
|
||||
leaderSkills: !!game.leaderSkills,
|
||||
cards: [
|
||||
...treacheryDeck(game.expansions),
|
||||
...(game.ecazTreachery ? ecazTreacheryCards() : []),
|
||||
...(game.players.some((player) => player.faction === 'richese')
|
||||
? richeseCards()
|
||||
: []),
|
||||
|
||||
Reference in new issue
Block a user