Compose original classic and paired Nexus Discovery modules

This commit is contained in:
Quintine committed 2026-10-06 04:06:16 +11:00
1 parent 9e8c718739
commit c8a6d0bb3c
20 files changed
+1603 -44

No files matched your search

@@ -0,0 +1,303 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateDiscoveryState } from '../game/discoveries';
import { validateNexusCards } from '../game/nexus-cards';
import { forceRevivalRemaining } from '../game/revival';
import { ownedTech } from '../game/tech-tokens';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { quoteClassicSkillsStrongholdBattle } from './fixture-classic-skills-stronghold';
import {
advanceDiscoveryClassicNexusSkills, closeDiscoveryClassicNexusSkills,
createDiscoveryClassicNexusSkillsFixture, discoveryClassicNexusSkillsClean,
discoveryClassicNexusSkillsPlayer as player, enterDiscoveryClassicNexusSkills,
finishDiscoveryClassicNexusSkillsBattle, initializeDiscoveryClassicNexusSkillsSetup,
openDiscoveryClassicNexusSkillsBattle, revealDiscoveryClassicNexusSkillsBattle,
stepDiscoveryClassicNexusSkills,
} from './fixture-discovery-classic-nexus-skills';
import {
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance, openClassicDiscoveryNexusAlliance,
} from './fixture-discovery-classic-nexus';
function inventory(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
validateDiscoveryState(game.discoveries!);
validateNexusCards(game.nexusCards!.cards!, game.players);
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
...game.leaderSkills!.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)].map(card => card.id).sort(),
baseDeck().map(card => card.id).sort());
for (const seat of game.players) {
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0), 20);
if (seat.elites) assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, amount) => sum + amount, 0),
seat.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 reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
void test('original classic all14/all12/Discovery setup trains before traitors and reaches physical next-turn entry at Basic/Advanced two through six seats', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced, seats, tech: seats >= 3, strongholds: advanced });
assert.equal(fixture.setup.status, 'setup'); assert.equal(fixture.setup.leaderSkills!.assignments.length, 0);
assert.equal(fixture.setup.spiceDeck.length, 28); assert.equal(fixture.setup.discoveries!.tokens.length, 8);
assert.ok(fixture.setup.players.every(seat => !seat.traitors.length && !seat.traitorChoices.length));
assert.equal(fixture.trained.leaderSkills!.assignments.length, seats);
const trainingSteps = fixture.actions.filter(step => step.action.type === 'leaderSkill');
assert.equal(trainingSteps.length, seats);
const lastTraining = fixture.actions.findLastIndex(step => step.action.type === 'leaderSkill');
const firstTraitor = fixture.actions.findIndex(step => step.action.type === 'traitor');
assert.ok(firstTraitor > lastTraining, 'Actual training precedes the original Traitor selection.');
assert.equal(fixture.firstMentat.before.phase, 8); assert.equal(fixture.firstMentat.after.turn, 2);
assert.equal(fixture.game.phase, 0); assert.equal(fixture.game.storm, fixture.firstMentat.before.storm);
const before = player(fixture.game, fixture.owner), entered = enterDiscoveryClassicNexusSkills(fixture), after = player(entered, fixture.owner);
assert.equal(after.forces['cistern:0'], 3); assert.equal(after.forces[fixture.source] ?? 0, 0);
assert.equal(after.reserves, before.reserves); assert.equal(after.spice, before.spice);
assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
assert.deepEqual(entered.leaderSkills, fixture.game.leaderSkills);
if (advanced) {
assert.equal(entered.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(entered.strongholdCards!.claimedTurn, 1);
}
inventory(fixture.setup); inventory(entered);
}
});
void test('authenticated original lobby and undealt human setup retain actual offered training, original hands and actors without demanding a specific skill', () => {
const lobby = createGame('HUMANCLASSICNXSKILLDISCOVERY', newPlayer('human-collector', 'Guild', 'guild'), true);
joinGame(lobby, newPlayer('human-emperor', 'Emperor', 'emperor'));
joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen'));
const before = structuredClone(lobby);
const setup = initializeDiscoveryClassicNexusSkillsSetup({ initial: lobby, tech: true, strongholds: true });
assert.deepEqual(lobby, before);
const offered = structuredClone(setup);
const fixture = createDiscoveryClassicNexusSkillsFixture({ initial: setup });
assert.deepEqual(setup, offered); assert.deepEqual(fixture.setup, offered);
assert.deepEqual(fixture.game.players.map(seat => seat.id), ['human-collector', 'human-emperor', 'human-fremen']);
for (const step of fixture.actions.filter(step => step.action.type === 'leaderSkill')) {
assert.ok(offered.leaderSkills!.offers[step.actor].cards.some(skill => skill === step.action.skill));
assert.ok(offered.players.find(seat => seat.id === step.actor)!.leaders.some(leader => leader.id === step.action.leader));
}
assert.deepEqual(fixture.setup.players.map(seat => seat.hand), offered.players.map(seat => seat.hand));
assert.equal(fixture.game.decision?.player, 'human-collector');
const closing = closeDiscoveryClassicNexusSkills(fixture);
const originalTop = closing.beforeDraw.nexusCards!.cards!.deck[0];
assert.equal(closing.game.nexusCards!.cards!.hands[fixture.owner], originalTop,
'Human closing draw consumes the actual original top card, not a demanded Nexus identity.');
assert.equal(closing.game.nexusCards!.cards!.deck.length, closing.beforeDraw.nexusCards!.cards!.deck.length - 1);
inventory(closing.game);
assert.throws(() => createDiscoveryClassicNexusSkillsFixture({ initial: fixture.game }));
});
void test('actual Great Maker losses and all votes precede both Advanced piles, settled alliance and the qualified closing draw', () => {
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true, strongholds: true });
const closing = closeDiscoveryClassicNexusSkills(fixture, 'emperor');
assert.equal(player(closing.vote, fixture.owner).tanks, 6);
assert.equal(player(closing.vote, fixture.owner).forces[fixture.wormSource], undefined);
assert.equal(player(closing.vote, fixture.owner).forces['cistern:0'], 3);
assert.equal(closing.vote.nexusCards!.cards!.hands[fixture.owner], null);
assert.notEqual(closing.vote.nexusCards!.phase?.stage, 'drawing');
assert.equal(closing.alliance.greatMaker?.stage, 'complete');
assert.equal(closing.beforeDraw.phase, 1); assert.equal(closing.beforeDraw.nexus, false);
assert.deepEqual(closing.beforeDraw.nexusCards!.phase!.eligible, [fixture.owner]);
assert.equal(closing.beforeDraw.spiceWindow, null); assert.equal(closing.beforeDraw.spiceResolution, null);
assert.ok(closing.beforeDraw.spiceDiscard.flat().some(card => 'territory' in card && card.territory === 'broken_land'));
assert.ok(closing.beforeDraw.spiceDiscard.flat().filter(card => 'territory' in card && card.territory === 'rock_outcroppings').length >= 1);
assert.equal(closing.game.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(closing.game.phase, 2); assert.equal(closing.game.nexusCards!.phase!.stage, 'complete');
assert.deepEqual(closing.game.leaderSkills, fixture.game.leaderSkills);
reject(closing.game, fixture.owner, closing.drawStep.action);
inventory(closing.vote); inventory(closing.game);
});
void test('own-faction redraw preference and later allied forfeiture use cards supplied by genuine closing draws only', () => {
const fixture = createDiscoveryClassicNexusSkillsFixture();
const closing = closeDiscoveryClassicNexusSkills(fixture, 'atreides');
const before = structuredClone(closing.beforeDraw), cards = before.nexusCards!.cards!;
const index = cards.deck.indexOf('emperor'); assert.ok(index > 0);
cards.deck.splice(1, 0, cards.deck.splice(index, 1)[0]);
const redraw = stepDiscoveryClassicNexusSkills(before, { actor: fixture.owner,
action: { ...closing.drawStep.action, ownRedraws: 1 } });
assert.equal(redraw.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(redraw.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
assert.equal(redraw.nexusCards!.cards!.deck.length, before.nexusCards!.cards!.deck.length - 2);
inventory(redraw);
let game = finishClassicDiscoveryNexusMentat(closing.game).after;
game = advanceDiscoveryClassicNexusSkills(game, game => game.phase === 1 && discoveryClassicNexusSkillsClean(game));
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker); assert.ok(wormIndex >= 0);
const worm = game.spiceDeck.splice(wormIndex, 1)[0];
const landIndex = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(landIndex >= 0);
game.spiceDeck.unshift(worm, game.spiceDeck.splice(landIndex, 1)[0]);
game = openClassicDiscoveryNexusAlliance(game);
const heldWindow = structuredClone(game);
const keep = advanceDiscoveryClassicNexusSkills(heldWindow, game => game.nexusCards?.phase?.stage === 'drawing');
const kept = stepDiscoveryClassicNexusSkills(keep, { actor: fixture.owner, action: {
type: 'nexusCardChoice', turn: keep.turn, card: 'atreides', choice: 'keep', ownRedraws: 0 } });
assert.equal(kept.nexusCards!.cards!.hands[fixture.owner], 'atreides');
assert.deepEqual(kept.nexusCards!.cards!.deck, keep.nexusCards!.cards!.deck);
game = stepDiscoveryClassicNexusSkills(game, { actor: fixture.opponent, action: { type: 'alliance', target: null } });
game = formClassicDiscoveryNexusAlliance(game, fixture.owner, fixture.opponent);
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
game = advanceDiscoveryClassicNexusSkills(game, game => game.nexusCards?.phase?.stage === 'drawing');
assert.ok(!game.nexusCards!.phase!.eligible.includes(fixture.owner));
assert.deepEqual(viewGame(game, fixture.owner).nexusCards!.choices, []);
inventory(kept); inventory(game);
});
for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} trained Discovery ${kind} returns native Great Maker casualties with distinct ordinary ledgers and actual Axlotl settlement`, () => {
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced, tech: true, strongholds: advanced });
const closing = closeDiscoveryClassicNexusSkills(fixture, kind);
const game = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
assert.equal(player(game, fixture.owner).tanks, 6, 'Tanks arise from the original worm action, never an injected death.');
const view = viewGame(game, fixture.owner), action: Action = { type: kind === 'emperor' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: 0 };
reject(game, fixture.opponent, action);
const before = player(game, fixture.owner);
let paid = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner, action });
const after = player(paid, fixture.owner);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, 3); assert.equal(after.spice, before.spice);
assert.equal(after.revived, kind === 'fremen' ? 3 : 0);
assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen' ? 3 : 0);
assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen' ? 0 : 3);
assert.equal(paid.nexusCards!.cards!.hands[fixture.owner], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === kind).length, 1);
assert.deepEqual(paid.leaderSkills, game.leaderSkills);
const history = kind === 'emperor' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
reject(paid, fixture.owner, action);
if (kind === 'emperor') {
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3 } });
paid = advanceDiscoveryClassicNexusSkills(paid, discoveryClassicNexusSkillsClean);
assert.equal(player(paid, fixture.owner).tanks, 0); assert.equal(player(paid, fixture.owner).revived, 3);
assert.equal(player(paid, fixture.owner).freeForcesRevived, 2); assert.equal(player(paid, fixture.owner).spice, before.spice - 2);
} else reject(paid, fixture.owner, { type: 'revive', amount: 1 });
const owner = paid.techTokens!.axlotl.owner!;
const credit = ownedTech(paid.techTokens, owner).length;
assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2); assert.equal(paid.techTokens!.axlotl.spice, credit);
const wallet = player(paid, owner).spice;
const movement = advanceDiscoveryClassicNexusSkills(paid, game => game.phase === 5 && discoveryClassicNexusSkillsClean(game));
assert.equal(player(movement, owner).spice, wallet + credit); assert.equal(movement.techTokens!.axlotl.spice, 0);
assert.deepEqual(movement.leaderSkills, fixture.game.leaderSkills); inventory(movement);
let ordinaryFirst = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner,
action: { type: 'revive', amount: kind === 'emperor' ? 3 : 1 } });
ordinaryFirst = advanceDiscoveryClassicNexusSkills(ordinaryFirst, discoveryClassicNexusSkillsClean);
if (kind === 'emperor') {
const extra = stepDiscoveryClassicNexusSkills(ordinaryFirst, { actor: fixture.owner, action });
assert.equal(player(extra, fixture.owner).tanks, 0);
assert.equal(player(extra, fixture.owner).revived, 3);
assert.equal(player(extra, fixture.owner).freeForcesRevived, 2);
assert.equal(player(extra, fixture.owner).spice, player(ordinaryFirst, fixture.owner).spice);
assert.deepEqual(extra.techTokens, ordinaryFirst.techTokens, 'Extra three cannot retrigger the already earned original Axlotl activity.');
inventory(extra);
} else {
reject(ordinaryFirst, fixture.owner, action);
assert.equal(player(ordinaryFirst, fixture.owner).tanks, 5);
assert.equal(player(ordinaryFirst, fixture.owner).revived, 1);
assert.equal(ordinaryFirst.nexusCards!.cards!.hands[fixture.owner], 'fremen');
}
});
}
void test('borrowed Guild nested shipment retains physical training and held Arrakeen, then actual skilled Warmaster cleanup and mandatory Tech transfer occur in order', () => {
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true, strongholds: true, skill: 'warmaster' });
const closing = closeDiscoveryClassicNexusSkills(fixture, 'guild');
const battle = openDiscoveryClassicNexusSkillsBattle(fixture, closing.game);
const before = player(battle.shipmentWindow, fixture.owner), arrived = player(battle.arrived, fixture.owner);
assert.equal(arrived.forces['cistern:0'], 6); assert.equal(arrived.reserves, before.reserves - 3);
assert.equal(arrived.spice, before.spice - 2); assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved);
assert.equal(battle.arrived.nexusCards!.cards!.hands[fixture.owner], null);
assert.equal(battle.arrived.nexusGuildSecretHistory!.length, 1);
assert.equal(battle.arrived.nexusGuildSecretHistory![0].receipt.owner, fixture.owner);
assert.equal(battle.arrived.strongholdCards!.owners.arrakeen, fixture.owner);
assert.deepEqual(battle.arrived.leaderSkills, battle.shipmentWindow.leaderSkills);
reject(battle.shipmentWindow, fixture.owner, { ...battle.shipmentStep.action, smuggler: true });
const industryOwner = battle.arrived.techTokens!.heighliners.owner!;
const industryCredit = ownedTech(battle.arrived.techTokens, industryOwner).length;
assert.equal(battle.arrived.techTokens!.heighliners.triggeredTurn, 2);
assert.equal(battle.arrived.techTokens!.heighliners.spice, industryCredit);
assert.equal(battle.game.techTokens!.heighliners.spice, 0);
assert.equal(player(battle.game, industryOwner).spice, player(battle.arrived, industryOwner).spice + industryCredit,
'Actual movement end settles shipping industry exactly once before the private nested plans.');
const revealed = revealDiscoveryClassicNexusSkillsBattle(battle), quote = quoteClassicSkillsStrongholdBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], null);
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
assert.equal(payment.bankSupport, 0); assert.equal(payment.ownPayment, 1);
const side = revealed.battle!.attacker === fixture.owner ? 'attacker' : 'defender';
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: 3, mode: 'skilled' }]);
const loser = player(revealed, fixture.opponent), reward = ownedTech(revealed.techTokens, fixture.opponent);
assert.ok(reward.length > 0);
const cleanup = finishDiscoveryClassicNexusSkillsBattle(revealed, true);
assert.equal(player(cleanup, fixture.owner).forces['cistern:0'], 5);
assert.equal(player(cleanup, fixture.opponent).forces['cistern:0'] ?? 0, 0);
assert.equal(player(cleanup, fixture.opponent).tanks, loser.tanks + 2);
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Battle cleanup still owns its priority before the winner reward.');
const done = finishDiscoveryClassicNexusSkillsBattle(cleanup);
assert.equal(done.lastBattleContext!.winner, fixture.owner);
assert.ok(player(done, fixture.owner).hand.some(card => card.id === battle.weapon));
assert.ok(!done.discard.some(card => card.id === battle.weapon));
assert.equal(viewGame(done, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.leader === fixture.leader)!.faceUp, true);
for (const token of reward) assert.equal(done.techTokens![token].owner, fixture.owner);
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
assert.deepEqual(done.nexusGuildSecretHistory, battle.arrived.nexusGuildSecretHistory);
assert.equal(done.strongholdCards!.owners.arrakeen, fixture.owner);
inventory(done);
});
void test('all four minimal policies produce actual offered training, physical entry, qualified Nexus draw and source-clear return outcomes', () => {
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true });
const closing = closeDiscoveryClassicNexusSkills(fixture, 'emperor');
const revival = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
const training = advanceDiscoveryClassicNexusSkills(structuredClone(fixture.setup), game => game.setupStage === 'leaderSkills');
for (const difficulty of DIFFICULTIES) {
const cases = [
{ game: training, actor: Object.keys(training.leaderSkills!.offers)[0], type: 'leaderSkill' },
{ game: fixture.game, actor: fixture.owner, type: 'decision' },
{ game: closing.beforeDraw, actor: fixture.owner, type: 'nexusCardChoice' },
{ game: revival, actor: fixture.owner, type: 'nexusEmperorRevive' },
];
for (const current of cases) {
const view = viewGame(current.game, current.actor); view.players.find(seat => seat.id === current.actor)!.bot = difficulty;
const untouched = structuredClone(view), action = botActions(view).find(action => action.type === current.type);
assert.ok(action, `${difficulty} needs the actual owned ${current.type}.`);
const after = stepDiscoveryClassicNexusSkills(structuredClone(current.game), { actor: current.actor, action });
assert.deepEqual(view, untouched);
if (current.type === 'leaderSkill') {
assert.equal(after.leaderSkills!.assignments.length, 1);
const assignment = after.leaderSkills!.assignments[0]; assert.equal(assignment.owner, current.actor);
assert.ok(current.game.leaderSkills!.offers[current.actor].cards.includes(assignment.skill));
assert.equal(after.leaderSkills!.offers[current.actor], undefined);
assert.deepEqual(after.players.map(seat => seat.hand), current.game.players.map(seat => seat.hand));
} else if (current.type === 'decision') {
assert.equal(after.discoveryEntry, undefined);
const entered = player(after, current.actor).forces['cistern:0'] ?? 0;
assert.equal((player(after, current.actor).forces[fixture.source] ?? 0) + entered, 3);
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
assert.deepEqual(after.leaderSkills, current.game.leaderSkills);
} else if (current.type === 'nexusCardChoice') {
assert.equal(after.nexusCards!.phase!.stage, 'complete');
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
assert.equal(after.phase, 2);
} else {
assert.equal(player(after, current.actor).tanks, 3);
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves + 3);
assert.equal(player(after, current.actor).revived, 0);
assert.equal(after.nexusCards!.cards!.hands[current.actor], null);
assert.equal(after.techTokens!.axlotl.triggeredTurn, 2);
assert.deepEqual(after.leaderSkills, current.game.leaderSkills);
}
inventory(after);
}
}
});
+1
View File
@@ -57,6 +57,7 @@ void test('Discovery Skills cannot silently drop unsupported native families or
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].homeworlds = { custody: null };
overlays[1] = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
overlays[3].mentatQuestionPreview = true;
@@ -0,0 +1,374 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_LOCATION, validGameLocation } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { validateRicheseNoField } from '../game/richese-no-field';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { validateNexusCards } from '../game/nexus-cards';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import { ownedTech } from '../game/tech-tokens';
import { traitorDeck } from '../game/traitors';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
advancePairedDiscoveryNexus, applyPairedDiscoveryNexusStep, closePairedDiscoveryNexus,
createPairedDiscoveryNexusFixture, holdPairedDiscoveryNexusCard,
initializePairedDiscoveryNexusForFixture, nextPairedDiscoveryNexusStep,
pairedDiscoveryNexusClean, pairedDiscoveryNexusPlayer as player, placePairedDiscoveryNexus,
pairedDiscoveryRicheseRequest, preparePairedDiscoveryNexusBattle,
reservePairedDiscoveryNexusBoard, revealPairedDiscoveryNexusBattle, settlePairedDiscoveryNexusShipment,
type PairedDiscoveryNexusFixture,
} from './fixture-discovery-paired-nexus';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected native choices leave original cards, wallets, subtype counters and receipts unchanged.');
}
function custody(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
const expected = [...treacheryDeck(game.expansions), ...(game.expansions[0] === 'choam' ? richeseCards() : [])];
assert.deepEqual(cards.map(c => c.id).sort(), expected.map(c => c.id).sort());
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
])];
assert.deepEqual(identities.sort(), traitorDeck(game.players, game.expansions[0] === 'ix').sort());
assert.equal(new Set(identities).size, identities.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count >= 0 && count <= (p.forces[key] ?? 0));
}
if (p.noField) validateRicheseNoField(p.noField);
}
validateDiscoveryState(game.discoveries!);
validateNexusCards(game.nexusCards!.cards!, game.players);
assert.ok(!game.leaderSkills && !game.homeworlds);
}
function richeseWindow(f: PairedDiscoveryNexusFixture, state: Game): Game {
return advancePairedDiscoveryNexus(state, g => g.phase === 5 && g.active === f.owner && pairedDiscoveryNexusClean(g));
}
function richeseReveal(game: Game, owner: string): Game {
const p = player(game, owner); assert.ok(p.noField!.deployed);
return applyAction(game, owner, { type: 'revealNoField', token: p.noField!.deployed.tokenId, event: p.noFieldEvent });
}
for (const family of ['ix', 'choam'] as const) for (const mode of [
{ advanced: false, tech: false, strongholds: false },
{ advanced: false, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: false },
{ advanced: true, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: true },
{ advanced: true, tech: true, strongholds: true },
]) void test(`paired ${family} ${JSON.stringify(mode)}: actual printed reveal, END Mentat, typed entry/Maker ride and alliance-qualified closing native deals`, () => {
const f = createPairedDiscoveryNexusFixture({ family, ...mode });
assert.deepEqual(f.setup.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.equal(f.setup.spiceDeck.filter(c => 'worm' in c && c.greatMaker).length, 1);
assert.equal(f.setup.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, family === 'ix' ? 1 : 0);
assert.equal(f.setup.discoveries!.tokens.length, 8);
assert.equal(f.afterSetup.players.reduce((sum, p) => sum + p.hand.length, 0) + f.afterSetup.deck.length,
family === 'ix' ? 47 : 35);
assert.equal(f.game.decision?.kind, 'discoveryEntry'); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 2);
assert.ok(f.actions.some(s => s.action.type === 'ship') && f.actions.some(s => s.action.type === 'discovery'));
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null), 'END Mentat/free entry do not manufacture a Nexus hand.');
if (mode.strongholds) assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
const c = closePairedDiscoveryNexus(f), old = player(c.entry.before, f.collector), entered = player(c.entry.after, f.collector);
const total = family === 'ix' ? 2 : 1, elite = family === 'ix' ? 1 : 0;
assert.equal(entered.forces['shrine:0'], total);
assert.equal(entered.elites?.forces['shrine:0'] ?? 0, elite);
assert.equal(entered.forces[f.source], 3 - total);
assert.equal(entered.reserves, old.reserves); assert.equal(entered.spice, old.spice);
assert.equal(entered.moved, old.moved); assert.equal(entered.shipped, old.shipped);
assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0);
assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2, 'Actual Great Maker ordinary worm losses precede its vote.');
assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(c => c === null));
const rider = player(c.ride.before, f.fremen), arrived = player(c.ride.after, f.fremen);
assert.equal(arrived.forces['polar_sink:0'], 2);
assert.equal(arrived.elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0);
assert.equal(arrived.reserves, rider.reserves - 2);
assert.equal(arrived.elites?.reserves ?? 0, (rider.elites?.reserves ?? 0) - (mode.advanced ? 1 : 0));
assert.equal(arrived.spice, rider.spice); assert.equal(arrived.shipped, rider.shipped); assert.equal(arrived.moved, rider.moved);
assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing', 'Maker reserve ride is not the closing phase draw.');
assert.equal(c.drawing.greatMaker!.stage, 'complete');
assert.ok(!c.drawing.spiceWindow && !c.drawing.spiceResolution);
assert.equal(player(c.drawing, f.guild).ally, f.fremen); assert.equal(player(c.drawing, f.fremen).ally, f.guild);
assert.deepEqual([...c.drawing.nexusCards!.phase!.eligible].sort(), [f.owner, f.native].sort());
if (mode.advanced) assert.ok(c.drawing.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'rock_outcroppings'),
'The actual second Advanced pile closes before any physical Nexus draw.');
const faces = family === 'ix' ? ['ixians', 'tleilaxu'] : ['richese', 'choam'];
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], faces[0]);
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], faces[1]);
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null); assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null);
if (mode.strongholds) assert.deepEqual(c.afterDraw.strongholdCards, f.firstMentat.after.strongholdCards);
for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.drawing, c.afterDraw]) custody(game);
});
void test('paired native Great Maker vote and both Spice piles without a settled alliance deal no Nexus cards', () => {
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
const game = advancePairedDiscoveryNexus(c.ride.after, g => g.phase === 2 && pairedDiscoveryNexusClean(g));
assert.ok(game.players.every(p => p.ally === null));
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
assert.equal(game.nexusCards!.cards!.deck.length, c.ride.after.nexusCards!.cards!.deck.length);
assert.notEqual(game.nexusCards!.phase?.stage, 'drawing');
custody(game);
});
void test('paired E2 immediate and concealed groups are capped by the original reserve pool without a second tariff', () => {
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced: false, tech: true, strongholds: false });
const c = closePairedDiscoveryNexus(f), window = richeseWindow(f, c.afterDraw);
const count = player(window, f.owner).reserves - 2;
placePairedDiscoveryNexus(window, f.owner, 'polar_sink:0', count);
f.staging.push(`Conserved ${count} original Richese reserves → Polar Sink; exactly two physical reserves remain before declaration.`);
const request = pairedDiscoveryRicheseRequest(f, window), before = player(window, f.owner);
const shipped = settlePairedDiscoveryNexusShipment(applyAction(window, f.owner, request));
assert.equal(player(shipped, f.owner).forces['shrine:0'], 2); assert.equal(player(shipped, f.owner).reserves, 0);
const materialized = richeseReveal(shipped, f.owner);
assert.equal(player(materialized, f.owner).forces['shrine:0'], 2);
assert.equal(player(materialized, f.owner).reserves, 0);
assert.equal(player(materialized, f.owner).spice, before.spice - 1);
assert.equal(player(materialized, f.guild).spice, player(window, f.guild).spice + 1);
assert.equal(player(materialized, f.owner).noField!.lastShipped, request.noField);
assert.equal(materialized.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
custody(materialized);
});
for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: original Cunning typed casualties, original Tech before nested Face Dance and draw-before-retire Tleilaxu Cunning`, () => {
const f = createPairedDiscoveryNexusFixture({ family: 'ix', advanced, tech: true, strongholds: advanced });
const closing = closePairedDiscoveryNexus(f), b = preparePairedDiscoveryNexusBattle(f, closing.afterDraw);
const offer = viewGame(b.game, f.owner).nexusSuboids!.offer!; assert.ok(offer && !offer.blocked);
const action: Action = { type: 'nexusSuboids', event: offer.event };
reject(b.game, f.native, action);
const used = applyAction(b.game, f.owner, action);
assert.equal(used.nexusCards!.cards!.hands[f.owner], null);
assert.equal(used.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
assert.deepEqual(player(used, f.owner).forces, player(b.game, f.owner).forces);
assert.deepEqual(player(used, f.owner).elites, player(b.game, f.owner).elites);
reject(used, f.owner, action);
const revealed = revealPairedDiscoveryNexusBattle(b, used), quote = quoteStrongholdFactionsBattle(revealed);
assert.equal(quote.winner, f.owner);
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Retained Arrakeen does not subsidize a different nested stronghold-price site.');
if (advanced) assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 0);
let losses = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'faceDance');
if (losses.decision?.kind === 'battleLosses') {
const choice = losses.decision.options.findIndex(o => o.normal === 2 && o.elite === 0); assert.ok(choice >= 0);
losses = applyAction(losses, f.owner, { type: 'decision', choice });
}
const pending = advancePairedDiscoveryNexus(losses, g => g.decision?.kind === 'faceDance');
assert.equal(pending.lastBattleContext!.winner, f.owner);
assert.equal(player(pending, f.owner).forces['shrine:0'], 4);
assert.equal(player(pending, f.owner).elites!.forces['shrine:0'], 3);
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 2);
assert.equal(player(pending, f.owner).elites!.tanks, player(revealed, f.owner).elites!.tanks);
assert.equal(player(pending, f.native).tanks, player(revealed, f.native).tanks + 6);
assert.ok(player(pending, f.owner).hand.some(c => c.id === b.weapon) && player(pending, f.owner).hand.some(c => c.id === b.defense));
const defeatedTech = ownedTech(revealed.techTokens, f.native); assert.equal(defeatedTech.length, 1);
assert.equal(pending.techTokens![defeatedTech[0]].owner, f.owner, 'Original winner already owns the actual loser Tech before replacement.');
const winner = player(pending, f.owner), dancer = player(pending, f.native), stock = structuredClone(dancer.faceDancers!);
const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 2, [f.source]: 2 } };
reject(pending, f.native, { ...dance, sector: 1 }); reject(pending, f.native, { ...dance, sources: { [MOBILE_LOCATION]: 4 } });
reject(pending, f.native, { ...dance, sources: { reserves: 5 } });
const after = applyAction(pending, f.native, dance), returned = player(after, f.owner), replaced = player(after, f.native);
assert.equal(returned.forces['shrine:0'] ?? 0, 0); assert.equal(returned.elites!.forces['shrine:0'] ?? 0, 0);
assert.equal(returned.reserves, winner.reserves + 4); assert.equal(returned.elites!.reserves, winner.elites!.reserves + 3);
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
assert.equal(returned.leaders.find(l => l.id === b.leader)!.dead, true);
assert.equal(replaced.forces['shrine:0'], 4); assert.equal(replaced.forces[f.source], 1); assert.equal(replaced.reserves, dancer.reserves - 2);
assert.deepEqual(after.techTokens, pending.techTokens); assert.deepEqual(after.strongholdCards, pending.strongholdCards);
const revealedIdentity = replaced.faceDancers!.find(c => c.revealed)!; assert.ok(revealedIdentity);
const untouched = stock.filter(c => c.leader !== revealedIdentity.leader), reserve = [...after.traitorReserve!];
const view = viewGame(after, f.native), replacement = nexusFaceDancersAction(view, view.nexusTleilaxu!.cunning!.event); assert.ok(replacement);
reject(after, f.native, { ...replacement, leaders: [revealedIdentity.leader] });
const renewed = applyAction(after, f.native, replacement), nextStock = player(renewed, f.native).faceDancers!;
assert.deepEqual(nextStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(nextStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), revealedIdentity.leader].sort());
assert.equal(player(renewed, f.native).faceDancerReplacedTurn, replaced.faceDancerReplacedTurn);
assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1);
reject(renewed, f.native, replacement); custody(renewed);
reservePairedDiscoveryNexusBoard(renewed, ['shrine:0', f.source, MOBILE_LOCATION]);
f.staging.push('After genuine Face Dance/replacement: conserve only the unrelated second-conflict groups back to their original reserves; original native Mentat closes the turn and expires Cunning.');
const nextTurn = advancePairedDiscoveryNexus(renewed, g => g.turn > renewed.turn);
assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false);
assert.equal(nextTurn.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
if (advanced) assert.equal(nextTurn.strongholdCards!.owners.arrakeen, null, 'Only the actual next END Mentat updates the held card to the conserved current board.');
custody(nextTurn);
});
for (const advanced of [false, true]) void test(`paired E2 ${advanced ? 'Advanced' : 'Basic'}: signed nested pair charges one physical tariff, native reveal and CHOAM support invoice`, () => {
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced, tech: true, strongholds: advanced });
const closing = closePairedDiscoveryNexus(f), window = richeseWindow(f, closing.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
const before = player(window, f.owner), guild = player(window, f.guild);
assert.equal(validGameLocation(window, 'shrine', 0), true); assert.equal(validGameLocation(window, 'cistern', 0), false);
reject(window, f.owner, { ...request, territory: 'cistern' }); reject(window, f.owner, { ...request, territory: 'invented-nested' });
reject(window, f.owner, { ...request, sector: 1 }); reject(window, f.owner, { ...request, revealedToken: request.noField });
holdPairedDiscoveryNexusCard(window, f.guild, c => c.effect === 'karama', f.staging);
const declared = applyAction(window, f.owner, request);
assert.equal(declared.response?.kind, 'richeseNoField'); assert.equal(declared.pendingShipment!.cost, 1);
assert.equal(declared.pendingShipment!.amount, 1); assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
assert.equal(player(declared, f.owner).spice, before.spice); assert.equal(player(declared, f.owner).reserves, before.reserves);
for (const target of ['cistern', 'invented-nested']) {
const corrupt = structuredClone(declared); corrupt.pendingShipment!.territory = target;
const next = nextPairedDiscoveryNexusStep(corrupt); assert.ok(next);
reject(corrupt, next.actor, next.action);
}
const shipped = settlePairedDiscoveryNexusShipment(declared), owner = player(shipped, f.owner);
assert.equal(owner.spice, before.spice - 1); assert.equal(player(shipped, f.guild).spice, guild.spice + 1);
assert.equal(owner.reserves, before.reserves - 5); assert.equal(owner.forces['shrine:0'], 5);
assert.equal(owner.noField!.deployed!.tokenId, request.noField); assert.equal(owner.noField!.lastShipped, request.noField);
assert.deepEqual(owner.noField!.tokens, before.noField!.tokens);
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped'); assert.equal(shipped.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
const publicView = viewGame(shipped, f.guild); assert.equal(publicView.richeseNoField!.private, null);
for (const token of owner.noField!.tokens) assert.equal(JSON.stringify(publicView).includes(token.id), false);
reject(shipped, f.owner, request);
for (const target of ['cistern', 'invented-nested']) {
const corrupt = structuredClone(shipped); corrupt.players.find(p => p.id === f.owner)!.noField!.deployed!.location = { territory: target, sector: 0 };
reject(corrupt, f.owner, { type: 'revealNoField', token: String(request.noField), event: owner.noFieldEvent });
}
const revealedMarker = richeseReveal(shipped, f.owner), materialized = player(revealedMarker, f.owner);
assert.equal(materialized.forces['shrine:0'], 8); assert.equal(materialized.reserves, before.reserves - 8);
assert.equal(materialized.spice, before.spice - 1); assert.equal(materialized.noField!.deployed, null);
assert.equal(materialized.noField!.lastShipped, request.noField); custody(revealedMarker);
const b = preparePairedDiscoveryNexusBattle(f, revealedMarker), revealed = revealPairedDiscoveryNexusBattle(b);
const quote = quoteStrongholdFactionsBattle(revealed); assert.equal(quote.winner, f.owner);
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Held Carthag is not inherited inside Shrine.');
if (advanced) {
const invoice = quote.payments.find(p => p.player === f.owner)!;
assert.equal(invoice.ownPayment, 3); assert.equal(invoice.bankSupport, 0);
assert.deepEqual(quote.choamIncome, { owner: f.native, amount: 1 });
} else { assert.deepEqual(quote.payments, []); assert.equal(quote.choamIncome, null); }
const done = advancePairedDiscoveryNexus(revealed, g => !g.battle && pairedDiscoveryNexusClean(g));
assert.equal(done.phase, 6, 'Second physical conflict keeps unrelated Collection out of this invoice.');
assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(player(done, f.owner).forces['shrine:0'], 5);
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 3);
assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 6);
assert.equal(player(done, f.native).spice, player(revealed, f.native).spice + (advanced ? 1 : 0));
const bounty = player(revealed, f.guild).leaders.find(l => l.id === b.enemy)!.strength;
assert.equal(player(done, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 3 : 0) + bounty);
assert.deepEqual(done.strongholdCards, revealed.strongholdCards); custody(done);
});
void test('paired E2 CHOAM own Cunning uses original physical fuel and native response after the closing deal', () => {
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), closing = closePairedDiscoveryNexus(f);
let game = advancePairedDiscoveryNexus(closing.afterDraw, g => g.phase === 5 && g.active === f.native && pairedDiscoveryNexusClean(g));
const fuel = holdPairedDiscoveryNexusCard(game, f.native, c => c.kind === 'projectile', f.staging);
holdPairedDiscoveryNexusCard(game, f.guild, c => c.effect === 'karama', f.staging);
const offer = viewGame(game, f.native).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon');
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event };
const before = structuredClone(game); game = applyAction(game, f.native, request);
assert.equal(game.response?.kind, 'choamWorthless'); assert.equal(game.nexusCards!.cards!.hands[f.native], null);
assert.ok(player(game, f.native).hand.some(c => c.id === fuel.id));
game = settlePairedDiscoveryNexusShipment(game);
assert.equal(game.choamMovement!.bonus, 1); assert.equal(game.nexusChoamLast!.stage, 'complete');
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
for (const p of before.players) { assert.equal(player(game, p.id).spice, p.spice); assert.deepEqual(player(game, p.id).forces, p.forces); }
reject(game, f.native, request); custody(game);
});
void test('all four minimal policies consume actual paired entry/vote/typed ride/closing draw and native Face Dance outcomes', () => {
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
const b = preparePairedDiscoveryNexusBattle(f, c.afterDraw), revealed = revealPairedDiscoveryNexusBattle(b);
const dance = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'faceDance');
for (const difficulty of DIFFICULTIES) for (const current of [
{ game: f.game, actor: f.collector, type: 'decision' },
{ game: c.vote, actor: c.vote.decision!.player, type: 'decision' },
{ game: c.ride.before, actor: f.fremen, type: 'decision' },
{ game: c.drawing, actor: f.owner, type: 'nexusCardChoice' },
{ game: dance, actor: f.native, type: 'decision' },
]) {
const view = viewGame(current.game, current.actor); view.players.find(p => p.id === current.actor)!.bot = difficulty;
const action = botActions(view).find(a => a.type === current.type); assert.ok(action);
const after = applyPairedDiscoveryNexusStep(structuredClone(current.game), { actor: current.actor, action });
if (current.game.decision?.kind === 'discoveryEntry') {
assert.equal(after.discoveryEntry, undefined);
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves);
} else 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');
assert.equal(player(after, f.fremen).spice, player(current.game, f.fremen).spice);
assert.equal(player(after, f.fremen).shipped, player(current.game, f.fremen).shipped);
} else if (current.game.decision?.kind === 'faceDance') {
assert.notEqual(after.decision?.kind, 'faceDance');
assert.deepEqual(after.techTokens, current.game.techTokens);
if (action.reveal) {
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
assert.equal(player(after, f.owner).leaders.find(l => l.id === b.leader)!.dead, true);
assert.ok((player(after, f.native).forces['shrine:0'] ?? 0) > 0);
} else assert.deepEqual(player(after, f.owner).forces, player(current.game, f.owner).forces);
} else {
assert.ok(after.nexusCards!.phase!.done.includes(current.actor));
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1);
}
custody(after);
}
});
void test('all four minimal policies settle the original signed E2 pair and materialize only physical reserves', () => {
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), c = closePairedDiscoveryNexus(f);
const window = richeseWindow(f, c.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
holdPairedDiscoveryNexusCard(window, f.guild, card => card.effect === 'karama', f.staging);
const pending = applyAction(window, f.owner, request);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.guild);
view.players.find(p => p.id === f.guild)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'passResponse' || a.type === 'card');
assert.ok(action, 'The actual eligible Karama holder must resolve the signed pair response.');
let game = applyAction(pending, f.guild, action);
game = settlePairedDiscoveryNexusShipment(game);
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
if (game.nexusRicheseCunningLast!.stage === 'stopped') {
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice);
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves);
assert.deepEqual(player(game, f.owner).forces, player(window, f.owner).forces);
assert.deepEqual(player(game, f.owner).noField, player(window, f.owner).noField);
assert.equal(player(game, f.owner).shipped, false);
custody(game);
} else {
assert.equal(game.nexusRicheseCunningLast!.stage, 'shipped');
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves - 5);
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice - 1);
const materialized = richeseReveal(game, f.owner);
assert.equal(player(materialized, f.owner).forces['shrine:0'], 8);
assert.equal(player(materialized, f.owner).reserves, player(window, f.owner).reserves - 8);
custody(materialized);
}
}
});
for (const family of ['ix', 'choam'] as const) void test(`paired ${family} authenticated original setup retains native actors, starting hands/offers and exposes a genuine human entry window`, () => {
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const : ['richese', 'choam', 'guild', 'fremen'] as const;
const lobby = createGame('PAIREDAUTHDISCOVERY', newPlayer(`auth-${factions[0]}`, factions[0], factions[0]), true, [family]);
for (const faction of factions.slice(1)) joinGame(lobby, newPlayer(`auth-${faction}`, faction, faction));
const setup = initializePairedDiscoveryNexusForFixture({ initial: lobby, family, tech: true, strongholds: true });
const original = structuredClone(setup), f = createPairedDiscoveryNexusFixture({ initial: setup });
assert.deepEqual(setup, original); assert.deepEqual(f.setup, original);
assert.equal(f.owner, `auth-${factions[0]}`); assert.equal(f.native, `auth-${factions[1]}`);
for (const p of setup.players) for (const card of p.hand)
assert.ok(player(f.afterSetup, p.id).hand.some(c => c.id === card.id), 'Continue the native starting hand, not a cleared/redealt approximation.');
assert.equal(f.game.decision!.player, family === 'ix' ? 'auth-ixians' : 'auth-guild');
const offer = viewGame(f.game, f.collector).discoveryEntry!;
const accepted = applyAction(f.game, f.collector, { type: 'decision', event: offer.event, accept: true,
groups: [{ source: f.source, normal: 1, elite: family === 'ix' ? 1 : 0 }] });
assert.equal(player(accepted, f.collector).forces['shrine:0'], family === 'ix' ? 2 : 1); custody(accepted);
});
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { createStrongholdCards } from '../game/stronghold-cards';
import {
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance,
nextClassicDiscoveryNexusStep, openClassicDiscoveryNexusAlliance, revealClassicDiscoveryNexus,
voteClassicDiscoveryNexus, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
} from './fixture-discovery-classic-nexus';
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
export type DiscoveryClassicNexusSkillsStep = { actor: string; action: Action };
export type DiscoveryClassicNexusSkillsOptions = {
/** Authenticated ready lobby or ORIGINAL unassigned turn-one setup, never a played save. */
initial?: Game;
gameId?: string;
seatIds?: string[];
seats?: 2 | 3 | 4 | 5 | 6;
factions?: FactionId[];
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Only a new, locally generated setup may position its physical offer lottery. */
skill?: LeaderSkillId;
};
export type DiscoveryClassicNexusSkillsFixture = {
initial: Game;
setup: Game;
trained: Game;
collection: Game;
firstMentat: { before: Game; step: DiscoveryClassicNexusSkillsStep; after: Game };
game: Game;
owner: string;
opponent: string;
leader: string;
skill: LeaderSkillId;
token: string;
source: string;
wormSource: string;
actions: DiscoveryClassicNexusSkillsStep[];
staging: string[];
};
export type DiscoveryClassicNexusSkillsClosing = {
vote: Game;
alliance: Game;
beforeDraw: Game;
drawStep: DiscoveryClassicNexusSkillsStep;
game: Game;
};
export type DiscoveryClassicNexusSkillsBattle = {
shipmentWindow: Game;
shipmentStep: DiscoveryClassicNexusSkillsStep;
arrived: Game;
game: Game;
weapon: string;
plans: DiscoveryClassicNexusSkillsStep[];
};
export const discoveryClassicNexusSkillsClean = classicDiscoveryNexusClean;
export const advanceDiscoveryClassicNexusSkills = advanceClassicDiscoveryNexus;
export const stepDiscoveryClassicNexusSkills = applyClassicDiscoveryNexusStep;
export function discoveryClassicNexusSkillsPlayer(game: Game, actor: string): Player {
const seat = game.players.find(player => player.id === actor);
assert.ok(seat); return seat;
}
/** The thirteen scalar draws belong only to the original all14 shuffle. Native
* UUID bytes and subsequent original component production remain unchanged. */
function offeredSkills<T>(selected: LeaderSkillId[], operation: () => T): T {
const working = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(skill => !selected.includes(skill));
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
desired.push(...remainder);
const rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const swap = working.indexOf(desired[index]); assert.ok(swap >= 0 && swap <= index);
rolls.push(Math.floor((swap + 0.5) / (index + 1) * 0x100000000));
[working[index], working[swap]] = [working[swap], working[index]];
}
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 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');
}
}
export function initializeDiscoveryClassicNexusSkillsSetup(options: DiscoveryClassicNexusSkillsOptions = {}): Game {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = options.factions ?? ['atreides', 'harkonnen', 'beneGesserit', 'guild', 'emperor', 'fremen'];
const count = options.seats ?? options.seatIds?.length ?? options.factions?.length ?? 3;
assert.ok(count >= 2 && count <= 6 && factions.length >= count);
if (options.seatIds) assert.equal(options.seatIds.length, count);
game = createGame(options.gameId ?? 'DISCOVERYCLASSICNEXUSSKILLS',
newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? false);
for (let index = 1; index < count; index++)
joinGame(game, newPlayer(options.seatIds?.[index] ?? factions[index], factions[index], factions[index]));
}
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.deepEqual(game.expansions, []);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills && !game.discoveries);
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 };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
assert.ok(classicNexusLeaderSkillsProfile(game));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
// A supplied human lobby must keep the real lottery; never demand one skill.
if (options.initial) game = withClassicDiscoveryNexusLottery(() => initializeLeaderSkillsGameForAudit(game));
else {
const requested = options.skill ?? 'warmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist']
.filter(skill => skill !== requested) as LeaderSkillId[];
const selected = game.players.map((_, index) => index === 0 ? requested : passive.shift()!);
game = offeredSkills(selected, () => initializeLeaderSkillsGameForAudit(game));
}
}
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(['prediction', 'leaderSkills'].includes(game.setupStage!));
assert.ok(game.leaderSkills && game.discoveries && game.nexusCards?.cards);
assert.ok(classicNexusLeaderSkillsProfile(game));
assert.equal(game.leaderSkills.assignments.length, 0);
assert.ok(game.players.every(player => !player.traitors.length && !player.traitorChoices.length));
if (game.strongholdCards) assert.deepEqual(game.strongholdCards, createStrongholdCards());
if (options.tech !== undefined) assert.equal(!!game.techTokens, options.tech);
if (options.strongholds !== undefined) assert.equal(!!game.strongholdCards, options.strongholds);
return game;
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
staging.push('Conserved original board counters into their OWN reserves before actual movement completion; no death, wallet, clock, alliance or earned receipt assigned.');
}
export function positionDiscoveryClassicNexusSkillsOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
const player = discoveryClassicNexusSkillsPlayer(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 at ${key}; subsequent native actions alone determine survival.`);
}
export function orderDiscoveryClassicNexusSkillsSpice(game: Game, secondTurn: boolean, staging: string[]): void {
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
const front: Game['spiceDeck'] = [];
if (secondTurn) front.push(take(card => 'worm' in card && !!card.greatMaker));
front.push(take(card => 'territory' in card && (secondTurn ? !card.discovery && card.territory === 'broken_land'
: card.discovery === 'discovery-hagga-basin')));
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery &&
(secondTurn ? card.territory !== 'broken_land' : card.territory === 'rock_outcroppings')));
game.spiceDeck.unshift(...front);
staging.push(secondTurn ? 'Conserved original unplayed Great Maker followed by BOTH Advanced land replacements; original discard history untouched.'
: 'Conserved original undealt Hagga Discovery and optional Advanced Rock Outcroppings positions; original token lottery selects Cistern.');
}
/** Human endpoint is actual trained next-turn free entry BEFORE Storm. Supplied
* offers/hands/IDs survive verbatim; only source-clear legal offered training is
* selected. Original owner clocks, reveal and first Mentat own every transition. */
export function createDiscoveryClassicNexusSkillsFixture(options: DiscoveryClassicNexusSkillsOptions = {}): DiscoveryClassicNexusSkillsFixture {
const setup = initializeDiscoveryClassicNexusSkillsSetup(options);
const initial = options.initial ? structuredClone(options.initial) : structuredClone(setup);
const owner = setup.players[0].id, opponent = setup.players[1].id;
const actions: DiscoveryClassicNexusSkillsStep[] = [], staging: string[] = [];
let game = structuredClone(setup);
while (game.status === 'setup') {
let step: DiscoveryClassicNexusSkillsStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const legal = view.offer!.cards.filter(skill => !view.unavailableSkills?.[skill]);
const desired = !options.initial && actor === owner ? options.skill ?? 'warmaster' : undefined;
const skill = desired && legal.includes(desired) ? desired : legal[0];
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = discoveryClassicNexusSkillsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader, 'Human continuation requires genuinely offered legal training, never a substitute offer.');
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
} else { const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); step = next; }
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
const trained = structuredClone(game), assignment = game.leaderSkills!.assignments.find(item => item.owner === owner)!;
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
orderDiscoveryClassicNexusSkillsSpice(game, false, staging);
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); actions.push(structuredClone(next));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
}
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
reserveBoard(game, staging);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12';
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, source, 3, staging);
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, wormSource, 6, staging);
if (game.strongholdCards) positionDiscoveryClassicNexusSkillsOrdinary(game, owner, 'arrakeen:10', 1, staging);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
const collection = structuredClone(game), token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
game = revealClassicDiscoveryNexus(game, owner, token.id, actions);
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions); game = firstMentat.after;
assert.equal(game.turn, 2); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner);
return { initial, setup, trained, collection, firstMentat, game, owner, opponent,
leader: assignment.leader, skill: assignment.skill, token: token.id, source, wormSource, actions, staging };
}
export function enterDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, amount = 3): Game {
const game = structuredClone(fixture.game), view = viewGame(game, fixture.owner);
const source = view.discoveryEntry!.sources.find(source => source.normal >= amount); assert.ok(source);
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action);
return applyClassicDiscoveryNexusStep(game, { actor: fixture.owner, action }, fixture.actions);
}
/** Actual Great Maker death -> all votes -> reserve ride -> settled alliance ->
* ENTIRE Spice Blow closure. Omitting target preserves the genuine original draw
* for human continuation; targeting only reorders an unplayed deck singleton. */
export function closeDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, target?: FactionId): DiscoveryClassicNexusSkillsClosing {
assert.ok(fixture.game.players.length >= 3, 'An unallied draw needs two other actual alliance seats.');
let game = enterDiscoveryClassicNexusSkills(fixture);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), fixture.actions);
orderDiscoveryClassicNexusSkillsSpice(game, true, fixture.staging);
game = advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
const vote = structuredClone(game);
game = voteClassicDiscoveryNexus(game, game.players.map(() => true), fixture.actions);
game = openClassicDiscoveryNexusAlliance(game);
const others = game.players.filter(player => player.id !== fixture.owner);
game = formClassicDiscoveryNexusAlliance(game, others[0].id, others[1].id);
const alliance = structuredClone(game);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', fixture.actions);
const cards = game.nexusCards!.cards!;
if (target) {
const index = cards.deck.indexOf(target); assert.ok(index >= 0, 'Only an original unplayed singleton may supply the targeted draw.');
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
fixture.staging.push(`Positioned original UNPLAYED ${target} Nexus singleton before actual closing draw; never a held-hand assignment.`);
}
const beforeDraw = structuredClone(game), drawStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[fixture.owner], choice: 'draw', ownRedraws: 0 } };
game = applyClassicDiscoveryNexusStep(game, drawStep, fixture.actions);
return { vote, alliance, beforeDraw, drawStep, game };
}
/** Borrowed Guild tariff is spent through an actual nested shipment. The trained
* Warmaster remains the physical actor; Arrakeen custody is not nested support. */
export function openDiscoveryClassicNexusSkillsBattle(fixture: DiscoveryClassicNexusSkillsFixture, state: Game): DiscoveryClassicNexusSkillsBattle {
assert.equal(fixture.skill, 'warmaster');
let game = advanceClassicDiscoveryNexus(state, game => game.phase === 5 && game.active === fixture.owner && classicDiscoveryNexusClean(game), fixture.actions);
const shipmentWindow = structuredClone(game), offer = viewGame(game, fixture.owner).nexusGuildSecretAlly;
assert.ok(offer && !offer.blocked);
const shipmentStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner, action: {
type: 'ship', territory: 'cistern', sector: 0, amount: 3, elite: 0, nexus: offer.event } };
game = applyClassicDiscoveryNexusStep(game, shipmentStep, fixture.actions);
game = advanceClassicDiscoveryNexus(game, game => !game.pendingShipment && classicDiscoveryNexusClean(game), fixture.actions);
const arrived = structuredClone(game);
positionDiscoveryClassicNexusSkillsOrdinary(game, fixture.opponent, 'cistern:0', 2, fixture.staging);
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.owner, 'worthless', fixture.staging).id;
const enemy = discoveryClassicNexusSkillsPlayer(game, fixture.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(enemy);
game = advanceClassicDiscoveryNexus(game, game => game.phase === 6 && classicDiscoveryNexusClean(game), fixture.actions);
const actor = game.active!; assert.ok(actor === fixture.owner || actor === fixture.opponent);
game = applyClassicDiscoveryNexusStep(game, { actor, action: { type: 'chooseBattle', territory: 'cistern',
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
for (let count = 0; count < 160; count++) {
const next = nextClassicDiscoveryNexusStep(game);
if (!next) break;
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner;
game = applyClassicDiscoveryNexusStep(game, next, fixture.actions);
}
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
return { shipmentWindow, shipmentStep, arrived, game, weapon, plans: [
{ actor: fixture.owner, action: { type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0,
leader: fixture.leader, weapon, defense: null } },
{ actor: fixture.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
] };
}
export function revealDiscoveryClassicNexusSkillsBattle(battle: DiscoveryClassicNexusSkillsBattle): Game {
let game = structuredClone(battle.game);
for (const step of battle.plans) {
game = applyClassicDiscoveryNexusStep(game, step);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); game = applyClassicDiscoveryNexusStep(game, next);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishDiscoveryClassicNexusSkillsBattle(state: Game, stopAtCleanup = false): Game {
let game = structuredClone(state);
for (let count = 0; count < 240; count++) {
if (stopAtCleanup && game.decision?.kind === 'battleCards') return game;
if (!game.battle && classicDiscoveryNexusClean(game) && !game.pendingTreacheryDiscard) return game;
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = false;
game = applyClassicDiscoveryNexusStep(game, next);
}
throw Error('Original trained nested battle did not finish its cleanup.');
}
+315
View File
@@ -0,0 +1,315 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializePairedNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { MOBILE_LOCATION, gameTerritories, territory, validGameLocation } from '../game/board';
import { isAuditorLeader, type Card } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nextPairedIxNexusModulesStep } from './fixture-paired-ix-nexus-modules';
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
import {
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
} from './fixture-discovery-classic-nexus';
export type PairedDiscoveryNexusStep = { actor: string; action: Action };
export type PairedDiscoveryNexusOptions = {
/** Authenticated original ready lobby or original unassigned turn-one setup. */
initial?: Game;
family?: 'ix' | 'choam';
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
};
export type PairedDiscoveryNexusTransition = { before: Game; step: PairedDiscoveryNexusStep; after: Game };
export type PairedDiscoveryNexusFixture = {
initial: Game; setup: Game; afterSetup: Game; collection: Game;
firstMentat: PairedDiscoveryNexusTransition; game: Game;
family: 'ix' | 'choam'; owner: string; native: string; collector: string;
guild: string; fremen: string; token: string; source: string; claim: 'arrakeen' | 'carthag';
actions: PairedDiscoveryNexusStep[]; staging: string[];
};
export type PairedDiscoveryNexusClosing = {
entry: PairedDiscoveryNexusTransition; vote: Game; ride: PairedDiscoveryNexusTransition;
alliance: Game; drawing: Game; afterDraw: Game;
};
export type PairedDiscoveryNexusBattle = {
game: Game; beforeBattle: Game; battleStep: PairedDiscoveryNexusStep;
winner: string; loser: string; leader: string; enemy: string;
weapon: string; defense: string; source: string; plans: PairedDiscoveryNexusStep[];
};
export const pairedDiscoveryNexusClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export function pairedDiscoveryNexusPlayer(game: Game, actor: string): Player {
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
}
/** Do not automate owned human entry, vote, ride, declarations or battle plans
* in the returned programmes. These defaults only cross unrelated native windows. */
export function nextPairedDiscoveryNexusStep(game: Game): PairedDiscoveryNexusStep | null {
if (!game.phaseOpening && !game.response && game.decision) {
const d = game.decision;
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d.kind === 'greatMakerVote')
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
if (d.kind === 'faceDance') return null;
if (d.kind === 'discoveryDiscard') {
const card = pairedDiscoveryNexusPlayer(game, d.player).hand[0]; assert.ok(card);
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
}
}
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
}
export function applyPairedDiscoveryNexusStep(game: Game, step: PairedDiscoveryNexusStep,
actions?: PairedDiscoveryNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advancePairedDiscoveryNexus(state: Game, until: (g: Game) => boolean,
actions?: PairedDiscoveryNexusStep[]): Game {
let game = structuredClone(state);
for (let n = 0; n < 2400; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextPairedDiscoveryNexusStep(game);
assert.ok(step, 'This original paired programme needs its human plan or Face Dance.');
game = applyPairedDiscoveryNexusStep(game, step, actions);
}
throw Error('Paired Discovery/Nexus continuation did not reach the requested original boundary');
}
/** Never restart the supplied setup, clear its original hands, replace its
* private offers, assign actors, clocks, training, or manufacture a held card. */
export function initializePairedDiscoveryNexusForFixture(options: PairedDiscoveryNexusOptions = {}): Game {
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
: ['richese', 'choam', 'guild', 'fremen'] as const;
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
let game = options.initial ? structuredClone(options.initial)
: createGame('PAIREDDISCOVERYNEXUS', newPlayer(factions[0], factions[0], factions[0]), advanced, [family]);
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.players.every(p => !p.traitors.length && p.leaders.every(l => !l.dead && !l.usedAt)),
'Use the original lobby or unassigned turn-one native setup, including any native starting hand.');
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
game = options.initial ? initializePairedNexusGameForAudit(game)
: withClassicDiscoveryNexusLottery(() => initializePairedNexusGameForAudit(game));
}
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries && game.nexusCards?.cards);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
return game;
}
function orderSpice(game: Game): void {
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
const at = game.spiceDeck.findIndex(matches); assert.ok(at >= 0);
return game.spiceDeck.splice(at, 1)[0];
};
const cards = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')];
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
cards.push(take(c => 'worm' in c && !!c.greatMaker));
cards.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
game.spiceDeck.unshift(...cards);
}
export function reservePairedDiscoveryNexusBoard(game: Game, kept: string[] = []): void {
for (const p of game.players) for (const [key, count] of Object.entries(p.forces)) {
if (kept.includes(key)) continue;
p.reserves += count; delete p.forces[key];
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
}
}
/** Explicitly conserved original subtype groups; elite is a subset of total. */
export function placePairedDiscoveryNexus(game: Game, actor: string, key: string, total: number, elite = 0): void {
const p = pairedDiscoveryNexusPlayer(game, actor);
assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite &&
(p.elites?.reserves ?? 0) >= elite);
p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total;
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; }
}
export function holdPairedDiscoveryNexusCard(game: Game, actor: string, matches: (c: Card) => boolean,
staging?: string[]): Card {
const p = pairedDiscoveryNexusPlayer(game, actor), held = p.hand.find(matches);
if (held) return held;
const at = game.deck.findIndex(matches); assert.ok(at >= 0 && p.hand.length < handLimit(p));
const card = game.deck.splice(at, 1)[0]; p.hand.push(card);
staging?.push(`Conserved unplayed ${card.id}: original undealt deck → ${actor} hand; retain original starting hands.`);
return card;
}
/** Original pair, printed blow and physical token lottery, actual paid typed
* collection, inspection/reveal and END Mentat. Stops BEFORE turn-two Storm
* at the authenticated collector's real free-entry control. */
export function createPairedDiscoveryNexusFixture(options: PairedDiscoveryNexusOptions = {}): PairedDiscoveryNexusFixture {
let game = initializePairedDiscoveryNexusForFixture(options);
const initial = structuredClone(game), setup = structuredClone(game);
const family = game.expansions[0] as 'ix' | 'choam';
const actor = (faction: string) => game.players.find(p => p.faction === faction)!.id;
const owner = actor(family === 'ix' ? 'ixians' : 'richese');
const native = actor(family === 'ix' ? 'tleilaxu' : 'choam'), guild = actor('guild'), fremen = actor('fremen');
const collector = family === 'ix' ? owner : guild;
const actions: PairedDiscoveryNexusStep[] = [], staging = [
'Conserved original unplayed Spice order: printed Hagga Basin, optional Advanced Broken Land; then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.',
'Selected actual Shrine supply-token and original scalar Storm/shuffle lotteries only; all UUIDs, actors, setup offers and starting hands remain original.',
];
game = options.initial ? advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions)
: withClassicDiscoveryNexusLottery(() => advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions));
const afterSetup = structuredClone(game);
orderSpice(game);
game = advancePairedDiscoveryNexus(game, g => g.phase === 1 && pairedDiscoveryNexusClean(g), actions);
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextPairedDiscoveryNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
}
const token = game.discoveries!.tokens.find(t => t.face === 'shrine')!; assert.equal(token.status, 'placed');
const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const source = `${printed.territory}:${printed.sector}`;
game = advancePairedDiscoveryNexus(game, g => g.phase === 5 && g.active === collector && pairedDiscoveryNexusClean(g), actions);
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'ship', territory: printed.territory,
sector: printed.sector, amount: 3, elite: family === 'ix' ? 1 : 0, allyPayment: 0 } }, actions);
game = advancePairedDiscoveryNexus(game, g => g.phase === 7 && pairedDiscoveryNexusClean(g), actions);
const collection = structuredClone(game);
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
game = advancePairedDiscoveryNexus(game, g => g.phase === 8 && pairedDiscoveryNexusClean(g), actions);
reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]);
const claim = family === 'ix' ? 'arrakeen' : 'carthag';
placePairedDiscoveryNexus(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1);
placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2);
staging.push(`Before actual END Mentat: conserve original unrelated board groups to reserves, keep paid parent/HMS counters, place one ${owner} ${claim} claim counter and two ${native} Hagga Basin worm-source counters. No custody, clocks, paid history or wallets assigned.`);
let firstMentat: PairedDiscoveryNexusTransition | undefined;
for (let n = 0; game.turn === 1 && n < 100; n++) {
const before = structuredClone(game), step = nextPairedDiscoveryNexusStep(game); assert.ok(step);
game = applyPairedDiscoveryNexusStep(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advancePairedDiscoveryNexus(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.phase, 0); assert.equal(game.turn, 2); assert.equal(game.decision!.player, collector);
return { initial, setup, afterSetup, collection, firstMentat, game, family, owner, native, collector,
guild, fremen, token: token.id, source, claim, actions, staging };
}
/** Native human entry, all storm-order votes, exact Fremen typed reserve ride,
* both Spice piles, reciprocal classic alliance and actual closing singletons. */
export function closePairedDiscoveryNexus(f: PairedDiscoveryNexusFixture): PairedDiscoveryNexusClosing {
const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!;
const step: PairedDiscoveryNexusStep = { actor: f.collector, action: { type: 'decision', event: offer.event,
accept: true, groups: [{ source: f.source, normal: 1, elite: f.family === 'ix' ? 1 : 0 }] } };
let game = applyPairedDiscoveryNexusStep(before, step, f.actions);
const entry = { before, step, after: structuredClone(game) };
game = advancePairedDiscoveryNexus(game, g => g.greatMaker?.stage === 'vote', f.actions);
const vote = structuredClone(game);
while (game.decision?.kind === 'greatMakerVote') game = applyPairedDiscoveryNexusStep(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen);
const rideBefore = structuredClone(game), action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0);
assert.ok(action);
const rideStep = { actor: f.fremen, action }; game = applyPairedDiscoveryNexusStep(game, rideStep, f.actions);
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
game = advancePairedDiscoveryNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedDiscoveryNexusClean(g), f.actions);
game = applyPairedDiscoveryNexusStep(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions);
game = applyPairedDiscoveryNexusStep(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions);
const alliance = structuredClone(game);
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions);
const cards = game.nexusCards!.cards!;
const faces = f.family === 'ix' ? ['ixians', 'tleilaxu'] as const : ['richese', 'choam'] as const;
for (const face of faces) assert.ok(cards.deck.includes(face));
cards.deck = [...faces, ...cards.deck.filter(c => !faces.some(face => face === c))];
f.staging.push('Conserve undealt all12 singleton order only; actual unallied closing draw actions deal the two native Cunning cards AFTER both Advanced piles and reciprocal alliance.');
const drawing = structuredClone(game);
for (const actor of [f.owner, f.native]) game = applyPairedDiscoveryNexusStep(game,
{ actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, f.actions);
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions);
return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) };
}
export function pairedDiscoveryRicheseRequest(f: PairedDiscoveryNexusFixture, game: Game): Action {
assert.equal(f.family, 'choam'); assert.equal(validGameLocation(game, 'shrine', 0), true);
const p = pairedDiscoveryNexusPlayer(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning!;
assert.ok(offer && !offer.blocked);
return { type: 'ship', territory: 'shrine', sector: 0, noField: p.noField!.tokens.find(t => t.value === 3)!.id,
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
}
export function settlePairedDiscoveryNexusShipment(game: Game): Game {
return advancePairedDiscoveryNexus(game, g => !g.pendingShipment && pairedDiscoveryNexusClean(g));
}
/** Conserved original private identities only, never revealed-stock/paid history.
* Select the genuine Face Dancer from its existing stock or exchange with the
* exact original unplayed Traitor/Face Dancer custody, before the native battle. */
function matchDancer(game: Game, actor: string, leader: string, staging: string[]): void {
const stock = pairedDiscoveryNexusPlayer(game, actor).faceDancers!;
if (stock.some(c => c.leader === leader && !c.revealed)) return;
const dancer = stock.find(c => !c.revealed); assert.ok(dancer);
const old = dancer.leader, index = game.traitorReserve!.indexOf(leader);
if (index >= 0) game.traitorReserve![index] = old;
else {
const holder = game.players.find(p => p.traitors.includes(leader)); assert.ok(holder);
holder.traitors[holder.traitors.indexOf(leader)] = old;
}
dancer.leader = leader;
staging.push(`Conserved original unplayed private identities: ${leader} ↔ ${old}; no revealed flag or replacement history assigned.`);
}
/** Native nested battle after actual entry and closing deal. E2 begins only
* AFTER real paid two-marker materialization/reveal, never mixed marker plans. */
export function preparePairedDiscoveryNexusBattle(f: PairedDiscoveryNexusFixture, state: Game): PairedDiscoveryNexusBattle {
let game = advancePairedDiscoveryNexus(state, g => g.phase === 5 && pairedDiscoveryNexusClean(g));
reservePairedDiscoveryNexusBoard(game, f.family === 'ix' ? [MOBILE_LOCATION] : ['shrine:0']);
const loser = f.family === 'ix' ? f.native : f.guild;
if (f.family === 'ix') {
placePairedDiscoveryNexus(game, f.owner, 'shrine:0', 6, 3);
placePairedDiscoveryNexus(game, f.native, f.source, 3);
} else {
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).forces['shrine:0'], 8);
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).noField!.deployed, null);
// The collector's earned entry counter is conserved back to its own reserve;
// only Richese's actually paid/materialized eight-counter group stays here.
const collector = pairedDiscoveryNexusPlayer(game, f.collector);
collector.reserves += collector.forces['shrine:0'] ?? 0;
delete collector.forces['shrine:0'];
}
placePairedDiscoveryNexus(game, loser, 'shrine:0', 6);
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== 'hagga_basin' && t.id !== 'gara_kulon' &&
t.sectors.some(s => s >= 9 && s !== game.storm)); assert.ok(other);
const key = `${other.id}:${other.sectors.find(s => s >= 9 && s !== game.storm)!}`;
for (const actor of [f.owner, loser]) placePairedDiscoveryNexus(game, actor, key, 1);
const leader = pairedDiscoveryNexusPlayer(game, f.owner).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => b.strength - a.strength)[0];
const enemy = pairedDiscoveryNexusPlayer(game, loser).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && enemy);
if (f.family === 'ix') matchDancer(game, f.native, leader.id, f.staging);
const weapon = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'projectile', f.staging).id;
const defense = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'shield', f.staging).id;
f.staging.push('Before actual Movement completion: conserve exact original subtype reserve/board groups into the REVEALED Shrine, parent replacement source and second native conflict; retain original hands, earned held cards, wallets and real Nexus deals.');
game = advancePairedDiscoveryNexus(game, g => g.phase === 6 && pairedDiscoveryNexusClean(g));
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === loser);
const battleStep: PairedDiscoveryNexusStep = { actor, action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? loser : f.owner } };
game = applyPairedDiscoveryNexusStep(game, battleStep);
game = advancePairedDiscoveryNexus(game, g => !!g.battle && !g.battle.revealed && !nextPairedDiscoveryNexusStep(g));
const plans: PairedDiscoveryNexusStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', leader: leader.id, dial: f.family === 'ix' ? 2 : 3,
support: f.family === 'choam' && game.advanced ? 3 : 0, weapon, defense } },
{ actor: loser, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
];
return { game, beforeBattle, battleStep, winner: f.owner, loser, leader: leader.id, enemy: enemy.id, weapon, defense, source: f.source, plans };
}
export function revealPairedDiscoveryNexusBattle(b: PairedDiscoveryNexusBattle, state = b.game): Game {
let game = structuredClone(state);
for (const step of b.plans) {
game = applyPairedDiscoveryNexusStep(game, step);
game = advancePairedDiscoveryNexus(game, g => !g.response && g.decision?.kind !== 'fullPlanOffer');
}
assert.ok(game.battle?.revealed); return game;
}
-3
View File
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import {
RULE_TOPICS,
RULE_CHECKLIST_AREAS,
PHASE_HELP,
phaseRuleId,
} from '../game/reference';
import { FACTIONS, PHASES } from '../game/catalog';
@@ -13,11 +12,9 @@ void test('every phase and base faction has a discoverable rule topic with valid
const ids = new Set(RULE_TOPICS.map((t) => t.id));
assert.equal(ids.size, RULE_TOPICS.length);
for (const topic of RULE_TOPICS) {
assert.ok(topic.steps.length > 0, topic.id);
for (const related of topic.related ?? [])
assert.ok(ids.has(related), `${topic.id} -> ${related}`);
}
assert.equal(PHASE_HELP.length, PHASES.length);
PHASES.forEach((_, i) => assert.ok(ids.has(phaseRuleId(i))));
FACTIONS.forEach((f) => assert.ok(ids.has(`faction-${f.id}`)));
assert.ok(ids.has('ai-players'));