Compose classic Nexus with original Discovery modules

This commit is contained in:
Quintine committed 2026-10-05 21:23:02 +11:00
1 parent 4672b97417
commit a2a92bb408
18 files changed
+1458 -28

No files matched your search

@@ -0,0 +1,292 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { emperorNexusModeSupported } from '../game/nexus-emperor-secret-ally';
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
import { ownedTech } from '../game/tech-tokens';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
import {
advanceDiscoveryClassicNexusEffects, advanceDiscoveryClassicNexusEffectsToPhase,
createDiscoveryClassicNexusEffectsFixture, discoveryClassicNexusEffectsInventory,
discoveryClassicNexusEffectsPlayer, finishDiscoveryClassicNexusEffectsInteraction,
finishDiscoveryClassicNexusNestedBattle, initializeDiscoveryClassicNexusEffectsSetup,
openDiscoveryClassicNexusNestedBattle, payDiscoveryClassicNexusEffectsFixture,
type DiscoveryClassicNexusEffectsFixture,
} from './fixture-discovery-classic-nexus-effects';
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected declarations retain original physical cards, counters and payment ledgers');
}
function legalPolicies(game: Game, actor: string): void {
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length > 0, `${difficulty} must expose an original legal continuation`);
for (const action of actions) discoveryClassicNexusEffectsInventory(applyAction(structuredClone(game), actor, action));
}
}
function sourceTransitions(fixture: DiscoveryClassicNexusEffectsFixture): void {
const revealer = fixture.kind === 'guild-shipment' ? fixture.opponent : fixture.actor;
const beforeToken = fixture.beforeReveal.discoveries!.tokens.find(token => token.id === fixture.token)!;
const afterToken = fixture.afterReveal.discoveries!.tokens.find(token => token.id === fixture.token)!;
assert.equal(beforeToken.face, 'shrine'); assert.equal(beforeToken.revealedTurn, null);
assert.equal(afterToken.revealedTurn, 1);
assert.equal(fixture.beforeEntry.turn, 2); assert.equal(fixture.beforeEntry.phase, 0);
const before = discoveryClassicNexusEffectsPlayer(fixture.beforeEntry, revealer);
const after = discoveryClassicNexusEffectsPlayer(fixture.afterEntry, revealer);
assert.equal(before.forces[fixture.source], 2); assert.equal(after.forces[fixture.source] ?? 0, 0);
assert.equal(after.forces['shrine:0'], 2);
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
assert.equal(after.spice, before.spice); assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
assert.deepEqual(fixture.afterEntry.techTokens, fixture.beforeEntry.techTokens, 'Free next-turn entry does not trigger paid shipping industry');
assert.equal(fixture.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
assert.equal(fixture.beforeClosingDraw.players.find(player => player.id === fixture.actor)!.ally, null);
assert.equal(fixture.afterClosingDraw.nexusCards!.cards!.hands[fixture.actor],
fixture.kind === 'guild-shipment' ? 'guild' : fixture.kind === 'fremen-revival' ? 'fremen' : 'emperor');
discoveryClassicNexusEffectsInventory(fixture.game);
}
function unchangedCustody(before: Game, after: Game): void {
assert.deepEqual(after.discoveries, before.discoveries);
assert.deepEqual(after.strongholdCards, before.strongholdCards);
for (const player of before.players) assert.deepEqual(viewGame(after, player.id).discoveries, viewGame(before, player.id).discoveries);
discoveryClassicNexusEffectsInventory(after);
}
const configurations = [
{ name: 'Basic', advanced: false, tech: false, strongholds: false },
{ name: 'Basic Tech', advanced: false, tech: true, strongholds: false },
{ name: 'Advanced', advanced: true, tech: false, strongholds: false },
{ name: 'Advanced Tech', advanced: true, tech: true, strongholds: false },
{ name: 'Advanced Strongholds', advanced: true, tech: false, strongholds: true },
{ name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true },
] as const;
for (const configuration of configurations) for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${configuration.name} classic Discovery/Nexus ${kind} uses the real source, separate quota and once-only phase-end industry`, () => {
const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind });
sourceTransitions(fixture);
if (configuration.strongholds) assert.equal(fixture.afterEntry.strongholdCards!.owners.arrakeen, fixture.actor);
const privateView = viewGame(fixture.game, fixture.actor);
const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView, 0);
assert.deepEqual(control, fixture.paymentAction, 'Existing human controls bind the actual private physical card offer');
assert.equal(kind === 'emperor-revival' ? viewGame(fixture.game, fixture.opponent).nexusEmperorSecretAlly
: viewGame(fixture.game, fixture.opponent).nexusFremenRevival, null);
legalPolicies(fixture.game, fixture.actor);
rejected(fixture.game, fixture.opponent, fixture.paymentAction);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, event: 'stale' });
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, bonus: true });
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
let paid = payDiscoveryClassicNexusEffectsFixture(fixture, structuredClone(fixture.game));
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(fixture.game));
assert.equal(after.revived, kind === 'fremen-revival' ? 3 : before.revived);
assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : before.freeForcesRevived ?? 0);
assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen-revival' ? 0 : 3);
assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null);
const card = kind === 'emperor-revival' ? 'emperor' : 'fremen';
assert.equal(paid.nexusCards!.cards!.discard.filter(value => value === card).length, 1);
const history = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.actor);
assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4);
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
assert.equal(history[0].before.spice, history[0].after.spice);
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
if (kind === 'emperor-revival') {
const quote = forceRevivalQuote(paid, after, 3);
assert.equal(quote.cost, 2);
paid = finishDiscoveryClassicNexusEffectsInteraction(applyAction(paid, fixture.actor, { type: 'revive', amount: 3 }));
const ordinary = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(ordinary.reserves, before.reserves + 6); assert.equal(ordinary.tanks, before.tanks - 6);
assert.equal(ordinary.revived, 3); assert.equal(ordinary.freeForcesRevived, 2);
assert.equal(ordinary.spice, before.spice - 2);
} else rejected(paid, fixture.actor, { type: 'revive', amount: 1 });
if (configuration.tech) {
assert.equal(paid.techTokens!.axlotl.spice, fixture.tokenCount);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2);
}
const expected = wallets(paid);
if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount;
const movement = advanceDiscoveryClassicNexusEffectsToPhase(paid, 5);
assert.deepEqual(wallets(movement), expected, 'Only real Revival-end Axlotl credit becomes spendable');
if (configuration.tech) assert.equal(movement.techTokens!.axlotl.spice, 0);
const completedMovement = advanceDiscoveryClassicNexusEffectsToPhase(movement, 6);
const afterMovement = { ...expected };
if (completedMovement.phase >= 7)
for (const receipt of quoteSpiceCollection(movement).receipts) afterMovement[receipt.player] += receipt.strongholds + receipt.collected;
assert.deepEqual(wallets(completedMovement), afterMovement, 'Only independently quoted Collection can accompany an auto-skipped empty Battle; Axlotl cannot pay twice');
const nextTurn = advanceDiscoveryClassicNexusEffects(completedMovement, game => game.turn === 3);
const nextRevival = advanceDiscoveryClassicNexusEffectsToPhase(nextTurn, 4);
assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).revived, 0);
assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).freeForcesRevived ?? 0, 0);
assert.deepEqual(kind === 'emperor-revival' ? nextRevival.nexusEmperorSecretHistory : nextRevival.nexusFremenRevivalHistory, history);
rejected(nextRevival, fixture.actor, fixture.paymentAction);
discoveryClassicNexusEffectsInventory(nextRevival);
});
}
void test('Advanced Discovery Emperor extra-three and Fremen free-three retain actual native elite subsets and the shared one-elite cap', () => {
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const fixture = createDiscoveryClassicNexusEffectsFixture({ kind, advanced: true, tech: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(fixture.game, fixture.actor);
assert.deepEqual(kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival.eliteOptions : view.nexusFremenRevival!.eliteOptions, [0, 1]);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, elite: 2 });
legalPolicies(fixture.game, fixture.actor);
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(fixture.game));
rejected(paid, fixture.actor, { type: 'revive', amount: 1, elite: 1 });
unchangedCustody(fixture.game, paid);
}
});
void test('Discovery ordinary-first Emperor return is extra; ordinary-first Fremen is genuinely late and still rejected', () => {
const emperor = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
const ordinary = finishDiscoveryClassicNexusEffectsInteraction(applyAction(emperor.game, emperor.actor, { type: 'revive', amount: 3 }));
const paid = payDiscoveryClassicNexusEffectsFixture(emperor, ordinary);
const before = discoveryClassicNexusEffectsPlayer(ordinary, emperor.actor), after = discoveryClassicNexusEffectsPlayer(paid, emperor.actor);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
assert.deepEqual(paid.techTokens, ordinary.techTokens);
const fremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', advanced: true, tech: true });
const late = finishDiscoveryClassicNexusEffectsInteraction(applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }));
assert.match(viewGame(late, fremen.actor).nexusFremenRevival!.blocked ?? '', /before ordinary force revivals/);
rejected(late, fremen.actor, fremen.paymentAction);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createDiscoveryClassicNexusEffectsFixture({ kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
assert.equal(discoveryClassicNexusEffectsPlayer(short.game, short.actor).tanks, 2);
}
});
void test('an earned Discovery/Nexus Axlotl pile cannot settle through an unsettled real printed Truthtrance', () => {
const fixture = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
let game = payDiscoveryClassicNexusEffectsFixture(fixture);
const card = holdNexusSkillsModulesPaymentsCard(game, fixture.opponent, 'truthtrance', fixture.staging);
const before = structuredClone(game);
game = applyAction(game, fixture.opponent, { type: 'card', card: card.id });
assert.ok(game.truthtrance); assert.equal(game.phase, 4);
assert.deepEqual(wallets(game), wallets(before)); assert.equal(game.techTokens!.axlotl.spice, fixture.tokenCount);
for (const player of game.players) rejected(game, player.id, { type: 'ready' });
legalPolicies(game, game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id);
const settled = finishDiscoveryClassicNexusEffectsInteraction(game);
assert.equal(settled.truthtrance ?? null, null); assert.equal(settled.phase, 4);
assert.deepEqual(wallets(settled), wallets(before)); assert.equal(settled.techTokens!.axlotl.spice, fixture.tokenCount);
assert.ok(settled.discard.some(value => value.id === card.id && value.effect === 'truthtrance'));
discoveryClassicNexusEffectsInventory(settled);
});
for (const configuration of [configurations[0], configurations[1], configurations[4], configurations[5]]) {
void test(`${configuration.name} paid Guild Secret Ally nested Shrine arrival retains invoice/source identity and original battle reward once`, () => {
const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind: 'guild-shipment' });
sourceTransitions(fixture); legalPolicies(fixture.game, fixture.actor);
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
const quote = quoteNexusGuildSecretShipment(territory('shrine').type, 3);
assert.equal(quote.cost, 2, 'A nested revealed location uses the printed stronghold shipping tariff, not a desert or native-free tariff');
rejected(fixture.game, fixture.opponent, fixture.paymentAction);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, nexus: 'stale' });
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, smuggler: true });
const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture));
const arrived = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(arrived.spice, before.spice - quote.cost);
assert.equal(arrived.reserves, before.reserves - 3); assert.equal(arrived.forces['shrine:0'], 3);
assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved);
for (const player of paid.players) if (player.id !== fixture.actor) assert.equal(player.spice, discoveryClassicNexusEffectsPlayer(fixture.game, player.id).spice);
assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.equal(paid.nexusGuildSecretHistory!.length, 1);
const record = paid.nexusGuildSecretHistory![0], frame = JSON.parse(record.frame);
assert.equal(record.receipt.owner, fixture.actor); assert.equal(record.receipt.turn, 2); assert.equal(record.kind, 'reserve');
assert.equal(frame.player, fixture.actor); assert.equal(frame.territory, 'shrine'); assert.equal(frame.sector, 0);
assert.equal(frame.amount, 3); assert.equal(frame.elite, 0); assert.equal(frame.cost, quote.cost); assert.equal(frame.allyPayment, 0);
assert.equal(frame.guildSecretEvent, fixture.paymentAction.nexus);
assert.equal(frame.nexusEvent, undefined); assert.equal(frame.guildNexusEvent, undefined); assert.equal(frame.source, undefined);
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
if (configuration.tech) {
assert.equal(paid.techTokens!.heighliners.spice, fixture.tokenCount);
assert.equal(paid.techTokens!.heighliners.triggeredTurn, 2);
}
const expected = wallets(paid);
if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount;
const battle = openDiscoveryClassicNexusNestedBattle(fixture, paid);
assert.deepEqual(wallets(battle.beforeBattle), expected, 'The original Movement-end industry pays once before the actual nested battle');
if (configuration.tech) assert.equal(battle.beforeBattle.techTokens!.heighliners.spice, 0);
assert.equal(battle.game.battle!.territory, 'shrine');
const loser = discoveryClassicNexusEffectsPlayer(battle.game, fixture.opponent);
const bounty = loser.leaders.find(leader => leader.id === battle.losingLeader)!.strength;
const priorTokenOwners = battle.game.techTokens && Object.fromEntries(Object.entries(battle.game.techTokens).map(([id, token]) => [id, token.owner]));
const collection = quoteSpiceCollection(battle.beforeBattle);
const done = finishDiscoveryClassicNexusNestedBattle(battle);
assert.equal(done.lastBattleContext!.winner, fixture.actor);
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.actor).forces['shrine:0'], 3);
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).forces['shrine:0'] ?? 0, 0);
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).tanks, loser.tanks + 2);
const battleWallets = { ...expected };
battleWallets[fixture.actor] += bounty;
if (done.phase >= 7)
for (const receipt of collection.receipts) battleWallets[receipt.player] += receipt.strongholds + receipt.collected;
assert.deepEqual(wallets(done), battleWallets, 'Only the original leader bounty and independently quoted native Collection may accompany battle completion');
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).leaders.find(leader => leader.id === battle.losingLeader)!.dead, true);
assert.ok(discoveryClassicNexusEffectsPlayer(done, fixture.actor).hand.some(card => card.id === battle.weapon.id));
assert.ok(!done.discard.some(card => card.id === battle.weapon.id));
assert.deepEqual(done.nexusGuildSecretHistory, paid.nexusGuildSecretHistory);
assert.deepEqual(done.discoveries, paid.discoveries); assert.deepEqual(done.strongholdCards, paid.strongholdCards);
if (configuration.tech) {
assert.equal(ownedTech(done.techTokens, fixture.opponent).length, 0);
for (const [id, owner] of Object.entries(priorTokenOwners!))
assert.equal(done.techTokens![id as keyof NonNullable<Game['techTokens']>].owner, owner === fixture.opponent ? fixture.actor : owner);
}
discoveryClassicNexusEffectsInventory(done);
const atMentat = wallets(done);
if (done.phase < 7)
for (const receipt of collection.receipts) atMentat[receipt.player] += receipt.strongholds + receipt.collected;
const mentat = advanceDiscoveryClassicNexusEffectsToPhase(done, 8);
assert.deepEqual(wallets(mentat), atMentat, 'Only original city Collection pays; Shrine cannot recollect shipment industry or leader bounty');
assert.deepEqual(mentat.nexusGuildSecretHistory, paid.nexusGuildSecretHistory);
discoveryClassicNexusEffectsInventory(mentat);
});
}
void test('authenticated native Nexus/Discovery setup preserves original seat IDs/dealt custody and excludes unrelated Emperor module profiles', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true,
seatIds: ['authenticated-owner', 'authenticated-opponent', 'authenticated-third'] });
const saved = structuredClone(initial);
const accepted = initializeDiscoveryClassicNexusEffectsSetup({ initial });
assert.deepEqual(accepted, saved);
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
assert.equal(fixture.actor, 'authenticated-owner'); assert.deepEqual(fixture.initial, saved);
assert.deepEqual(fixture.game.players.map(player => player.id), initial.players.map(player => player.id));
assert.deepEqual(initial, saved);
assert.equal(emperorNexusModeSupported(fixture.game), true);
// Explicit adversarial compositions, not histories claimed to be natural play.
for (const change of [
(game: Game) => { game.expansions = ['ix']; },
(game: Game) => { game.players.find(player => player.id === fixture.opponent)!.faction = 'emperor'; },
(game: Game) => { game.advanced = false; },
(game: Game) => { game.semutaPreview = true; },
]) {
const invalid = structuredClone(fixture.game); change(invalid);
assert.equal(emperorNexusModeSupported(invalid), false);
rejected(invalid, fixture.actor, fixture.paymentAction);
}
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
const tampered = structuredClone(paid); tampered.nexusEmperorSecretHistory![0].after.revived++;
rejected(tampered, fixture.actor, { type: 'ready' });
discoveryClassicNexusEffectsInventory(paid);
});
@@ -0,0 +1,373 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { territory } from '../game/board';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { validateNexusCards } from '../game/nexus-cards';
import { validateDiscoveryState } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { quoteEcazInquiry } from '../game/nexus-ecaz-inquiry';
import { nexusCleanPlayBlocked } from '../game/nexus-play-boundary';
import { strongholdControllers } from '../game/stronghold-cards';
import {
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
classicDiscoveryNexusPlayer, createClassicDiscoveryNexusFixture, enterClassicDiscoveryNexus,
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance, nextClassicDiscoveryNexusStep,
openClassicDiscoveryNexusAlliance, openClassicDiscoveryNexusEncounter, revealClassicDiscoveryNexus,
voteClassicDiscoveryNexus, type ClassicDiscoveryNexusFixture,
} from './fixture-discovery-classic-nexus';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function custody(game: Game): void {
validateNexusCards(game.nexusCards!.cards!, game.players);
validateDiscoveryState(game.discoveries!);
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort(), baseDeck().map(card => card.id).sort());
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), player.faction === 'emperor' ? 5 : 3);
}
assert.ok(!(game.discoveryEntry && game.nexusCards!.phase?.stage === 'drawing'));
assert.ok(!(game.greatMaker?.stage === 'vote' && game.nexusCards!.phase?.stage === 'drawing'));
}
function rejectUnchanged(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Real majority, optional reserve-ride decline, real negotiated alliance, then
* the single source-closing deal. Only the remaining physical Nexus deck order
* is positioned before that deal; no card is transferred into a hand. */
function closingDraw(fixture: ClassicDiscoveryNexusFixture): Game {
let game = openClassicDiscoveryNexusEncounter(fixture);
game = voteClassicDiscoveryNexus(game, game.players.map(() => true));
game = openClassicDiscoveryNexusAlliance(game);
assert.ok(fixture.fremen);
game = formClassicDiscoveryNexusAlliance(game, fixture.opponent, fixture.fremen);
const deck = game.nexusCards!.cards!.deck;
const index = deck.indexOf('ecaz'); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
assert.equal(game.phase, 1);
assert.equal(game.nexus, false);
assert.equal(game.discoveryEntry, undefined);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.nexusCards!.phase!.eligible, [fixture.collector]);
assert.deepEqual(viewGame(game, fixture.opponent).nexusCards!.choices, []);
const draw: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
const before = structuredClone(game), owner = classicDiscoveryNexusPlayer(game, fixture.collector);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector, action: draw });
assert.equal(game.phase, 2, 'Completing the original closing draw must release the next phase.');
assert.equal(game.nexusCards!.phase!.stage, 'complete');
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).spice, owner.spice);
assert.equal(game.nexusCards!.cards!.deck.length, before.nexusCards!.cards!.deck.length - 1);
assert.deepEqual(game.wormRides, []);
rejectUnchanged(game, fixture.collector, draw);
custody(game);
return game;
}
function secondCollection(fixture: ClassicDiscoveryNexusFixture, game: Game): Game {
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine' && token.status === 'placed');
assert.ok(shrine, 'The explicit original second Hiereg supply lottery must place Shrine.');
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && state.active === fixture.collector && classicDiscoveryNexusClean(state));
const sector = territory(shrine.territory!).sectors.find(value => value !== game.storm);
assert.notEqual(sector, undefined);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector,
action: { type: 'ship', territory: shrine.territory!, sector: sector!, amount: 1, elite: 0, allyPayment: 0 } });
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state));
return revealClassicDiscoveryNexus(game, fixture.collector, shrine.id);
}
void test('original staged Nexus/Discovery is playable from Basic and Advanced two-through-six seats through Mentat and free entry', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const fixture = createClassicDiscoveryNexusFixture({ advanced, seats, tech: seats >= 3, strongholds: advanced && seats >= 3 });
assert.equal(fixture.setup.status, 'setup');
assert.equal(fixture.setup.players.length, seats);
assert.equal(fixture.setup.spiceDeck.length, 28);
assert.equal(fixture.setup.discoveries!.tokens.length, 8);
custody(fixture.setup);
assert.equal(fixture.collection.turn, 1);
assert.equal(classicDiscoveryNexusPlayer(fixture.collection, fixture.collector).forces[`${fixture.parent}:${fixture.parentSector}`], 3);
assert.equal(fixture.firstMentat.before.phase, 8);
assert.equal(fixture.firstMentat.after.turn, 2);
assert.equal(fixture.game.phase, 0);
assert.equal(fixture.game.discoveryEntry?.stage, 'choose');
assert.equal(fixture.game.storm, fixture.firstMentat.before.storm, 'Free entry precedes original Storm movement.');
assert.equal(fixture.game.nexusCards!.phase?.stage, 'complete');
if (fixture.game.strongholdCards) {
assert.equal(fixture.firstMentat.before.strongholdCards!.claimedTurn, 0);
assert.equal(fixture.game.strongholdCards.claimedTurn, 1);
assert.deepEqual(fixture.game.strongholdCards.owners, strongholdControllers(fixture.game.players, false));
assert.equal(fixture.game.strongholdCards.owners.tueks_sietch, fixture.collector);
assert.equal(fixture.game.strongholdCards.owners.sietch_tabr, fixture.fremen);
}
const before = classicDiscoveryNexusPlayer(fixture.game, fixture.collector);
const entered = enterClassicDiscoveryNexus(reload(fixture.game), fixture.collector);
const after = classicDiscoveryNexusPlayer(entered, fixture.collector);
assert.equal(after.forces['cistern:0'], 1);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`], 2);
assert.equal(after.spice, before.spice);
assert.equal(after.shipped, before.shipped);
assert.equal(after.moved, before.moved);
assert.equal(entered.discoveryEntry, undefined);
const encounter = advanceClassicDiscoveryNexus(entered, game => game.greatMaker?.stage === 'vote');
assert.equal(encounter.turn, 2);
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.opponent).forces[fixture.wormSource], undefined);
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.opponent).tanks, 3);
assert.equal(encounter.spice[fixture.wormSource], undefined);
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.collector).forces['cistern:0'], 1);
assert.deepEqual(encounter.wormRides, []);
custody(encounter);
const refused = voteClassicDiscoveryNexus(encounter, encounter.players.map(() => false));
const mentat = finishClassicDiscoveryNexusMentat(refused);
assert.equal(mentat.after.turn, 3);
assert.equal(mentat.after.nexusCards!.phase?.stage, 'complete');
assert.equal(mentat.after.discoveryEntry, undefined, 'An exhausted one-time entry cannot reopen without a newly revealed location.');
custody(mentat.after);
}
});
void test('fresh authenticated Game and seat IDs survive original setup, private cards and first free entry without mutating the caller', () => {
const fixture = createClassicDiscoveryNexusFixture({ gameId: 'AUTHENTICATEDNEXUS', seatIds: ['auth-guild', 'auth-emperor', 'auth-fremen'], advanced: true });
for (const initial of [fixture.initial, fixture.setup]) {
const before = structuredClone(initial);
const continued = createClassicDiscoveryNexusFixture({ initial });
assert.deepEqual(initial, before);
assert.equal(continued.game.code, initial.code);
assert.deepEqual(continued.game.players.map(player => player.id), initial.players.map(player => player.id));
assert.equal(continued.collector, 'auth-guild');
assert.equal(continued.opponent, 'auth-emperor');
assert.equal(continued.game.decision?.kind, 'discoveryEntry');
custody(continued.game);
}
rejectUnchanged(fixture.initial, fixture.initial.host, { type: 'start' });
assert.throws(() => createClassicDiscoveryNexusFixture({ initial: fixture.game }));
});
void test('Great Maker ordinary worm prefix precedes every vote; strict majority waits for all owners, and a tie adds no Nexus', () => {
const fixture = createClassicDiscoveryNexusFixture({ seats: 4 });
const original = openClassicDiscoveryNexusEncounter(fixture);
assert.equal(original.greatMaker?.territory, 'hagga_basin');
assert.equal(original.greatMaker?.stage, 'vote');
assert.equal(classicDiscoveryNexusPlayer(original, fixture.opponent).tanks, 3);
assert.equal(original.nexus, false);
assert.ok(nexusCleanPlayBlocked(original));
rejectUnchanged(original, original.order[1], { type: 'decision', event: original.greatMaker!.event, yes: true });
let majority = voteClassicDiscoveryNexus(reload(original), [true, true, true]);
assert.equal(majority.nexus, false);
assert.equal(majority.decision?.player, majority.order[3]);
majority = voteClassicDiscoveryNexus(majority, [false]);
assert.equal(majority.nexus, true);
assert.equal(majority.nexusCards!.phase!.occurred, true);
assert.equal(majority.decision?.kind, 'greatMakerRide');
const tied = voteClassicDiscoveryNexus(reload(original), [true, false, true, false]);
assert.equal(tied.nexus, false);
assert.equal(tied.nexusCards!.phase!.occurred, false);
const afterTie = finishClassicDiscoveryNexusMentat(tied).after;
assert.equal(afterTie.turn, 3);
assert.equal(afterTie.nexusCards!.phase!.eligible.length, 0);
custody(afterTie);
});
void test('a preceding original Shai-Hulud and tied Great Maker produce one negotiated Nexus and one closing deal, not duplicated rides or payments', () => {
const fixture = createClassicDiscoveryNexusFixture({ advanced: true, seats: 4, ordinaryWormFirst: true });
let game = openClassicDiscoveryNexusEncounter(fixture);
assert.equal(game.nexus, true);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).tanks, 3);
const wallets = game.players.map(player => player.spice);
game = voteClassicDiscoveryNexus(game, [true, false, true, false]);
assert.equal(game.nexus, true);
game = openClassicDiscoveryNexusAlliance(game);
game = formClassicDiscoveryNexusAlliance(game, fixture.opponent, fixture.fremen!);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
const action: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector, action });
rejectUnchanged(game, fixture.collector, action);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 2 && classicDiscoveryNexusClean(state));
assert.equal(game.phase, 2);
assert.deepEqual(game.players.map(player => player.spice), wallets);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).tanks, 3);
assert.deepEqual(game.wormRides, []);
rejectUnchanged(game, fixture.collector, action);
custody(game);
});
void test('Fremen Great Maker reserve ride transfers real normal/starred counters into a separately revealed nested destination without spending shipment, movement or spice', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoveryNexusFixture({ advanced });
let game = openClassicDiscoveryNexusEncounter(fixture, false);
game = voteClassicDiscoveryNexus(game, game.players.map(() => false));
assert.equal(game.decision?.kind, 'greatMakerRide');
const fremen = classicDiscoveryNexusPlayer(game, fixture.fremen!);
const elite = advanced ? 1 : 0;
assert.ok((fremen.elites?.reserves ?? 0) >= elite);
const ride = greatMakerRideAction(viewGame(game, fremen.id), 'cistern', 0, 2, elite);
assert.ok(ride, 'A revealed separate location is a real Great Maker reserve destination.');
const event = game.greatMaker!.event;
game = applyClassicDiscoveryNexusStep(game, { actor: fremen.id, action: ride });
const after = classicDiscoveryNexusPlayer(game, fremen.id);
assert.equal(after.reserves, fremen.reserves - 2);
assert.equal(after.forces['cistern:0'], 2);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, elite);
assert.equal(after.elites?.reserves ?? 0, (fremen.elites?.reserves ?? 0) - elite);
assert.equal(after.spice, fremen.spice);
assert.equal(after.shipped, fremen.shipped);
assert.equal(after.moved, fremen.moved);
assert.deepEqual(Object.fromEntries(Object.entries(after.forces).filter(([key]) => key !== 'cistern:0')), fremen.forces);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.wormRides, []);
assert.notEqual(game.response?.kind, 'wormSurvival');
rejectUnchanged(game, fremen.id, { ...ride, event });
const moved = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && state.active === fremen.id && classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(moved, fremen.id).shipped, false);
custody(moved);
}
});
void test('actual closing Nexus draw, end-Mentat settlement and next free-entry owner are sequential; held no-phase Nexus cannot interrupt that owner', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoveryNexusFixture({ advanced, strongholds: advanced });
let game = secondCollection(fixture, closingDraw(fixture));
const mentat = finishClassicDiscoveryNexusMentat(game);
game = mentat.after;
assert.equal(game.turn, 3);
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.nexusCards!.phase!.stage, 'complete');
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
if (advanced) assert.equal(game.strongholdCards!.claimedTurn, 2);
const blocked = quoteEcazInquiry(game, fixture.collector); assert.ok(blocked?.blocked);
rejectUnchanged(game, fixture.collector, { type: 'nexusEcazInquiry', event: blocked.event, target: fixture.opponent });
const before = classicDiscoveryNexusPlayer(game, fixture.collector);
game = enterClassicDiscoveryNexus(game, fixture.collector);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).forces['shrine:0'], 1);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).spice, before.spice);
assert.equal(game.discoveryEntry, undefined);
const offer = quoteEcazInquiry(game, fixture.collector); assert.ok(offer && !offer.blocked);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector,
action: { type: 'nexusEcazInquiry', event: offer.event, target: fixture.opponent } });
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], null);
assert.equal(game.nexusEcazInquiries!.length, 1);
const storm = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(storm, fixture.collector).forces['shrine:0'], 1);
custody(storm);
}
});
void test('additional original Advanced worm destroys the real parent army but not its nested Cistern, while owned placement blocks no-phase interruption', () => {
const fixture = createClassicDiscoveryNexusFixture({ advanced: true });
let game = secondCollection(fixture, closingDraw(fixture));
const front: Game['spiceDeck'] = [];
for (let index = 0; index < 2; index++) {
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker);
assert.ok(worm >= 0); front.push(game.spiceDeck.splice(worm, 1)[0]);
}
for (let index = 0; index < 2; index++) {
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(land >= 0); front.push(game.spiceDeck.splice(land, 1)[0]);
}
// Explicit turn-three unplayed positions: two original worms, two original
// land replacements. Native Fremen placement chooses the actual parent.
game.spiceDeck.unshift(...front);
game = finishClassicDiscoveryNexusMentat(game).after;
game = enterClassicDiscoveryNexus(game, fixture.collector);
game = advanceClassicDiscoveryNexus(game, state => state.decision?.kind === 'wormPlacement');
assert.ok(nexusCleanPlayBlocked(game));
const blocked = quoteEcazInquiry(game, fixture.collector); assert.ok(blocked?.blocked);
rejectUnchanged(game, fixture.collector, { type: 'nexusEcazInquiry', event: blocked.event, target: fixture.opponent });
const before = classicDiscoveryNexusPlayer(game, fixture.collector);
assert.equal(before.forces[`${fixture.parent}:${fixture.parentSector}`], 2);
assert.equal(before.forces['cistern:0'], 1);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.fremen!,
action: { type: 'decision', accept: true, territory: fixture.parent } });
game = advanceClassicDiscoveryNexus(game, state => !state.response && !state.decision);
const after = classicDiscoveryNexusPlayer(game, fixture.collector);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`], undefined);
assert.equal(after.tanks, before.tanks + 2);
assert.equal(after.forces['cistern:0'], 1);
assert.equal(after.forces['shrine:0'], 1);
custody(game);
});
void test('joining a later original alliance returns the real held Nexus card and removes draw eligibility before original closing controls continue', () => {
const fixture = createClassicDiscoveryNexusFixture();
let game = secondCollection(fixture, closingDraw(fixture));
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker); assert.ok(worm >= 0);
const original = game.spiceDeck.splice(worm, 1)[0];
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0);
game.spiceDeck.unshift(original, game.spiceDeck.splice(land, 1)[0]);
game = finishClassicDiscoveryNexusMentat(game).after;
game = enterClassicDiscoveryNexus(game, fixture.collector);
game = openClassicDiscoveryNexusAlliance(game);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.opponent, action: { type: 'alliance', target: null } });
game = formClassicDiscoveryNexusAlliance(game, fixture.collector, fixture.opponent);
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
assert.deepEqual(game.nexusCards!.phase!.eligible, [fixture.fremen]);
assert.deepEqual(viewGame(game, fixture.collector).nexusCards!.choices, []);
rejectUnchanged(game, fixture.collector, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 });
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
game = applyClassicDiscoveryNexusStep(game, next);
assert.equal(game.phase, 2);
custody(game);
});
void test('optional original Tech accrues after real next-turn charity and pays exactly once at native phase end', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoveryNexusFixture({ advanced, tech: true, techCharity: true });
let game = openClassicDiscoveryNexusEncounter(fixture);
game = voteClassicDiscoveryNexus(game, game.players.map(() => false));
game = advanceClassicDiscoveryNexus(game, state => state.phase === 2 && classicDiscoveryNexusClean(state));
const before = classicDiscoveryNexusPlayer(game, fixture.fremen!).spice;
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.opponent, action: { type: 'charity' } });
game = advanceClassicDiscoveryNexus(game, state => classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).spice, 2);
assert.equal(game.techTokens!.production.spice, 1);
assert.equal(game.techTokens!.production.triggeredTurn, 2);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.fremen!).spice, before);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 3 && classicDiscoveryNexusClean(state));
assert.equal(game.techTokens!.production.spice, 0);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.fremen!).spice, before + 1);
rejectUnchanged(game, fixture.opponent, { type: 'charity' });
const revival = advanceClassicDiscoveryNexus(game, state => state.phase === 4 && classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(revival, fixture.fremen!).spice, before + 1);
custody(revival);
}
});
void test('all four minimal policies have actual legal owned continuations at free entry, Great Maker vote/ride and original closing Nexus deal', () => {
const fixture = createClassicDiscoveryNexusFixture({ advanced: true });
const vote = openClassicDiscoveryNexusEncounter(fixture);
const ride = voteClassicDiscoveryNexus(reload(vote), vote.players.map(() => true));
let drawing = openClassicDiscoveryNexusAlliance(reload(ride));
drawing = formClassicDiscoveryNexusAlliance(drawing, fixture.opponent, fixture.fremen!);
drawing = advanceClassicDiscoveryNexus(drawing, game => game.nexusCards!.phase?.stage === 'drawing');
const cases = [
{ game: fixture.game, owner: fixture.collector, type: 'decision' },
{ game: vote, owner: vote.decision!.player, type: 'decision' },
{ game: ride, owner: ride.decision!.player, type: 'decision' },
{ game: drawing, owner: fixture.collector, type: 'nexusCardChoice' },
];
for (const difficulty of DIFFICULTIES) for (const current of cases) {
const view = viewGame(current.game, current.owner);
view.players.find(player => player.id === current.owner)!.bot = difficulty;
const before = structuredClone(view);
const action = botActions(view).find(action => action.type === current.type);
assert.ok(action, `${difficulty} must answer the actual owned ${current.game.decision?.kind ?? 'closing Nexus draw'}.`);
const after = applyClassicDiscoveryNexusStep(reload(current.game), { actor: current.owner, action });
assert.deepEqual(view, before);
if (current.game.decision?.kind === 'greatMakerVote')
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
else if (current.game.decision?.kind === 'greatMakerRide')
assert.equal(after.greatMaker!.stage, 'complete');
else if (current.game.decision?.kind === 'discoveryEntry')
assert.equal(after.discoveryEntry, undefined);
else assert.equal(after.nexusCards!.phase!.stage, 'complete');
custody(after);
}
});
+20 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
@@ -70,3 +70,22 @@ void test('Discovery Skills cannot replace already initialized Discovery compone
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'harkonnen'], []));
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError);
});
void test('Discovery Nexus rejects native families, used components and incompatible prerequisites before redealing', () => {
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
native.nexusCards = { cards: null, phase: null };
assert.throws(() => initializeNexusGameForAudit(native), RuleError);
const two = lobby(['guild', 'emperor'], []);
two.nexusCards = { cards: null, phase: null }; two.techTokens = createTechTokens();
assert.throws(() => initializeNexusGameForAudit(two), RuleError);
const basic = lobby(['guild', 'emperor', 'fremen'], [], false);
basic.nexusCards = { cards: null, phase: null }; basic.strongholdCards = createStrongholdCards();
assert.throws(() => initializeNexusGameForAudit(basic), RuleError);
const used = lobby(['guild', 'emperor', 'fremen'], []);
used.nexusCards = { cards: null, phase: null }; used.techTokens = createTechTokens();
used.techTokens.production.owner = used.host;
assert.throws(() => initializeNexusGameForAudit(used), RuleError);
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'fremen'], []));
started.nexusCards = { cards: null, phase: null };
assert.throws(() => initializeNexusGameForAudit(started), RuleError);
});
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Card } from '../game/cards';
import { validateDiscoveryState } from '../game/discoveries';
import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile';
import { traitorDeck } from '../game/traitors';
import { ownedTech, type TechId } from '../game/tech-tokens';
import { nexusInventory } from './fixture-nexus-cards';
import { holdNexusSkillsModulesPaymentsCard, nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments';
export type DiscoveryClassicNexusEffectKind = 'emperor-revival' | 'fremen-revival' | 'guild-shipment';
export type DiscoveryClassicNexusEffectStep = { actor: string; action: Action };
export type DiscoveryClassicNexusEffectsOptions = {
/** Authenticated original lobby or turn-one uncompleted native setup; never redealt. */
initial?: Game;
seatIds?: string[];
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
kind?: DiscoveryClassicNexusEffectKind;
ownerFaction?: FactionId;
tanks?: number;
eliteTanks?: number;
};
export type DiscoveryClassicNexusEffectsFixture = {
initial: Game;
afterSetup: Game;
beforeReveal: Game;
afterReveal: Game;
beforeEntry: Game;
afterEntry: Game;
beforeClosingDraw: Game;
closingDraw: DiscoveryClassicNexusEffectStep;
afterClosingDraw: Game;
game: Game;
actor: string;
opponent: string;
kind: DiscoveryClassicNexusEffectKind;
paymentAction: Action;
token: string;
source: string;
tokenOwner: string | null;
tokenCount: number;
industry: TechId;
actions: DiscoveryClassicNexusEffectStep[];
staging: string[];
};
export type DiscoveryClassicNexusNestedBattle = {
beforeBattle: Game;
game: Game;
actor: string;
opponent: string;
weapon: Card;
losingLeader: string;
plans: DiscoveryClassicNexusEffectStep[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
export function discoveryClassicNexusEffectsPlayer(game: Game, id: string): Player {
const player = game.players.find(player => player.id === id);
assert.ok(player, `Missing original authenticated seat ${id}`);
return player;
}
/** Labelled scalar lottery: native UUID byte-array generation is not replaced. */
function originalLottery<T>(run: () => T, scalar = 0xffffffff): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = scalar;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function nextDiscoveryClassicNexusEffectsStep(game: Game): DiscoveryClassicNexusEffectStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide' || decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection') return { actor: decision.player, action: { type: 'decision', accept: true } };
}
return nextNexusSkillsModulesPaymentsNativeStep(game);
}
export function advanceDiscoveryClassicNexusEffectsStep(game: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
const next = nextDiscoveryClassicNexusEffectsStep(game);
assert.ok(next, 'Original continuation stopped for a human private battle plan');
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advanceDiscoveryClassicNexusEffects(state: Game, predicate: (game: Game) => boolean,
actions?: DiscoveryClassicNexusEffectStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished');
game = advanceDiscoveryClassicNexusEffectsStep(game, actions);
}
throw Error('Original Discovery/Nexus continuation did not reach its boundary');
}
export function advanceDiscoveryClassicNexusEffectsToPhase(state: Game, phase: number,
actions?: DiscoveryClassicNexusEffectStep[]): Game {
const turn = state.turn;
return advanceDiscoveryClassicNexusEffects(state, game => {
assert.equal(game.turn, turn);
return game.phase >= phase && clean(game);
}, actions);
}
export function discoveryClassicNexusEffectsInventory(game: Game): void {
nexusInventory(game);
validateDiscoveryState(game.discoveries!);
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])].sort(), traitorDeck(game.players, false).sort());
for (const player of game.players) if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0),
player.faction === 'fremen' ? 3 : 5);
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
}
}
export function initializeDiscoveryClassicNexusEffectsSetup(options: DiscoveryClassicNexusEffectsOptions = {}): Game {
const kind = options.kind ?? 'emperor-revival', faction = options.ownerFaction ?? 'atreides';
const forbidden = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYCLASSICNEXUSEFFECTS', newPlayer(options.seatIds?.[0] ?? 'owner', faction, faction),
options.advanced ?? !!options.strongholds);
if (!options.initial) {
const count = options.seatIds?.length ?? 3;
assert.ok(count >= 3 && count <= 5);
for (const candidate of ['harkonnen', 'guild', 'fremen', 'atreides', 'beneGesserit', 'emperor'] as const)
if (game.players.length < count && candidate !== forbidden && candidate !== faction)
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate));
assert.equal(game.players.length, count);
}
assert.ok(game.players.some(player => player.faction === faction));
assert.ok(!game.players.some(player => player.faction === forbidden));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards;
if (game.status === 'setup') {
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
assert.ok(classicDiscoveryNexusProfile(game));
discoveryClassicNexusEffectsInventory(game);
return game;
}
assert.equal(game.status, 'lobby');
if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
assert.ok(classicDiscoveryNexusProfile(game));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return originalLottery(() => initializeNexusGameForAudit(game));
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
staging.push('Conserved all original on-board counters into their own reserves; no clock, wallet, receipt or module ownership assigned.');
}
function placeOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
const player = discoveryClassicNexusEffectsPlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
staging.push(`Conserved ${amount} original ordinary ${actor} reserve counters to ${key}.`);
}
function orderSpice(game: Game, selectors: ('discovery' | 'land' | 'worm')[], staging: string[]): void {
const front: Game['spiceDeck'] = [];
for (const selector of selectors) {
const index = game.spiceDeck.findIndex(card => selector === 'worm' ? 'worm' in card
: 'territory' in card && (selector === 'discovery' ? card.discovery === 'discovery-hagga-basin'
: !card.discovery && card.territory !== 'gara_kulon'));
assert.ok(index >= 0, 'Only original undealt physical Spice singletons may be selected');
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
staging.push(`Ordered conserved original undealt Spice Cards: ${selectors.join(', ')}; original discard untouched.`);
}
/** Original setup, actual printed Discovery blow/lottery, Collection reveal,
* next-turn entry and end-Spice-Blow Nexus/alliance draw all precede the effect. */
export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClassicNexusEffectsOptions = {}): DiscoveryClassicNexusEffectsFixture {
const initial = initializeDiscoveryClassicNexusEffectsSetup(options);
return originalLottery(() => {
const kind = options.kind ?? 'emperor-revival', actor = initial.players.find(player => player.faction === (options.ownerFaction ?? 'atreides'))!.id;
const opponent = initial.players.find(player => player.id !== actor && player.faction !== 'beneGesserit')!.id;
const actions: DiscoveryClassicNexusEffectStep[] = [], staging = ['Scalar lottery fixed at the last legal index; UUID bytes remain native. Physical Hiereg lottery selects the original Shrine token; not an untouched random sample.'];
let game = advanceDiscoveryClassicNexusEffects(initial, game => game.status === 'playing', actions);
const afterSetup = structuredClone(game);
reserveBoard(game, staging);
// The actual first Storm producers assign original Tech owners.
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions);
orderSpice(game, ['discovery', 'land'], staging);
const pool = game.discoveries!.tokens.filter(token => token.type === 'hiereg' && token.status === 'supply');
const lotteryIndex = pool.findIndex(token => token.face === 'shrine'); assert.ok(lotteryIndex >= 0);
staging.push(`Actual printed Discovery blow lottery selects index ${lotteryIndex} of ${pool.length} original supply Hiereg tokens; original token IDs/order remain unchanged.`);
game = originalLottery(() => advanceDiscoveryClassicNexusEffects(game,
state => state.discoveries!.tokens.some(token => token.face === 'shrine' && token.status === 'placed'), actions),
Math.floor((lotteryIndex + 0.5) * 0x100000000 / pool.length));
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 5, actions);
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine')!;
assert.equal(shrine.status, 'placed');
assert.equal(shrine.revealedTurn, null);
const source = `${shrine.territory}:${shrine.sector}`, revealer = kind === 'guild-shipment' ? opponent : actor;
assert.notEqual(shrine.sector, game.storm);
placeOrdinary(game, revealer, source, 2, staging);
if (game.strongholdCards) placeOrdinary(game, actor, 'arrakeen:10', 1, staging);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 7, actions);
if (viewGame(game, revealer).discoveries!.canInspect.includes(shrine.id)) {
const action: Action = { type: 'discovery', token: shrine.id, reveal: false };
actions.push({ actor: revealer, action }); game = applyAction(game, revealer, action);
}
const beforeReveal = structuredClone(game), reveal: Action = { type: 'discovery', token: shrine.id, reveal: true };
assert.ok(viewGame(game, revealer).discoveries!.canReveal.includes(shrine.id));
actions.push({ actor: revealer, action: reveal }); game = applyAction(game, revealer, reveal);
const afterReveal = structuredClone(game);
game = advanceDiscoveryClassicNexusEffects(game, game => game.decision?.kind === 'discoveryEntry', actions);
assert.ok(game.decision?.kind === 'discoveryEntry');
assert.equal(game.turn, 2); assert.equal(game.decision!.player, revealer);
const beforeEntry = structuredClone(game), entry: Action = { type: 'decision', event: game.decision!.event,
accept: true, groups: viewGame(game, revealer).discoveryEntry!.sources };
actions.push({ actor: revealer, action: entry }); game = applyAction(game, revealer, entry);
const afterEntry = structuredClone(game);
assert.equal(discoveryClassicNexusEffectsPlayer(game, revealer).forces['shrine:0'], 2);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(player => player.id !== actor);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
const action: Action = { type: 'alliance', target: to };
actions.push({ actor: from, action }); game = applyAction(game, from, action);
}
allied = true;
}
game = advanceDiscoveryClassicNexusEffectsStep(game, actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const selected = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(selected); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Ordered the original undealt ${selected} Nexus singleton before its actual end-Spice-Blow draw.`);
const beforeClosingDraw = structuredClone(game), closingDraw: DiscoveryClassicNexusEffectStep = { actor,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
const afterClosingDraw = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[actor], selected);
game = advanceDiscoveryClassicNexusEffects(game, game => game.nexusCards?.phase?.stage !== 'drawing', actions);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, kind === 'guild-shipment' ? 5 : 4, actions);
let paymentAction: Action;
if (kind === 'guild-shipment') {
game = advanceDiscoveryClassicNexusEffects(game, game => game.active === actor && clean(game), actions);
paymentAction = { type: 'ship', territory: 'shrine', sector: 0, amount: 3, elite: 0,
nexus: viewGame(game, actor).nexusGuildSecretAlly!.event };
} else {
const player = discoveryClassicNexusEffectsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks);
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
player.reserves -= tanks; player.tanks += tanks;
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${tanks} original ${actor} reserve counters to Tanks, including ${elite} elites; no return or quota receipt assigned.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
}
discoveryClassicNexusEffectsInventory(game);
const industry: TechId = kind === 'guild-shipment' ? 'heighliners' : 'axlotl', tokenOwner = game.techTokens?.[industry].owner ?? null;
return { initial, afterSetup, beforeReveal, afterReveal, beforeEntry, afterEntry, beforeClosingDraw, closingDraw,
afterClosingDraw, game, actor, opponent, kind, paymentAction, token: shrine.id, source, industry, tokenOwner,
tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, actions, staging };
});
}
export function payDiscoveryClassicNexusEffectsFixture(fixture: DiscoveryClassicNexusEffectsFixture, game = fixture.game): Game {
return applyAction(game, fixture.actor, fixture.paymentAction);
}
export function finishDiscoveryClassicNexusEffectsInteraction(state: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
return advanceDiscoveryClassicNexusEffects(state, game => clean(game) && !game.pendingTreacheryDiscard && !game.pendingKarama, actions);
}
/** Paid nested arrival is retained, then the ordinary battle producer seals real
* physical cards/plans. No battle result, ownership or reward is fabricated. */
export function openDiscoveryClassicNexusNestedBattle(fixture: DiscoveryClassicNexusEffectsFixture, state: Game): DiscoveryClassicNexusNestedBattle {
assert.equal(fixture.kind, 'guild-shipment');
let game = finishDiscoveryClassicNexusEffectsInteraction(state, fixture.actions);
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
fixture.staging.push('Conserved all original held Treachery Cards into the undealt deck before selecting the nested battle cards.');
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.actor, 'projectile', fixture.staging);
const owner = discoveryClassicNexusEffectsPlayer(game, fixture.actor), opponent = discoveryClassicNexusEffectsPlayer(game, fixture.opponent);
const leader = owner.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const losingLeader = opponent.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && losingLeader);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 6, fixture.actions);
assert.ok(game.active === owner.id || game.active === opponent.id);
const beforeBattle = structuredClone(game), choose: Action = { type: 'chooseBattle', territory: 'shrine', target: game.active === owner.id ? opponent.id : owner.id };
fixture.actions.push({ actor: game.active!, action: choose }); game = applyAction(game, game.active!, choose);
game = advanceDiscoveryClassicNexusEffects(game, game => !!game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false, fixture.actions);
const plans: DiscoveryClassicNexusEffectStep[] = [
{ actor: owner.id, action: { type: 'battlePlan', leader: leader.id, dial: 0, support: 0, weapon: weapon.id } },
{ actor: opponent.id, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } },
];
return { beforeBattle, game, actor: owner.id, opponent: opponent.id, weapon, losingLeader: losingLeader.id, plans };
}
export function finishDiscoveryClassicNexusNestedBattle(battle: DiscoveryClassicNexusNestedBattle): Game {
let game = battle.game;
for (const step of battle.plans) game = applyAction(game, step.actor, step.action);
return advanceDiscoveryClassicNexusEffects(game, game => !game.battle && clean(game) && !game.pendingTreacheryDiscard);
}
+269
View File
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile';
import { nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments';
export type ClassicDiscoveryNexusStep = { actor: string; action: Action };
export type ClassicDiscoveryNexusOptions = {
/** Authenticated fresh lobby or its undealt original staged setup; never a played save. */
initial?: Game;
gameId?: string;
seatIds?: string[];
seats?: 2 | 3 | 4 | 5 | 6;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Actual ordinary worm immediately before the Great Maker, not a Nexus flag assignment. */
ordinaryWormFirst?: boolean;
/** Actual ten-spice Emperor shipment to an empty desert makes next-turn charity relevant. */
techCharity?: boolean;
};
export type ClassicDiscoveryNexusMentat = {
before: Game; step: ClassicDiscoveryNexusStep; after: Game;
};
export type ClassicDiscoveryNexusFixture = {
initial: Game; setup: Game; collection: Game; game: Game;
firstMentat: ClassicDiscoveryNexusMentat;
collector: string; opponent: string; fremen: string | null;
token: string; parent: string; parentSector: number; wormSource: string;
actions: ClassicDiscoveryNexusStep[]; staging: string[];
};
export const classicDiscoveryNexusClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function classicDiscoveryNexusPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original classic seat ${id}`);
return player;
}
/** Explicit scalar lottery position: preserve the original shuffled arrays.
* This also selects original Storm Card 1 first, rather than assigning a clock
* or storm distance. UUID/byte-array entropy remains native. */
export function withClassicDiscoveryNexusLottery<T>(operation: () => T): T {
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** No optional spending, shipment, movement, card play, alliance or sealed
* Battle Plan is invented by this driver. Every step belongs to a live owner.
* Shared original controls retain priority over the optional Discovery choices. */
export function nextClassicDiscoveryNexusStep(game: Game): ClassicDiscoveryNexusStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection')
return { actor: decision.player, action: { type: 'decision', accept: true } };
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'discoveryDiscard') {
const card = classicDiscoveryNexusPlayer(game, decision.player).hand[0];
assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: decision.event, card: card.id } };
}
}
return nextNexusSkillsModulesPaymentsNativeStep(game);
}
export function applyClassicDiscoveryNexusStep(game: Game, step: ClassicDiscoveryNexusStep,
actions?: ClassicDiscoveryNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceClassicDiscoveryNexus(game: Game, until: (game: Game) => boolean,
actions?: ClassicDiscoveryNexusStep[]): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original lifecycle ended before its requested continuation.');
const step = nextClassicDiscoveryNexusStep(game);
assert.ok(step, 'The original continuation needs a human sealed Battle Plan.');
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
throw new Error('The original Nexus/Discovery lifecycle did not reach its owned continuation.');
}
/** Physical supply-token lottery only; never writes a token location or face. */
export function withClassicDiscoveryNexusToken<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face);
assert.ok(index >= 0, 'The requested physical token must remain in its original supply.');
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function orderSpice(game: Game, options: ClassicDiscoveryNexusOptions): void {
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(matches);
assert.ok(index >= 0, 'Each selected physical Spice Card must still be undealt.');
return game.spiceDeck.splice(index, 1)[0];
};
const front = [take(card => 'territory' in card && card.discovery === 'discovery-hagga-basin')];
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery && card.territory === 'broken_land'));
if (options.ordinaryWormFirst) front.push(take(card => 'worm' in card && !card.greatMaker));
front.push(take(card => 'worm' in card && !!card.greatMaker));
front.push(take(card => 'territory' in card && card.discovery === 'discovery-sihaya-ridge'));
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery && card.territory === 'rock_outcroppings'));
game.spiceDeck.unshift(...front);
}
export function revealClassicDiscoveryNexus(game: Game, owner: string, token: string,
actions?: ClassicDiscoveryNexusStep[]): Game {
if (viewGame(game, owner).discoveries!.canInspect.some(id => id === token))
game = applyClassicDiscoveryNexusStep(game, { actor: owner, action: { type: 'discovery', token, reveal: false } }, actions);
return applyClassicDiscoveryNexusStep(game, { actor: owner, action: { type: 'discovery', token, reveal: true } }, actions);
}
export function finishClassicDiscoveryNexusMentat(game: Game, actions?: ClassicDiscoveryNexusStep[]): ClassicDiscoveryNexusMentat {
const turn = game.turn;
game = advanceClassicDiscoveryNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state), actions);
for (let count = 0; count < 100; count++) {
const before = structuredClone(game), step = nextClassicDiscoveryNexusStep(game);
assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
}
throw new Error('The original Mentat controls failed to close the turn.');
}
export function enterClassicDiscoveryNexus(game: Game, owner: string, amount = 1,
actions?: ClassicDiscoveryNexusStep[]): Game {
assert.ok(game.decision?.kind === 'discoveryEntry');
assert.equal(game.decision.player, owner);
const view = viewGame(game, owner), source = view.discoveryEntry!.sources.find(source => source.normal >= amount);
assert.ok(source, 'Select physically available ordinary source counters.');
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]);
assert.ok(action);
return applyClassicDiscoveryNexusStep(game, { actor: owner, action }, actions);
}
/** Original base33/all12 Nexus + seven Spice Cards/eight Discovery tokens.
* All clocks, wallets, board counters and pending frames arise from native
* staged setup and actions. Only undealt cards and supply/Storm lotteries are
* positioned explicitly. The returned human window is before turn-two Storm. */
export function createClassicDiscoveryNexusFixture(options: ClassicDiscoveryNexusOptions = {}): ClassicDiscoveryNexusFixture {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions: FactionId[] = ['guild', 'emperor', 'fremen', 'atreides', 'harkonnen', 'beneGesserit'];
const count = options.seats ?? options.seatIds?.length ?? 3;
assert.ok(count >= 2 && count <= 6);
if (options.seatIds) assert.equal(options.seatIds.length, count);
const ids = options.seatIds ?? factions;
game = createGame(options.gameId ?? 'CLASSICDISCOVERYNEXUS', newPlayer(ids[0], 'Guild', 'guild'), options.advanced ?? false);
for (let seat = 1; seat < count; seat++) joinGame(game, newPlayer(ids[seat], factions[seat], factions[seat]));
}
assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery && !game.spiceBankerIncomePreview);
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
['prediction', 'traitors'].includes(game.setupStage!) && game.players.every(player => !player.hand.length && !player.traitors.length)));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const collector = game.players.find(player => player.faction === 'guild')?.id;
const opponent = game.players.find(player => player.faction === 'emperor')?.id;
assert.ok(collector && opponent, 'This original human recipe needs its Guild collector and Emperor source army.');
const actions: ClassicDiscoveryNexusStep[] = [];
if (game.status === 'lobby') {
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
}
const initial = structuredClone(game);
if (game.status === 'lobby') game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
assert.ok(game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
assert.ok(classicDiscoveryNexusProfile(game));
const setup = structuredClone(game);
game = advanceClassicDiscoveryNexus(game, state => state.status === 'playing', actions);
orderSpice(game, options);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
// Token selection must surround the real Spice Blow action, not the generic
// scalar-lottery wrapper that would otherwise own that same random draw.
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const shipped = new Set<string>();
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
for (let count = 0; game.phase === 5 && count < 200; count++) {
if (classicDiscoveryNexusClean(game) && game.active && !shipped.has(game.active) && [collector, opponent].includes(game.active)) {
const actor = game.active;
const action: Action = actor === collector
? { type: 'ship', territory: placement.territory, sector: placement.sector, amount: 3, elite: 0, allyPayment: 0 }
: { type: 'ship', territory: options.techCharity ? 'rock_outcroppings' : 'hagga_basin', sector: options.techCharity ? 13 : 12,
amount: options.techCharity ? 5 : 3, elite: 0, allyPayment: 0 };
game = applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
shipped.add(actor);
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.equal(shipped.size, 2);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
const token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
const collection = structuredClone(game);
game = revealClassicDiscoveryNexus(game, collector, token.id, actions);
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions);
game = firstMentat.after;
assert.equal(game.turn, 2);
assert.ok(game.decision?.kind === 'discoveryEntry');
assert.equal(game.decision.player, collector);
return { initial, setup, collection, firstMentat, game, collector, opponent,
fremen: game.players.find(player => player.faction === 'fremen')?.id ?? null,
token: token.id, parent: placement.territory, parentSector: placement.sector, wormSource: 'hagga_basin:12', actions,
staging: ['Conserved undealt Spice positions: turn-one Hagga Basin Discovery, optional Advanced Broken Land; turn-two optional ordinary Shai-Hulud, Great Maker, Sihaya Ridge Discovery, optional Advanced Rock Outcroppings.',
'Selected original Cistern supply-token lottery; original scalar shuffle/Storm lottery positions. No phases, wallets, armies or pending frames assigned.',
options.techCharity ? 'Native Emperor five-force/ten-spice Rock Outcroppings shipment supplies actual later charity.' : 'Native Emperor three-force Hagga Basin shipment supplies actual Great Maker worm casualties.'] };
}
/** Continue into the real encounter without silently voting, riding or entering. */
export function openClassicDiscoveryNexusEncounter(fixture: ClassicDiscoveryNexusFixture, enter = true): Game {
assert.ok(fixture.game.decision?.kind === 'discoveryEntry');
const game = enter ? enterClassicDiscoveryNexus(fixture.game, fixture.collector) : applyClassicDiscoveryNexusStep(fixture.game,
{ actor: fixture.collector, action: { type: 'decision', event: fixture.game.decision!.event, accept: false } });
return advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote');
}
export function voteClassicDiscoveryNexus(game: Game, votes: readonly boolean[], actions?: ClassicDiscoveryNexusStep[]): Game {
for (const yes of votes) {
assert.ok(game.decision?.kind === 'greatMakerVote');
game = applyClassicDiscoveryNexusStep(game, { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, yes } }, actions);
}
return game;
}
export function openClassicDiscoveryNexusAlliance(game: Game): Game {
return advanceClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state));
}
export function formClassicDiscoveryNexusAlliance(game: Game, first: string, second: string): Game {
game = applyClassicDiscoveryNexusStep(game, { actor: first, action: { type: 'alliance', target: second } });
return applyClassicDiscoveryNexusStep(game, { actor: second, action: { type: 'alliance', target: first } });
}