Compose native trained Skills Nexus Discovery modules

This commit is contained in:
Quintine committed 2026-10-06 10:13:07 +11:00
1 parent 958d7b3436
commit 2270c07f97
18 files changed
+2510 -18

No files matched your search

+1 -1
View File
@@ -57,7 +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] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
overlays[3].mentatQuestionPreview = true;
@@ -0,0 +1,356 @@
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 { gameDistance, MOBILE_LOCATION, splitLocation } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
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 { pairedDiscoveryNexusClean as clean, placePairedDiscoveryNexus, reservePairedDiscoveryNexusBoard } from './fixture-discovery-paired-nexus';
import {
advanceDiscoveryPairedNexusE1Skills as advance, beginDiscoveryPairedNexusE1SkillsCunning as cunning,
closeDiscoveryPairedNexusE1Skills as close, createDiscoveryPairedNexusE1SkillsBattle as battle,
createDiscoveryPairedNexusE1SkillsEntry as entry, discoveryPairedNexusE1SkillsPlayer as player,
discoveryPairedNexusE1SkillsRangeSource as rangeSource, finishDiscoveryPairedNexusE1SkillsBattle as finish,
initializeDiscoveryPairedNexusE1SkillsSetup as setup, prepareDiscoveryPairedNexusE1SkillsPlans as prepare,
revealDiscoveryPairedNexusE1SkillsBattle as reveal, stepDiscoveryPairedNexusE1Skills as step,
type DiscoveryPairedNexusE1SkillsBattle,
} from './fixture-discovery-paired-nexus-e1-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected original action leaves the whole original input untouched');
}
function custody(game: Game, original: 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) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(['ix']).map(c => c.id).sort());
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, true).sort());
validateLeaderSkills(game.leaderSkills!, game.players);
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
validateDiscoveryState(game.discoveries!); validateNexusCards(game.nexusCards!.cards!, game.players);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) {
const previous = player(original, p.id).elites!;
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
previous.reserves + previous.tanks + Object.values(previous.forces).reduce((sum, n) => sum + n, 0));
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
}
}
}
/** Select real Cunning commitment, never assign a loss receipt. */
function rescueWindow(f: DiscoveryPairedNexusE1SkillsBattle, revealed = reveal(f)): Game {
let game = finish(revealed, 'losses');
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 2); assert.ok(choice >= 0);
game = step(game, { actor: game.decision.player, action: { type: 'decision', choice } });
}
game = finish(game, 'rescue');
assert.equal(game.decision?.kind, 'sukRescue');
return game;
}
function rescueAndSubstitute(f: DiscoveryPairedNexusE1SkillsBattle, pending: Game): Game {
assert.ok(pending.decision?.kind === 'sukRescue');
const choice = pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
const rescued = step(pending, { actor: f.owner, action: { type: 'decision', event: pending.decision.event, choice } });
assert.equal(rescued.decision?.kind, 'ixSubstitution');
return advance(step(rescued, { actor: f.owner, action: { type: 'decision',
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }),
game => clean(game) || game.decision?.kind === 'battleCards' || game.decision?.kind === 'faceDance');
}
const MODES = [
{ 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 },
] as const;
for (const mode of MODES) void test(`paired E1 Skills ${JSON.stringify(mode)}: original hands, printed collection, END Mentat, Maker and qualified closing`, () => {
const f = entry(mode);
assert.deepEqual(f.offered.players.map(p => [p.id, p.hand]), f.afterSetup.players.map(p => [p.id, p.hand]));
assert.ok(f.offered.players.every(p => !p.traitors.length && !p.traitorChoices.length));
assert.equal(player(f.offered, f.native).faceDancers, undefined);
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.discoveries!.tokens.length, 8);
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, 1);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); assert.equal(f.game.decision?.kind, 'discoveryEntry');
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null));
assert.equal(player(f.game, f.owner).forces[f.source], 3);
assert.equal(player(f.game, f.owner).elites!.forces[f.source], 1);
if (mode.strongholds) {
assert.equal(f.firstMentat.before.strongholdCards!.owners.arrakeen, null);
assert.equal(f.firstMentat.after.strongholdCards!.owners.arrakeen, f.owner);
}
const c = close(f), before = player(c.entry.before, f.owner), arrived = player(c.entry.after, f.owner);
assert.equal(arrived.forces['shrine:0'], 2); assert.equal(arrived.elites!.forces['shrine:0'], 1);
assert.equal(arrived.forces[f.source], 1); assert.equal(arrived.elites!.forces[f.source] ?? 0, 0);
assert.equal(arrived.spice, before.spice); assert.equal(arrived.reserves, before.reserves);
assert.equal(arrived.shipped, before.shipped); assert.equal(arrived.moved, before.moved);
assert.deepEqual(c.entry.after.leaderSkills, c.entry.before.leaderSkills);
assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2);
assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0);
assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(card => card === null));
assert.equal(player(c.ride.after, f.fremen).forces['polar_sink:0'], 2);
assert.equal(player(c.ride.after, f.fremen).elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0);
assert.equal(player(c.ride.after, f.fremen).spice, player(c.ride.before, f.fremen).spice);
assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing');
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'));
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], 'ixians');
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], 'tleilaxu');
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null);
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null);
for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.afterDraw]) custody(game, f.afterSetup);
});
void test('paired E1 authenticated original human actors, existing first hands and actual offers survive continuation without requiring Suk', () => {
const lobby = createGame('HUMANPAIREDE1SKILLS', newPlayer('human-ix', 'Human Ix', 'ixians'), true, ['ix']);
for (const [id, faction] of [['human-tl', 'tleilaxu'], ['human-gu', 'guild'], ['human-fr', 'fremen']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
const fresh = setup({ initial: lobby, skill: 'planetologist', tech: true, strongholds: true });
assert.deepEqual(fresh.initial, lobby);
const original = structuredClone(fresh.offered), offered = original.leaderSkills!.offers[fresh.owner];
const continued = entry({ initial: original });
assert.deepEqual(continued.initial, original);
assert.deepEqual(continued.offered.leaderSkills!.offers, original.leaderSkills!.offers);
assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.name, p.hand]), original.players.map(p => [p.id, p.name, p.hand]));
assert.deepEqual(continued.afterSetup.players.map(p => p.id), ['human-ix', 'human-tl', 'human-gu', 'human-fr']);
assert.ok(offered.cards.includes(continued.skill));
reject(original, fresh.owner, { type: 'leaderSkill', event: offered.event, skill: offered.cards[0],
leader: player(original, fresh.native).leaders[0].id });
const absent = LEADER_SKILL_CARDS.find(c => !offered.cards.includes(c.id))!.id;
assert.throws(() => entry({ initial: original, skill: absent }));
assert.deepEqual(original, fresh.offered);
custody(continued.game, continued.afterSetup);
});
void test('paired E1 source-clear free entry rejects foreign actors and HMS companions; real Planetologist crosses the printed nested range', () => {
const f = entry({ advanced: true, tech: true, strongholds: true, skill: 'planetologist' });
assert.ok(f.game.decision?.kind === 'discoveryEntry');
const action: Action = { type: 'decision', event: f.game.decision.event, accept: true,
groups: [{ source: f.source, normal: 1, elite: 1 }] };
reject(f.game, f.native, action);
reject(f.game, f.owner, { ...action, groups: [{ source: f.source, normal: 1, elite: 2 }] });
reject(f.game, f.owner, { ...action, groups: [{ source: MOBILE_LOCATION, normal: 1, elite: 1 }] });
const entered = step(f.game, { actor: f.owner, action });
reject(entered, f.owner, action);
const c = close(f);
let game = advance(c.afterDraw, g => g.phase === 5 && g.active === f.owner && clean(g));
reservePairedDiscoveryNexusBoard(game, [MOBILE_LOCATION]);
const source = rangeSource(game);
assert.equal(gameDistance(game, source, 'shrine:0', key => splitLocation(key).sector === game.storm), 3);
placePairedDiscoveryNexus(game, f.owner, source, 3, 1);
const before = structuredClone(game), move: Action = { type: 'move', territory: 'shrine', sector: 0,
forces: { [source]: 2 }, eliteForces: { [source]: 1 }, planetologist: 'range' };
reject(game, f.owner, { ...move, planetologist: undefined });
reject(game, f.owner, { ...move, territory: 'shrine', sector: 1 });
game = advance(step(game, { actor: f.owner, action: move }), clean);
assert.equal(player(game, f.owner).forces['shrine:0'], 2);
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 1);
assert.equal(player(game, f.owner).forces[source], 1);
assert.equal(player(game, f.owner).moved, 1);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], player(before, f.owner).forces[MOBILE_LOCATION]);
assert.deepEqual(game.leaderSkills, before.leaderSkills);
custody(game, f.afterSetup);
});
for (const mode of MODES) void test(`paired E1 Skills ${JSON.stringify(mode)}: full Cunning, trained Suk rescue then genuine equal Cyborg substitution`, () => {
const f = battle(mode), prepared = prepare(f), offer = viewGame(prepared, f.owner).nexusSuboids!.offer!;
reject(prepared, f.opponent, { type: 'nexusSuboids', event: offer.event });
const sealed = step(prepared, f.plans[1]);
const ownSealed = step(prepared, { actor: f.owner, action: { ...f.plans[0].action, dial: 6, support: mode.advanced ? 1 : 0 } });
reject(ownSealed, f.owner, { type: 'nexusSuboids', event: offer.event });
const active = cunning(f, sealed);
assert.equal(active.nexusCards!.cards!.hands[f.owner], null);
assert.equal(active.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
assert.deepEqual(player(active, f.owner).forces, player(sealed, f.owner).forces);
reject(active, f.owner, { type: 'nexusSuboids', event: offer.event });
const revealed = reveal(f), pending = rescueWindow(f, revealed);
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null,
'An actually retained named Arrakeen Card cannot subsidize the nested site');
assert.equal(pending.lastBattleContext!.winner, f.owner);
assert.deepEqual(pending.pendingSukRescue!.losses,
{ normal: 4, elite: 2, paidNormal: 0, paidElite: mode.advanced ? 2 : 0 });
assert.equal(player(pending, f.owner).forces[f.location], 8);
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks);
assert.equal(pending.pendingIxSubstitution, undefined);
if (mode.strongholds) assert.equal(pending.strongholdCards!.owners.arrakeen, f.owner);
const choice = pending.decision!.kind === 'sukRescue'
? pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal') : -1;
assert.ok(choice >= 0);
reject(pending, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
const saved = step(pending, { actor: f.owner, action: { type: 'decision', event: pending.decision!.kind === 'sukRescue' ? pending.decision.event : '', choice } });
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
assert.equal(saved.decision?.kind, 'ixSubstitution');
assert.equal(player(saved, f.owner).forces[f.location], 3);
assert.equal(player(saved, f.owner).elites!.forces[f.location] ?? 0, 0);
assert.equal(player(saved, f.owner).reserves, player(pending, f.owner).reserves + 2);
assert.equal(player(saved, f.owner).elites!.reserves, player(pending, f.owner).elites!.reserves + 1);
assert.equal(player(saved, f.owner).tanks, player(pending, f.owner).tanks + 3);
assert.equal(player(saved, f.owner).elites!.tanks, player(pending, f.owner).elites!.tanks + 1);
assert.deepEqual(saved.pendingIxSubstitution!.losses, { [f.location]: 1 });
reject(saved, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.replacementSource]: 1 } });
reject(saved, f.owner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } });
const substituted = advance(step(saved, { actor: f.owner, action: { type: 'decision',
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }),
game => clean(game) || game.decision?.kind === 'battleCards' || game.decision?.kind === 'faceDance');
assert.equal(player(substituted, f.owner).forces[f.location], 3);
assert.equal(player(substituted, f.owner).elites!.forces[f.location], 1);
assert.equal(player(substituted, f.owner).elites!.tanks, player(pending, f.owner).elites!.tanks);
assert.equal(player(substituted, f.owner).spice, player(saved, f.owner).spice);
assert.equal(substituted.lastBattleContext!.ixSubstitution!.completed, true);
custody(substituted, f.entry.afterSetup);
});
for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: physical winner cleanup and original Tech precede Face Dance, partial Cunning retains Mentat rights`, () => {
const f = battle({ advanced, tech: true, strongholds: advanced });
assert.ok(f.weapon, 'Controlled original first-hand offer supplies an actually selected weapon');
const revealed = reveal(f), rescued = rescueAndSubstitute(f, rescueWindow(f, revealed));
const cards = finish(rescued, 'cards'); assert.equal(cards.decision?.kind, 'battleCards');
const premature: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 1, [f.replacementSource]: 2 } };
reject(cards, f.opponent, premature);
assert.equal(cards.lastBattleContext!.sukRescue!.completed, true);
assert.equal(cards.lastBattleContext!.ixSubstitution!.completed, true);
const cleaned = step(cards, { actor: f.owner, action: { type: 'decision', discard: [f.weapon] } });
const face = finish(cleaned, 'faceDance');
assert.ok(face.discard.some(c => c.id === f.weapon));
assert.ok(!player(face, f.owner).hand.some(c => c.id === f.weapon));
assert.equal(player(face, f.opponent).leaders.find(l => l.id === f.enemy)!.dead, true);
const defeatedTech = ownedTech(revealed.techTokens, f.opponent); assert.equal(defeatedTech.length, 1);
assert.equal(face.techTokens![defeatedTech[0]].owner, f.owner);
assert.ok(!face.pendingTech);
const winner = structuredClone(player(face, f.owner)), native = structuredClone(player(face, f.opponent));
const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 1, [f.replacementSource]: 2 } };
reject(face, f.opponent, { ...dance, sector: 1 });
reject(face, f.opponent, { ...dance, sources: { [MOBILE_LOCATION]: 3 } });
reject(face, f.opponent, { ...dance, sources: { reserves: 4 } });
const danced = step(face, { actor: f.opponent, action: dance });
assert.equal(player(danced, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(danced.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(danced.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
assert.equal(player(danced, f.owner).forces[f.location] ?? 0, 0);
assert.equal(player(danced, f.owner).reserves, winner.reserves + 3);
assert.equal(player(danced, f.owner).elites!.reserves, winner.elites!.reserves + 1);
assert.equal(player(danced, f.opponent).forces[f.location], 3);
assert.equal(player(danced, f.opponent).forces[f.replacementSource], 1);
assert.equal(player(danced, f.opponent).reserves, native.reserves - 1);
assert.deepEqual(danced.techTokens, face.techTokens);
assert.deepEqual(player(danced, f.owner).hand, winner.hand);
for (const p of face.players) assert.equal(player(danced, p.id).spice, p.spice);
reject(danced, f.opponent, dance);
const projection = viewGame(danced, f.opponent);
const renew = nexusFaceDancersAction(projection, projection.nexusTleilaxu!.cunning!.event); assert.ok(renew);
const revealedDancer = player(danced, f.opponent).faceDancers!.find(c => c.leader === f.trainer)!;
assert.equal(revealedDancer.revealed, true);
reject(danced, f.opponent, { ...renew, leaders: [f.trainer] });
const reserve = [...danced.traitorReserve!], allowance = player(danced, f.opponent).faceDancerReplacedTurn;
const renewed = step(danced, { actor: f.opponent, action: renew });
assert.deepEqual(player(renewed, f.opponent).faceDancers!.filter(c => !native.faceDancers!.some(old => old.leader === c.leader)),
[{ leader: reserve[0], revealed: false }]);
assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort());
assert.equal(player(renewed, f.opponent).faceDancerReplacedTurn, allowance);
assert.equal(renewed.nexusCards!.cards!.hands[f.opponent], null);
assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1);
assert.equal(renewed.leaderSkills!.assignments.some(a => a.owner === f.opponent && a.leader === f.trainer), false);
reject(renewed, f.opponent, renew);
reservePairedDiscoveryNexusBoard(renewed, [f.location, f.replacementSource, MOBILE_LOCATION]);
const mentat = advance(renewed, g => g.phase === 8 && clean(g));
const untouched = player(mentat, f.opponent).faceDancers!.find(c => !c.revealed && c.leader !== reserve[0]); assert.ok(untouched);
const replaced = advance(step(mentat, { actor: f.opponent, action: { type: 'replaceFaceDancer', leader: untouched.leader } }), clean);
assert.equal(player(replaced, f.opponent).faceDancerReplacedTurn, replaced.turn);
reject(replaced, f.opponent, { type: 'replaceFaceDancer', leader: player(replaced, f.opponent).faceDancers![0].leader });
const nextTurn = advance(replaced, g => g.turn > replaced.turn);
assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false);
assert.equal(nextTurn.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
if (!advanced) {
const revival = advance(nextTurn, g => g.turn === 3 && g.phase === 4 && clean(g));
reject(revival, f.opponent, { type: 'reviveForeignGhola', leader: f.trainer });
assert.equal(player(revival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(revival.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
custody(revival, f.entry.afterSetup);
}
custody(danced, f.entry.afterSetup); custody(replaced, f.entry.afterSetup); custody(nextTurn, f.entry.afterSetup);
});
void test('all four minimal paired E1 policies use eligible original actors and produce entry, vote, Cunning, trained rescue and Face Dance receipts', () => {
const f = entry(), c = close(f), b = battle(), prepared = prepare(b), revealed = reveal(b);
const pending = rescueWindow(b, revealed);
const face = finish(rescueAndSubstitute(b, pending), 'faceDance');
for (const difficulty of DIFFICULTIES) {
for (const current of [
{ game: f.game, actor: f.owner, 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: prepared, actor: b.owner, type: 'nexusSuboids' },
{ game: pending, actor: b.owner, type: 'decision' },
{ game: face, actor: b.opponent, type: 'decision' },
]) {
const projection = viewGame(current.game, current.actor);
projection.players.find(p => p.id === current.actor)!.bot = difficulty;
const action = botActions(projection).find(a => a.type === current.type); assert.ok(action);
const acted = step(current.game, { actor: current.actor, action });
const after = current.type === 'nexusSuboids' ? advance(acted, clean) : acted;
if (current.game.decision?.kind === 'discoveryEntry') {
assert.equal(after.discoveryEntry, undefined);
const own = player(after, current.actor), old = player(current.game, current.actor);
assert.equal((own.forces[f.source] ?? 0) + (own.forces['shrine:0'] ?? 0), old.forces[f.source]);
assert.equal((own.elites!.forces[f.source] ?? 0) + (own.elites!.forces['shrine:0'] ?? 0), 1);
assert.equal(own.spice, old.spice);
} 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);
} else if (current.type === 'nexusCardChoice') {
assert.ok(after.nexusCards!.phase!.done.includes(current.actor));
assert.equal(after.nexusCards!.cards!.hands[current.actor], 'ixians');
assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1);
} else if (current.type === 'nexusSuboids') {
assert.equal(after.nexusCards!.cards!.hands[b.owner], null);
assert.equal(after.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
assert.deepEqual(player(after, b.owner).forces, player(current.game, b.owner).forces);
assert.equal(viewGame(after, b.owner).nexusSuboids!.active, true);
const own = viewGame(after, b.owner); own.players.find(p => p.id === b.owner)!.bot = difficulty;
const plan = botActions(own).find(a => a.type === 'battlePlan'); assert.ok(plan);
const committed = step(after, { actor: b.owner, action: plan });
assert.equal(committed.battle!.plans[b.owner].leader, plan.leader);
assert.equal(player(committed, b.owner).spice, player(after, b.owner).spice);
custody(committed, b.entry.afterSetup);
} else if (current.game.decision?.kind === 'sukRescue') {
assert.equal(after.pendingSukRescue, null);
assert.equal(after.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(after, b.owner).forces[b.location], 3);
assert.equal(player(after, b.owner).tanks, player(pending, b.owner).tanks + 3);
assert.equal(player(after, b.owner).reserves, player(pending, b.owner).reserves + 2);
} else {
assert.equal(player(after, b.owner).forces[b.location] ?? 0, 0);
assert.equal(player(after, b.owner).leaders.find(l => l.id === b.trainer)!.dead, true);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === b.trainer), false);
assert.deepEqual(after.techTokens, face.techTokens);
}
custody(after, current.actor === b.owner || current.actor === b.opponent ? b.entry.afterSetup : f.afterSetup);
}
}
});
@@ -0,0 +1,384 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck, spiceDeck } from '../game/cards';
import type { Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { validGameLocation, territory } from '../game/board';
import { validateDiscoveryState } from '../game/discoveries';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateNexusCards } from '../game/nexus-cards';
import { validateRicheseNoField } from '../game/richese-no-field';
import { ownedTech } from '../game/tech-tokens';
import { placePairedSkillsForce, stagePairedSkillsPlanetologistRoute } from './fixture-paired-choam-nexus-skills';
import {
advanceDiscoveryPairedNexusE2Skills as advance, closeDiscoveryPairedNexusE2Skills as close,
completeDiscoveryPairedNexusE2SkillsSetup as completeSetup,
createDiscoveryPairedNexusE2SkillsFixture as fixture,
discoveryPairedNexusE2SkillsClean as clean,
discoveryPairedNexusE2SkillsCunningAction as pairAction,
discoveryPairedNexusE2SkillsInvoiceAction as invoice,
discoveryPairedNexusE2SkillsPlayer as player,
finishDiscoveryPairedNexusE2SkillsBattle as finishBattle,
initializeDiscoveryPairedNexusE2Skills as initialize,
prepareDiscoveryPairedNexusE2SkillsBattle as prepareBattle,
settleDiscoveryPairedNexusE2Skills as settle,
stepDiscoveryPairedNexusE2Skills as step,
type DiscoveryPairedNexusE2SkillsFixture,
} from './fixture-discovery-paired-nexus-e2-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected human declarations cannot spend cards, invoice, counters or training.');
}
function custody(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
validateLeaderSkills(game.leaderSkills!, game.players);
validateNexusCards(game.nexusCards!.cards!, game.players);
validateDiscoveryState(game.discoveries!);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, count) => n + count, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, count) => n + count, 0), p.faction === 'fremen' ? 3 : 5);
if (p.noField) validateRicheseNoField(p.noField);
}
}
function acquiredCard(f: DiscoveryPairedNexusE2SkillsFixture, buyer: string, matches: (card: Card) => boolean): Card {
const receipt = f.acquisitions.find(a => a.buyer === buyer && matches(a.card));
assert.ok(receipt, 'The intended eligible actor must actually acquire this original card through Bidding.');
const { before, after, card } = receipt, auction = before.auction!;
assert.equal(before.phase, 3);
assert.equal(before.turn, 1);
assert.equal(auction.cards[auction.index].id, card.id);
assert.equal(auction.bidder, buyer);
assert.equal(auction.bid, 1);
assert.ok(player(before, buyer).hand.length < handLimit(player(before, buyer)));
assert.ok(f.afterSetup.deck.some(c => c.id === card.id));
assert.deepEqual(player(after, buyer).hand, [...player(before, buyer).hand, card]);
assert.equal(player(after, buyer).spice, player(before, buyer).spice - 1);
assert.ok(f.actions.some(s => s.actor === buyer && s.action.type === 'bid' && s.action.amount === 1));
return card;
}
const modules = [
{ 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 },
] as const;
for (const options of modules) void test(`original paired E2 Discovery/Skills ${JSON.stringify(options)}: paid collection, real end-Mentat entry, Maker and qualified closing draws`, () => {
const f = fixture(options), ending = close(f);
const ordinary = [...f.initial.deck, ...f.initial.players.flatMap(p => p.hand)];
assert.equal(ordinary.length, 35);
assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(['choam']).map(c => c.id).sort());
assert.equal(f.initial.richeseCache!.length, 10);
assert.equal(f.initial.spiceDeck.length, spiceDeck().length + 7);
assert.equal(f.initial.discoveries!.tokens.length, 8);
assert.equal(f.initial.leaderSkills!.deck.length + Object.values(f.initial.leaderSkills!.offers).reduce((n, o) => n + o.cards.length, 0), 14);
assert.equal(f.initial.nexusCards!.cards!.deck.length, 12);
assert.equal(player(f.previousMarker.after, f.richese).spice, player(f.previousMarker.before, f.richese).spice - 1);
assert.equal(player(f.previousMarker.after, f.richese).reserves, player(f.previousMarker.before, f.richese).reserves);
assert.equal(player(f.collection, f.collector).forces[f.source], 3);
assert.equal(validGameLocation(f.collection, 'shrine', 0), false);
assert.ok(f.firstMentat);
assert.equal(f.firstMentat.before.turn, 1);
assert.equal(f.firstMentat.before.phase, 8);
assert.equal(f.firstMentat.after.turn, 2);
assert.equal(ending.entry.before.phase, 0);
assert.equal(ending.entry.before.decision?.player, f.collector);
assert.equal(player(ending.entry.after, f.collector).forces[f.source] ?? 0, 0);
assert.equal(player(ending.entry.after, f.collector).forces['shrine:0'], 3);
assert.equal(player(ending.entry.after, f.collector).spice, player(ending.entry.before, f.collector).spice);
assert.equal(validGameLocation(ending.game, 'shrine', 0), true);
assert.equal(player(ending.vote, f.choam).forces['hagga_basin:12'] ?? 0, 0);
assert.equal(player(ending.vote, f.choam).tanks, player(ending.entry.before, f.choam).tanks + 2);
assert.equal(ending.ride.before.decision?.player, f.fremen);
assert.equal(player(ending.ride.after, f.fremen).forces['polar_sink:0'], 2);
assert.equal(player(ending.ride.after, f.fremen).reserves, player(ending.ride.before, f.fremen).reserves - 2);
if (options.advanced) assert.equal(player(ending.ride.after, f.fremen).elites!.forces['polar_sink:0'], 1);
assert.equal(ending.alliance.spiceWindow, null);
assert.equal(ending.alliance.spiceResolution, null);
assert.equal(player(ending.alliance, f.guild).ally, f.fremen);
assert.equal(player(ending.alliance, f.fremen).ally, f.guild);
assert.equal(ending.drawing.phase, 1);
for (const draw of ending.draws) {
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
reject(draw.after, draw.step.actor, draw.step.action);
}
assert.equal(ending.game.nexusCards!.cards!.hands[f.guild], null);
assert.equal(ending.game.nexusCards!.cards!.hands[f.fremen], null);
assert.deepEqual(ending.game.leaderSkills!.assignments, f.afterSetup.leaderSkills!.assignments);
if (options.strongholds) {
assert.equal(f.firstMentat.before.strongholdCards!.owners.arrakeen, null);
assert.equal(f.firstMentat.after.strongholdCards!.owners.arrakeen, f.choam);
}
custody(ending.game);
});
void test('saved authenticated original skill offers and starting hands survive continuation without demanding a random consumer', () => {
const setup = initialize({ advanced: true, tech: true, strongholds: true,
seatIds: ['human-richese', 'human-choam', 'human-guild', 'human-fremen'] });
const before = structuredClone(setup), continued = initialize({ initial: reload(setup) });
assert.deepEqual(continued, before);
const done = completeSetup(continued);
for (const assignment of done.leaderSkills!.assignments)
assert.ok(before.leaderSkills!.offers[assignment.owner].cards.includes(assignment.skill));
assert.deepEqual(done.players.map(p => [p.id, p.hand]), before.players.map(p => [p.id, p.hand]));
assert.equal(Object.keys(done.leaderSkills!.offers).length, 0);
assert.equal(done.status, 'playing');
assert.equal(done.leaderSkills!.assignments.length, 4);
custody(done);
});
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6]) void test(`${advanced ? 'Advanced' : 'Basic'} human ${seats}-seat original paired programme trains only original offers with legal native modules`, () => {
const factions = ['richese', 'choam', 'guild', 'fremen', 'emperor', 'atreides'] as const;
const lobby = createGame(`HUMANE2${seats}`, newPlayer('authenticated-r', 'R', 'richese'), advanced, ['choam']);
for (const faction of factions.slice(1, seats)) joinGame(lobby, newPlayer(`authenticated-${faction}`, faction, faction));
const setup = initialize({ initial: lobby, tech: seats >= 3, strongholds: advanced });
const done = completeSetup(setup);
assert.equal(done.players.length, seats);
assert.equal(done.leaderSkills!.assignments.length, seats);
assert.deepEqual(done.players.map(p => p.id), lobby.players.map(p => p.id));
assert.equal(done.status, 'playing');
custody(done);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} nested signed two-marker Cunning keeps one physical invoice through native responses/Guild and private reserve caps`, () => {
const f = fixture({ advanced, tech: true, strongholds: advanced }), ending = close(f), before = ending.game;
const karama = acquiredCard(f, f.fremen, c => c.effect === 'karama');
assert.ok(player(before, f.fremen).hand.some(c => c.id === karama.id));
const own = player(before, f.richese), previous = own.noField!.tokens.find(t => t.value === 0)!;
const request = pairAction(before, f.richese);
reject(before, f.richese, { ...request, noField: previous.id });
reject(before, f.richese, { ...request, revealedToken: request.noField });
reject(before, f.richese, { ...request, nexus: 'stale-own-pair' });
reject(before, f.richese, { ...request, territory: 'treachery-card-stash', sector: 0 });
reject(before, f.richese, { ...request, sector: 1 });
reject(before, f.richese, { ...request, amount: 8 });
reject(before, f.richese, { ...request, smuggler: true });
for (const cap of [2, 20]) {
const capped = reload(before), p = player(capped, f.richese);
placePairedSkillsForce(capped, f.richese, 'polar_sink:0', p.reserves - cap, f.staging);
const pending = applyAction(capped, f.richese, pairAction(capped, f.richese));
assert.equal(pending.response?.kind, 'richeseNoField');
assert.equal(pending.pendingShipment!.cost, 1);
assert.equal(player(pending, f.richese).spice, p.spice);
assert.equal(player(pending, f.richese).reserves, cap);
const corrupted = reload(pending), holder = player(corrupted, f.fremen);
corrupted.pendingShipment!.cost = 0;
reject(corrupted, holder.id, { type: 'passResponse' });
const retargeted = reload(pending);
retargeted.pendingShipment!.territory = 'arrakeen';
reject(retargeted, holder.id, { type: 'passResponse' });
const guild = advance(pending, g => g.decision?.kind === 'guildShipment' || clean(g));
if (advanced) {
assert.equal(guild.decision?.player, f.guild);
assert.equal(player(guild, f.richese).spice, p.spice);
reject(guild, f.choam, { type: 'decision', allow: true });
reject(guild, f.guild, { type: 'decision', allow: false });
}
const shipped = settle(guild);
assert.equal(player(shipped, f.richese).spice, p.spice - 1);
assert.equal(player(shipped, f.guild).spice, player(capped, f.guild).spice + 1);
assert.equal(player(shipped, f.richese).reserves, cap - Math.min(5, cap));
assert.equal(player(shipped, f.richese).forces['shrine:0'], Math.min(5, cap));
assert.equal(player(shipped, f.richese).noField!.deployed!.tokenId, request.noField);
assert.equal(player(shipped, f.richese).noField!.lastShipped, request.noField);
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
assert.deepEqual(player(shipped, f.richese).noField!.tokens, own.noField!.tokens);
reject(shipped, f.richese, request);
assert.equal(player(settle(shipped), f.guild).spice, player(shipped, f.guild).spice);
custody(shipped);
}
reject(before, f.richese, { ...pairAction(before, f.richese), smuggler: true });
});
void test('actual original Karama holder cancels a nested pair without a fee, counter materialization or ordinary substitute entitlement', () => {
const f = fixture({ advanced: true, tech: true }), before = close(f).game;
const card = acquiredCard(f, f.fremen, c => c.effect === 'karama'), holder = player(before, f.fremen);
assert.ok(holder.hand.some(c => c.id === card.id));
const request = pairAction(before, f.richese), pending = applyAction(reload(before), f.richese, request);
assert.equal(pending.response?.kind, 'richeseNoField');
const stopped = settle(applyAction(pending, holder.id, { type: 'card', mode: 'cancel', card: card.id }));
assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped');
assert.equal(stopped.nexusCards!.cards!.hands[f.richese], null);
assert.equal(stopped.discard.filter(c => c.id === card.id).length, 1);
assert.equal(player(stopped, holder.id).hand.some(c => c.id === card.id), false);
for (const p of before.players) {
assert.equal(player(stopped, p.id).spice, p.spice);
assert.deepEqual(player(stopped, p.id).forces, p.forces);
assert.equal(player(stopped, p.id).reserves, p.reserves);
}
assert.equal(player(stopped, f.richese).noField!.lastShipped, player(before, f.richese).noField!.lastShipped);
reject(stopped, f.richese, request);
reject(stopped, f.richese, { type: 'ship', amount: 6, territory: 'shrine', sector: 0, smuggler: true });
custody(stopped);
});
void test('all four minimal policies pass the actual Karama-holder response then consume the native two-marker invoice once', () => {
const f = fixture({ advanced: true, tech: true, strongholds: true }), before = close(f).game;
const card = acquiredCard(f, f.fremen, c => c.effect === 'karama');
const holder = player(before, f.fremen), request = pairAction(before, f.richese);
assert.ok(holder.hand.some(c => c.id === card.id));
for (const difficulty of DIFFICULTIES) {
const pending = applyAction(reload(before), f.richese, request), view = viewGame(pending, holder.id);
assert.equal(pending.response?.kind, 'richeseNoField');
view.players.find(p => p.id === holder.id)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'passResponse');
assert.ok(action, `${difficulty} must act as the actual eligible Karama holder.`);
const shipped = settle(applyAction(pending, holder.id, action));
assert.equal(player(shipped, f.richese).forces['shrine:0'], 5);
assert.equal(player(shipped, f.richese).spice, player(before, f.richese).spice - 1);
assert.equal(player(shipped, f.guild).spice, player(before, f.guild).spice + 1);
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped');
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
assert.deepEqual(player(shipped, holder.id).hand, player(before, holder.id).hand);
custody(shipped);
}
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original CHOAM Stash draws its one genuine next card without changing its five-card capacity or training`, () => {
const f = fixture({ advanced, kind: 'stash' }), before = f.collection;
const hand = player(before, f.choam).hand.map(c => c.id), drawn = before.deck[0]; assert.ok(drawn);
assert.equal(player(f.game, f.choam).hand.length, hand.length + 1);
assert.deepEqual(player(f.game, f.choam).hand.map(c => c.id), [...hand, drawn.id]);
assert.equal(f.game.deck.length, before.deck.length - 1);
assert.equal(player(f.game, f.choam).spice, player(before, f.choam).spice);
assert.equal(f.game.discoveries!.tokens.find(t => t.id === f.token)!.status, 'removed');
assert.deepEqual(f.game.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(f.game, f.choam, { type: 'discovery', token: f.token, reveal: true });
custody(f.game);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} own CHOAM Cunning consumes actual auction-acquired Special fuel, separate from the trained Smuggler invoice`, () => {
const f = fixture({ advanced, tech: true, strongholds: advanced }), ending = close(f);
const before = advance(ending.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g));
const fuel = acquiredCard(f, f.choam, c => c.kind === 'special' && c.effect !== 'karama');
assert.ok(player(before, f.choam).hand.some(c => c.id === fuel.id));
const offer = viewGame(before, f.choam).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon');
assert.ok(offer && !offer.blocked && offer.source === 'nexus');
const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event };
reject(before, f.choam, { ...request, nexus: 'stale-fuel' });
const declared = applyAction(reload(before), f.choam, request);
if (declared.response) assert.equal(declared.response.kind, 'choamWorthless');
assert.equal(declared.nexusCards!.cards!.hands[f.choam], null);
if (declared.response) assert.equal(player(declared, f.choam).hand.some(c => c.id === fuel.id), true);
const fueled = settle(declared);
assert.equal(fueled.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(fueled, f.choam).hand.some(c => c.id === fuel.id), false);
assert.equal(fueled.choamMovement!.bonus, 1);
reject(fueled, f.choam, request);
const shipment = invoice(fueled), paid = settle(applyAction(reload(fueled), f.choam, shipment.action));
assert.equal(player(paid, f.choam).forces[shipment.key], 6);
assert.equal(player(paid, f.choam).reserves, player(fueled, f.choam).reserves - 6);
assert.equal(player(paid, f.choam).spice, player(fueled, f.choam).spice - 5);
assert.equal(player(paid, f.guild).spice, player(fueled, f.guild).spice + 5);
assert.equal(paid.discard.filter(c => c.id === fuel.id).length, 1);
assert.deepEqual(paid.leaderSkills!.assignments, before.leaderSkills!.assignments);
custody(paid);
});
void test('all four minimal card policies spend actual physical fuel for Kulon before a separate ordinary skill move', () => {
const f = fixture({ advanced: true, choamSkill: 'planetologist', tech: true, strongholds: true }), ending = close(f);
const before = advance(ending.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g));
const route = stagePairedSkillsPlanetologistRoute(before, f.choam, f.staging);
reject(before, f.choam, route.action);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(before, f.choam); view.players.find(p => p.id === f.choam)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'card' && a.mode === 'choam' && a.effect === 'kulon');
assert.ok(action);
const fuel = player(before, f.choam).hand.find(c => c.id === action.card); assert.ok(fuel);
const used = settle(applyAction(reload(before), f.choam, action));
assert.equal(used.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(used, f.choam).hand.some(c => c.id === fuel.id), false);
const moved = settle(applyAction(used, f.choam, route.action));
assert.equal(player(moved, f.choam).forces[route.from] ?? 0, 0);
assert.equal(player(moved, f.choam).forces[route.to], 1);
assert.equal(player(moved, f.choam).moved, 1);
assert.equal(moved.discard.filter(c => c.id === fuel.id).length, 1);
assert.deepEqual(moved.leaderSkills!.assignments, before.leaderSkills!.assignments);
custody(moved);
}
});
void test('all four eligible Suk decisions follow voluntary nested pair reveal and settled physical casualties before optional Tech transfer', () => {
const f = fixture({ advanced: true, tech: true, strongholds: true }), before = close(f).game;
const shipped = settle(applyAction(reload(before), f.richese, pairAction(before, f.richese)));
const battle = prepareBattle(f, shipped);
let game = battle.game;
for (const plan of battle.plans) game = settle(step(game, plan));
const revealed = reload(game), loserTokens = ownedTech(game.techTokens, f.guild);
assert.equal(player(game, f.richese).forces[battle.key], 8);
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(game.decision?.player, f.richese);
if (game.decision?.kind !== 'sukRescue') throw Error('The genuinely trained battle must reach its actual rescue decision.');
const pending = game, decision = game.decision;
reject(pending, f.guild, { type: 'decision', event: decision.event, choice: 0 });
reject(pending, f.richese, { type: 'decision', event: 'stale-rescue', choice: 0 });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.richese); view.players.find(p => p.id === f.richese)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'decision' && typeof a.choice === 'number'); assert.ok(action && typeof action.choice === 'number');
const rescue = decision.options[action.choice]; assert.ok(rescue);
const done = finishBattle(applyAction(reload(pending), f.richese, action));
const kept = rescue.kept?.kind === 'normal' ? 1 : 0;
assert.equal(player(done, f.richese).forces[battle.key], 6 + kept);
assert.equal(player(done, f.richese).reserves, 12 + rescue.normal - kept);
assert.equal(player(done, f.richese).tanks, 2 - rescue.normal);
assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 3);
assert.equal(done.lastBattleContext!.winner, f.richese);
assert.equal(player(done, f.richese).noField!.deployed, null);
assert.deepEqual(done.leaderSkills!.assignments, before.leaderSkills!.assignments);
for (const token of loserTokens) assert.equal(done.techTokens![token].owner, f.richese);
custody(done);
}
});
void test('held Arrakeen support affects only its proper payer leg, not an unrelated nested Shrine entitlement', () => {
const f = fixture({ advanced: true, tech: true, strongholds: true, arrakeenOwner: 'richese' }), ending = close(f);
assert.equal(ending.game.strongholdCards!.owners.arrakeen, f.richese);
const nested = settle(applyAction(reload(ending.game), f.richese, pairAction(ending.game, f.richese)));
const battle = prepareBattle(f, nested);
let game = battle.game;
for (const plan of battle.plans) game = settle(step(game, plan));
const wallet = player(game, f.richese).spice;
const committed = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(committed, f.richese).spice, wallet - 2, 'Arrakeen held card cannot fund support at another source.');
assert.equal(committed.strongholdCards!.owners.arrakeen, f.richese);
const done = finishBattle(committed);
assert.equal(done.lastBattleContext!.winner, f.richese);
assert.equal(player(done, f.richese).forces['shrine:0'], 7);
custody(done);
});
void test('actual held Arrakeen bank support changes its payer debit without increasing trained dial or rescue', () => {
const f = fixture({ advanced: true, tech: true, strongholds: true, arrakeenOwner: 'richese' }), before = close(f).game;
const key = `arrakeen:${territory('arrakeen').sectors[0]}`, own = player(before, f.richese);
assert.equal(before.strongholdCards!.owners.arrakeen, f.richese);
own.reserves += own.forces[key]; delete own.forces[key];
f.staging.push('Conserve the original Arrakeen claiming counter back to native reserves after its real END Mentat claim; retain the actually held card, never assign custody.');
const shipped = settle(applyAction(reload(before), f.richese, pairAction(before, f.richese, 'arrakeen')));
const battle = prepareBattle(f, shipped);
let game = battle.game;
reject(game, f.richese, { ...battle.plans[0].action, dial: 9, support: 9 });
for (const plan of battle.plans) game = settle(step(game, plan));
const wallet = player(game, f.richese).spice;
const committed = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(committed, f.richese).spice, wallet, 'Held Arrakeen funds exactly the original declared support leg.');
assert.equal(committed.decision?.player, f.richese);
const done = finishBattle(committed);
assert.equal(done.lastBattleContext!.winner, f.richese);
assert.equal(player(done, f.richese).forces[key], 7);
assert.equal(player(done, f.richese).tanks, 0);
assert.equal(player(done, f.guild).tanks, 3);
assert.equal(done.strongholdCards!.owners.arrakeen, f.richese);
custody(done);
});
@@ -0,0 +1,329 @@
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 type { FactionId } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { ownedTech } from '../game/tech-tokens';
import {
initializeDiscoveryStandaloneNexusE3Skills as initialize,
completeDiscoveryStandaloneNexusE3SkillsSetup as complete,
createDiscoveryStandaloneNexusE3SkillsFixture as fixture,
closeDiscoveryStandaloneNexusE3Skills as close,
enterDiscoveryStandaloneNexusE3Skills as enter,
cunningDiscoveryStandaloneNexusE3Skills as cunning,
openDiscoveryStandaloneNexusE3SkillsBattle as open,
revealDiscoveryStandaloneNexusE3SkillsBattle as reveal,
settleDiscoveryStandaloneNexusE3SkillsBattle as boundary,
finishDiscoveryStandaloneNexusE3SkillsBattle as finish,
advanceDiscoveryStandaloneNexusE3Skills as advance,
stepDiscoveryStandaloneNexusE3Skills as step,
discoveryStandaloneNexusE3SkillsPlayer as player,
discoveryStandaloneNexusE3SkillsPolicy as policy,
discoveryStandaloneNexusE3SkillsClean as clean,
assertDiscoveryStandaloneNexusE3SkillsCustody as custody,
type DiscoveryStandaloneNexusE3SkillsOptions,
} from './fixture-discovery-standalone-nexus-e3-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
/** Consume actual physical loss/rescue choices; do not hide the later owner choice. */
function aftermath(state: Game, callers: readonly string[] = []): Game {
let next = boundary(state, callers);
for (let count = 0; count < 20 && (next.kind === 'losses' || next.kind === 'rescue'); count++) {
const d = next.game.decision!;
if (d.kind === 'battleLosses') next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', choice: 0 } }), callers);
else if (d.kind === 'sukRescue') {
const max = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === max);
next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', event: d.event, choice } }), callers);
}
}
return next.game;
}
void test('original one-native Basic/Advanced 2–6 setup trains eligible own discs and preserves supplied original hands and offers', () => {
for (const native of ['ecaz', 'moritani'] as const) for (const rules of ['basic', 'advanced'] as const)
for (const count of [2, 3, 4, 5, 6]) {
const factions: FactionId[] = [native, 'guild', 'emperor', 'fremen', 'beneGesserit', rules === 'basic' ? 'harkonnen' : 'atreides'];
const options: DiscoveryStandaloneNexusE3SkillsOptions = { native, rules, factions: factions.slice(0, count),
seatIds: factions.slice(0, count).map((f, n) => `${native}-${rules}-${count}-${n}-${f}`),
tech: count >= 3, strongholds: rules === 'advanced' };
if (native === 'ecaz' && rules === 'basic' && count % 2) { assert.throws(() => initialize(options)); continue; }
const offered = advance(initialize(options), g => g.setupStage === 'leaderSkills');
const owner = offered.players.find(p => p.faction === native)!.id;
const original = structuredClone(offered), continued = initialize({ initial: offered });
assert.deepEqual(continued, original);
const trained = complete(continued, { initial: offered });
assert.deepEqual(trained.players.map(p => p.id), original.players.map(p => p.id));
for (const p of original.players) assert.deepEqual(player(trained, p.id).hand, p.hand);
assert.ok(trained.leaderSkills!.assignments.every(a => original.leaderSkills!.offers[a.owner].cards.includes(a.skill) &&
player(original, a.owner).leaders.some(l => l.id === a.leader)));
const assignment = trained.leaderSkills!.assignments.find(a => a.owner === owner)!;
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
if (rules === 'basic') assert.equal(trained.moritaniAssassinate, undefined);
if (count === 2) {
const f = fixture({ initial: offered });
assert.equal(viewGame(f.game, owner).discoveryEntry!.owner, owner);
if (rules === 'advanced') {
assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null);
assert.equal(f.game.strongholdCards!.owners[f.claim], owner);
}
assert.throws(() => close(f), /two other real alliance seats/);
}
custody(trained);
}
});
void test('source guards reject Advanced Harkonnen, paired/mixed E3, variants, shared Duke and foreign/captured training without consuming offers', () => {
for (const native of ['ecaz', 'moritani'] as const) {
for (const other of ['harkonnen', native === 'ecaz' ? 'moritani' : 'ecaz', 'ixians', 'choam', 'richese', 'tleilaxu'] as FactionId[])
assert.throws(() => initialize({ native, factions: [native, 'guild', other] }));
assert.throws(() => initialize({ native, rules: 'basic', strongholds: true }));
const offered = initialize({ native }), owner = offered.players.find(p => p.faction === native)!.id;
const own = viewGame(offered, owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s])!;
const foreign = offered.players.find(p => p.id !== owner)!.leaders[0].id;
for (const leader of [DUKE_VIDAL_ID, foreign]) reject(offered, owner,
{ type: 'leaderSkill', event: own.offer!.event, skill, leader });
const captured = structuredClone(offered), disc = player(captured, owner).leaders.find(l => l.id === own.eligibleLeaders[0].id)!;
// Rejected counterfactual only: no captured state is accepted as played history.
disc.capturedBy = offered.players.find(p => p.id !== owner)!.id;
reject(captured, owner, { type: 'leaderSkill', event: own.offer!.event, skill, leader: disc.id });
assert.throws(() => initialize({ initial: { ...offered, ecazTreachery: true } }));
assert.throws(() => initialize({ initial: { ...offered, homeworlds: {} as NonNullable<Game['homeworlds']> } }));
custody(offered);
}
});
void test('human lobby/setup actors and first hands survive the actual Discovery reveal and pre-Storm free entry, not a redeal or a paid shipment', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const lobby = createGame(`HUMAN-${native}`, newPlayer(`human-${native}`, native, native), true, ['ecaz']);
joinGame(lobby, newPlayer('human-guild', 'Guild', 'guild')); joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen'));
const original = structuredClone(lobby), offered = initialize({ initial: lobby }), f = fixture({ initial: offered });
assert.deepEqual(lobby, original); assert.deepEqual(f.setup, offered);
assert.equal(f.owner, `human-${native}`); assert.equal(f.opponent, 'human-guild');
for (const p of offered.players) assert.deepEqual(player(f.game, p.id).hand, p.hand);
const own = viewGame(f.game, f.owner), source = player(f.game, f.owner).forces[f.source];
assert.equal(own.discoveryEntry!.owner, f.owner);
assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.before.phase, 8);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
reject(f.game, f.opponent, { type: 'decision', event: own.discoveryEntry!.event, accept: false });
reject(f.firstMentat.before, f.owner, { type: 'nexusCardChoice', turn: 1, card: null, choice: 'draw', ownRedraws: 0 });
const entered = enter(f), funds = player(f.game, f.owner).spice;
assert.equal(player(entered, f.owner).forces['cistern:0'], 3);
assert.equal(player(entered, f.owner).forces[f.source] ?? 0, source - 3);
assert.equal(player(entered, f.owner).spice, funds);
assert.equal(player(entered, f.owner).shipped, player(f.game, f.owner).shipped);
assert.deepEqual(entered.leaderSkills, f.game.leaderSkills); custody(entered);
}
});
void test('Maker losses, all votes, typed reserve ride, settled alliances and both Advanced piles precede the only native closing draw', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const f = fixture({ native, tech: true, strongholds: true }), closing = close(f);
assert.equal(player(closing.vote, f.owner).tanks, player(closing.entered, f.owner).tanks + 6);
assert.equal(player(closing.vote, f.owner).forces[f.wormSource] ?? 0, 0);
assert.equal(player(closing.vote, f.owner).forces['cistern:0'], 3);
assert.equal(closing.vote.nexusCards!.cards!.hands[f.owner], null);
reject(closing.vote, f.owner, closing.draw.step.action);
const ride = closing.ride; assert.ok(ride && f.fremen);
const rider = player(ride.before, f.fremen);
assert.equal(player(ride.after, f.fremen).reserves, rider.reserves - 2);
assert.equal(player(ride.after, f.fremen).elites!.reserves, rider.elites!.reserves - 1);
assert.equal(player(ride.after, f.fremen).forces['polar_sink:0'], 2);
assert.equal(player(ride.after, f.fremen).elites!.forces['polar_sink:0'], 1);
assert.equal(player(ride.after, f.fremen).spice, rider.spice);
assert.equal(closing.draw.before.phase, 1); assert.equal(closing.draw.before.spiceWindow, null);
assert.equal(closing.draw.before.spiceResolution, null); assert.equal(closing.draw.before.nexus, false);
assert.equal(player(closing.alliance, f.owner).ally, null);
assert.equal(player(closing.alliance, f.opponent).ally, f.fremen);
assert.equal(closing.draw.after.nexusCards!.cards!.hands[f.owner], native);
assert.equal(closing.draw.after.nexusCards!.cards!.deck.includes(native), false);
reject(closing.draw.after, f.owner, closing.draw.step.action);
assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null);
assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.owner).length, 1);
custody(closing.game);
}
});
void test('borrowed Emperor free-return remains source guarded for either native even with real Maker casualties and an actually drawn card', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const f = fixture({ native }), closing = close(f, 'emperor');
const revival = advance(closing.game, g => g.phase === 4 && clean(g));
assert.equal(player(revival, f.owner).tanks, 6);
const offer = viewGame(revival, f.owner).nexusEmperorSecretAlly; assert.ok(offer && offer.revival.blocked);
const action: Action = { type: 'nexusEmperorRevive', event: offer.event, elite: 0 };
reject(revival, f.owner, action);
assert.equal(revival.nexusCards!.cards!.hands[f.owner], 'emperor');
assert.equal(player(revival, f.owner).freeForcesRevived, 0);
custody(revival);
}
});
void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
const f = fixture({ native: 'ecaz' }), closing = close(f), acquired = cunning(f, closing.game);
assert.equal(acquired.before.status, 'playing');
assert.equal(acquired.before.turn, closing.game.turn);
assert.equal(acquired.before.phase, 6);
assert.equal(acquired.before.battle, null);
assert.ok(viewGame(acquired.before, f.owner).battleChoices.length > 0,
'Physical opponents keep the original Battle chooser open before quiet Cunning.');
assert.equal(acquired.before.dukeVidal!.controller, null);
assert.equal(acquired.after.dukeVidal!.controller, f.owner);
assert.equal(acquired.after.dukeVidal!.leader.dead, false);
assert.equal(acquired.after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(acquired.after.leaderSkills, acquired.before.leaderSkills);
assert.ok(acquired.after.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
for (const property of ['dead', 'capturedBy', 'gholaBy', 'usedAt'] as const) {
const input = structuredClone(acquired.before);
if (property === 'dead') input.dukeVidal!.leader.dead = true;
else if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
else input.dukeVidal!.leader[property] = f.opponent;
reject(input, f.owner, acquired.step.action);
}
const battle = open(f, closing.game);
reject(battle.game, f.owner, acquired.step.action);
const revealed = reveal(battle), cleanup = aftermath(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, f.owner);
assert.equal(player(cleanup, f.owner).forces[battle.location], 5);
assert.equal(player(cleanup, f.opponent).forces[battle.location] ?? 0, 0);
assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
assert.equal(cleanup.dukeVidal!.controller, f.owner, 'The actual native trained disc was used; the separate Duke was not set aside.');
const done = finish(step(cleanup, { actor: f.owner, action: { type: 'decision', discard: [] } }));
assert.equal(player(done, f.owner).hand.some(c => c.id === battle.card), true);
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.leader)!.usedAt, 'cistern');
custody(done);
});
void test('mandatory selected-lead Occupy stays a real battle with owner-labelled commitments, native skill custody, payments, cleanup and Tech', () => {
for (const lead of ['native', 'ally'] as const) {
const f = fixture({ native: 'ecaz', factions: ['ecaz', 'guild', 'emperor'], tech: true, strongholds: true }), closing = close(f);
const acquired = cunning(f, closing.game), battle = open(f, acquired.after, { occupy: true, lead });
const choice = battle.leadChoice; assert.ok(choice);
assert.equal(choice.before.status, 'playing');
assert.equal(choice.before.turn, acquired.after.turn + 1);
assert.equal(choice.before.decision!.kind, 'ecazBattleLead'); assert.equal(choice.before.decision!.player, f.owner);
reject(choice.before, f.opponent, choice.step.action);
reject(choice.before, f.owner, { ...choice.step.action, lead: f.opponent });
assert.equal(battle.game.battle!.ecazOccupy!.lead, battle.actor);
assert.equal(battle.game.battle!.plans[battle.actor], undefined);
assert.notEqual(battle.leader, DUKE_VIDAL_ID);
if (lead === 'ally') reject(battle.game, battle.actor, { ...battle.plans[0].action, leader: f.leader });
const assignments = structuredClone(battle.game.leaderSkills!.assignments), revealed = reveal(battle);
const cleanup = aftermath(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, battle.actor);
assert.equal(player(cleanup, f.owner).forces[battle.location], 1);
assert.equal(player(cleanup, f.owner).tanks, player(revealed, f.owner).tanks + 2);
const ally = player(revealed, f.owner).ally!;
assert.equal(player(cleanup, ally).forces[battle.location], 5);
assert.equal(player(cleanup, ally).tanks, player(revealed, ally).tanks + 1);
assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
assert.equal(player(cleanup, battle.actor).spice, player(revealed, battle.actor).spice - 1);
const companion = battle.actor === f.owner ? ally : f.owner;
assert.equal(player(cleanup, companion).spice, player(revealed, companion).spice);
assert.equal(player(cleanup, f.opponent).spice, player(revealed, f.opponent).spice - 1);
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Actual winner card custody precedes optional winner Tech.');
const done = finish(step(cleanup, { actor: battle.actor, action: { type: 'decision', discard: [battle.card] } }));
assert.equal(done.discard.filter(c => c.id === battle.card).length, 1);
assert.equal(player(done, battle.actor).hand.some(c => c.id === battle.card), false);
assert.deepEqual(done.leaderSkills!.assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })),
assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })));
for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(done.techTokens![token].owner, battle.actor);
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
custody(done);
}
});
void test('Advanced Moritani after-loss assassination kills the actual unused trained disc, pays printed bounty, returns its skill and replaces only the private Traitor at actual Mentat', () => {
const f = fixture({ native: 'moritani', tech: true, strongholds: true }), closing = close(f), battle = open(f, closing.game);
assert.ok(f.target);
assert.ok(player(f.trained, f.owner).traitors.includes(f.target));
assert.ok(f.actions.some(s => s.actor === f.owner && s.action.type === 'traitor' && s.action.leader === f.target),
'The trained enemy disc is selected through the original pending Traitor offer.');
const revealed = reveal(battle), pending = aftermath(revealed);
assert.equal(pending.decision?.kind, 'moritaniAssassinate'); assert.equal(pending.decision!.player, f.owner);
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 6);
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
const originalSkill = pending.leaderSkills!.assignments.find(a => a.leader === target.id)!.skill;
assert.equal(target.dead, false); assert.equal(target.usedAt, undefined);
const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
const action: Action = { type: 'decision', event: decision.event, card: target.id };
reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader });
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
assert.equal(quote.bounty, 3);
const killed = step(pending, { actor: f.owner, action });
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3);
assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false);
assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1);
assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent);
assert.deepEqual(killed.techTokens, pending.techTokens);
let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } }));
for (const card of [battle.card, battle.enemyCard!]) assert.equal(done.discard.filter(c => c.id === card).length, 1);
for (const token of ownedTech(revealed.techTokens, f.owner)) assert.equal(done.techTokens![token].owner, f.opponent);
done = advance(done, g => g.decision?.kind === 'moritaniPlacement');
assert.deepEqual(done.traitorReserve, reserve.slice(1));
assert.deepEqual(player(done, f.owner).traitors, [...held.filter(c => c !== target.id), reserve[0]]);
assert.equal('replacement' in viewGame(done, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assert.equal(done.leaderSkills!.offers[f.opponent], undefined);
reject(done, f.owner, action);
const placed = cunning(f, done);
assert.equal(placed.after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm');
assert.deepEqual(placed.after.leaderSkills, done.leaderSkills);
custody(placed.after);
});
void test('normal Traitor call forfeits Advanced assassination; Basic losses never grant its power, while original Cunning remains available', () => {
const advanced = fixture({ native: 'moritani' }), closing = close(advanced);
const called = open(advanced, closing.game, { normalCall: true }), revealed = reveal(called);
assert.ok(player(advanced.trained, advanced.owner).traitors.includes(called.enemyLeader));
assert.equal(called.enemyLeader, advanced.target);
let done = aftermath(revealed, [advanced.owner]);
assert.notEqual(done.decision?.kind, 'moritaniAssassinate');
done = finish(done);
assert.equal(player(done, advanced.opponent).leaders.find(l => l.id === called.enemyLeader)!.dead, true);
const held = [...player(done, advanced.owner).traitors], reserve = [...done.traitorReserve!];
done = advance(done, g => g.decision?.kind === 'moritaniPlacement');
assert.deepEqual(player(done, advanced.owner).traitors, held); assert.deepEqual(done.traitorReserve, reserve);
custody(done);
const basic = fixture({ native: 'moritani', rules: 'basic', tech: true }), basicClose = close(basic);
const battle = open(basic, basicClose.game), basicReveal = reveal(battle), loss = aftermath(basicReveal);
assert.equal(loss.moritaniAssassinate, undefined);
assert.equal(player(loss, basic.owner).spice, player(basicReveal, basic.owner).spice);
reject(loss, basic.owner, { type: 'decision', event: 'not-earned', card: basic.target });
assert.equal(player(loss, basic.opponent).leaders.find(l => l.id === basic.target)!.dead, false);
const finished = finish(loss), placed = cunning(basic, finished);
assert.equal(placed.after.nexusCards!.cards!.hands[basic.owner], null);
assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm');
assert.equal(placed.after.moritaniAssassinate, undefined); custody(placed.after);
});
void test('all four minimal policies act for the eligible native Cunning/assassination and cleanup owner with physical consumer outcomes', () => {
const ecaz = fixture({ native: 'ecaz' }), ecazClosing = close(ecaz), ecazCunning = cunning(ecaz, ecazClosing.game);
const moritani = fixture({ native: 'moritani', tech: true }), moritaniClosing = close(moritani);
const battle = open(moritani, moritaniClosing.game), pending = aftermath(reveal(battle));
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
for (const difficulty of DIFFICULTIES) {
const ecazAction = policy(ecazCunning.before, ecaz.owner, difficulty)[0]; assert.ok(ecazAction);
const acquired = step(structuredClone(ecazCunning.before), { actor: ecaz.owner, action: ecazAction });
assert.equal(acquired.dukeVidal!.controller, ecaz.owner); assert.equal(acquired.nexusCards!.cards!.hands[ecaz.owner], null);
assert.deepEqual(acquired.leaderSkills, ecazCunning.before.leaderSkills);
const action = policy(pending, pending.decision!.player, difficulty)[0]; assert.ok(action);
let game = step(structuredClone(pending), { actor: pending.decision!.player, action });
assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true);
assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3);
assert.equal(game.decision?.kind, 'battleCards');
const cleanupActor = game.decision!.player, cleanup = policy(game, cleanupActor, difficulty)[0]; assert.ok(cleanup);
game = finish(step(game, { actor: cleanupActor, action: cleanup }));
assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false);
game = advance(game, g => g.decision?.kind === 'moritaniPlacement');
assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === moritani.target), false);
custody(game);
}
});
@@ -0,0 +1,362 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { gameDistance, gameTerritories, MOBILE_LOCATION, splitLocation, territory } from '../game/board';
import { isAuditorLeader } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { traitorDeck } from '../game/traitors';
import { nextPairedIxNexusSkillsModulesStep } from './fixture-paired-ix-nexus-skills-modules';
import {
pairedDiscoveryNexusClean as clean, pairedDiscoveryNexusPlayer,
placePairedDiscoveryNexus, reservePairedDiscoveryNexusBoard,
type PairedDiscoveryNexusClosing, type PairedDiscoveryNexusFixture,
type PairedDiscoveryNexusTransition,
} from './fixture-discovery-paired-nexus';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type DiscoveryPairedNexusE1SkillsStep = { actor: string; action: Action };
export type DiscoveryPairedNexusE1SkillsOptions = {
/** Authenticated original lobby or unassigned original turn-one setup. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
/** An explicit discipline must belong to the actual original offer. */
skill?: LeaderSkillId;
};
export type DiscoveryPairedNexusE1SkillsSetup = {
initial: Game; setup: Game; offered: Game; afterSetup: Game; game: Game;
owner: string; native: string; guild: string; fremen: string;
trainer: string; skill: LeaderSkillId;
actions: DiscoveryPairedNexusE1SkillsStep[]; staging: string[];
};
export type DiscoveryPairedNexusE1SkillsEntry = PairedDiscoveryNexusFixture & {
offered: Game; trainer: string; skill: LeaderSkillId;
};
export type DiscoveryPairedNexusE1SkillsBattle = {
entry: DiscoveryPairedNexusE1SkillsEntry; closing: PairedDiscoveryNexusClosing;
beforeBattle: Game; battleStep: DiscoveryPairedNexusE1SkillsStep; game: Game;
owner: string; opponent: string; trainer: string; skill: LeaderSkillId;
enemy: string; location: 'shrine:0'; replacementSource: string;
/** Only original, actually dealt cards are eligible; none is injected. */
weapon: string | null; defense: string | null;
plans: DiscoveryPairedNexusE1SkillsStep[];
actions: DiscoveryPairedNexusE1SkillsStep[]; staging: string[];
};
export type DiscoveryPairedNexusE1SkillsBoundary =
'losses' | 'rescue' | 'substitution' | 'cards' | 'tech' | 'faceDance' | 'complete';
export const discoveryPairedNexusE1SkillsPlayer = pairedDiscoveryNexusPlayer;
/** Original schedulers handle shared responses first. Returned controls never
* choose an owned free entry, vote, ride, Cunning, private plan or Face Dance. */
export function nextDiscoveryPairedNexusE1SkillsStep(game: Game): DiscoveryPairedNexusE1SkillsStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
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 === '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 } };
}
if (d?.kind === 'faceDance' || d?.kind === 'strongholdCopy') return null;
}
return nextPairedIxNexusSkillsModulesStep(game);
}
export function stepDiscoveryPairedNexusE1Skills(game: Game, step: DiscoveryPairedNexusE1SkillsStep,
actions?: DiscoveryPairedNexusE1SkillsStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceDiscoveryPairedNexusE1Skills(state: Game, until: (game: Game) => boolean,
actions?: DiscoveryPairedNexusE1SkillsStep[]): Game {
let game = structuredClone(state);
for (let n = 0; n < 2400; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextDiscoveryPairedNexusE1SkillsStep(game);
assert.ok(step, 'Original paired E1 needs its owned human plan, copy or matching Face Dance');
game = stepDiscoveryPairedNexusE1Skills(game, step, actions);
}
throw Error('Original paired E1 Discovery/Skills programme did not reach its human boundary');
}
/** Bounded original shuffle control. Byte/UUID entropy is forwarded unchanged. */
function withShuffle<T>(operation: () => T, source: string[], desired: string[]): T {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[from]] = [working[from], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) for (let i = 0; i < array.length; i++) array[i] = 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');
}
}
/** Starting hands precede Skills. Supplied hands/offers and actor IDs are
* authoritative. Only an original undealt shuffle is controlled, never redealt. */
export function initializeDiscoveryPairedNexusE1SkillsSetup(
options: DiscoveryPairedNexusE1SkillsOptions = {},
): DiscoveryPairedNexusE1SkillsSetup {
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('DISCOVERYPAIREDE1SKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'fremen'] as const)
joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.players.map(p => p.faction).sort(), ['ixians', 'tleilaxu', 'guild', 'fremen'].sort());
assert.deepEqual(game.expansions, ['ix']); assert.equal(game.advanced, advanced);
assert.ok(!game.homeworlds && !game.ecazTreachery);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p => !p.traitors.length && !p.faceDancers?.length &&
p.leaders.every(l => !l.dead && !l.usedAt)), 'Only a genuine original lobby or unassigned native setup may continue');
const initial = structuredClone(game), actions: DiscoveryPairedNexusE1SkillsStep[] = [], staging: string[] = [];
const seat = (faction: string) => game.players.find(p => p.faction === faction)!.id;
const owner = seat('ixians'), native = seat('tleilaxu'), guild = seat('guild'), fremen = seat('fremen');
const requested = options.skill ?? 'suk-graduate';
const passives: LeaderSkillId[] = (['sandmaster', 'smuggler', 'prana-bindu-adept', 'killer-medic'] as LeaderSkillId[])
.filter(skill => skill !== requested);
const selected: LeaderSkillId[] = game.players.map(p => p.id === owner ? requested : passives.shift()!);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills);
for (const [type, enabled, present] of [
['techTokens', tech, !!game.techTokens], ['strongholdCards', strongholds, !!game.strongholdCards],
] as const) if (enabled !== present)
game = stepDiscoveryPairedNexusE1Skills(game, { actor: game.host, action: { type, enabled } }, actions);
game.discoveryEnabled = true; game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
for (const p of game.players) if (!p.ready)
game = stepDiscoveryPairedNexusE1Skills(game, { actor: p.id, action: { type: 'ready' } }, actions);
const source = LEADER_SKILL_CARDS.map(c => c.id), remainder = source.filter(s => !selected.includes(s));
const desired = selected.flatMap(s => [s, remainder.shift()!]);
game = withShuffle(() => initializeLeaderSkillsGameForAudit(game), source, [...desired, ...remainder]);
staging.push('Controlled original all14 shuffle before original starting-card selection; native all47 first hands and actual two-card offers are never replaced.');
}
assert.ok(pairedNexusLeaderSkillsProfile(game)); assert.equal(game.discoveryEnabled, true);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
assert.ok(game.discoveries && game.nexusCards?.cards && game.leaderSkills);
const setup = structuredClone(game); let offered = structuredClone(game), trainer = '';
for (let n = 0; game.status === 'setup' && n < 250; n++) {
let step: DiscoveryPairedNexusE1SkillsStep;
if (game.setupStage === 'leaderSkills') {
if (offered.setupStage !== 'leaderSkills') offered = structuredClone(game);
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const projection = viewGame(game, actor).leaderSkills!;
const legal = offer.cards.filter(c => !projection.unavailableSkills?.[c]);
const desired = selected[game.players.findIndex(p => p.id === actor)];
const skill = actor === owner && options.skill ? legal.find(c => c === options.skill)
: options.initial?.status === 'setup' ? legal[0] : legal.find(c => c === desired) ?? legal[0];
const eligible = new Set(projection.eligibleLeaders.map(l => l.id));
const leader = pairedDiscoveryNexusPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader, 'Explicit training must be available in the original offer on an original eligible living disc');
if (actor === owner) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const next = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(next); step = next;
if (game.setupStage === 'traitors' && step.action.type === 'traitor') {
const card = pairedDiscoveryNexusPlayer(game, step.actor).traitorChoices.find(id => id !== trainer);
assert.ok(card); step.action.leader = card;
}
}
if (game.setupStage === 'forces') {
const held = new Set(game.players.flatMap(p => p.traitors));
const source = traitorDeck(game.players, true).filter(id => !held.has(id));
assert.ok(source.includes(trainer)); actions.push(structuredClone(step));
game = withShuffle(() => applyAction(game, step.actor, step.action), source, [trainer, ...source.filter(id => id !== trainer)]);
staging.push('Controlled original undealt Traitor remainder shuffle before actual native three-Face-Dancer draw; no held identity, reveal flag or replacement history is assigned.');
} else game = stepDiscoveryPairedNexusE1Skills(game, step, actions);
}
assert.equal(game.status, 'playing');
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
return { initial, setup, offered, afterSetup: structuredClone(game), game, owner, native, guild, fremen,
trainer: assignment.leader, skill: assignment.skill, actions, staging };
}
/** Printed blow, actual paid typed collection and real inspection/reveal precede
* first END Mentat. The endpoint is free entry BEFORE next Storm, not a fake clock. */
export function createDiscoveryPairedNexusE1SkillsEntry(
options: DiscoveryPairedNexusE1SkillsOptions = {},
): DiscoveryPairedNexusE1SkillsEntry {
const s = initializeDiscoveryPairedNexusE1SkillsSetup(options);
const { owner, native, guild, fremen, trainer, skill, actions, staging } = s;
let game = s.game;
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 ordered = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')];
if (game.advanced) ordered.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
ordered.push(take(c => 'worm' in c && !!c.greatMaker));
ordered.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
if (game.advanced) ordered.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
game.spiceDeck.unshift(...ordered);
staging.push('Conserved original unplayed Spice order: printed Hagga Basin and optional Advanced Broken Land, then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.');
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 1 && clean(g), actions);
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextDiscoveryPairedNexusE1SkillsStep(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');
staging.push('Actual original supply-token lottery selects Shrine at the printed first blow; no token face, status or location is assigned.');
const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'], source = `${printed.territory}:${printed.sector}`;
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 5 && g.active === owner && clean(g), actions);
game = stepDiscoveryPairedNexusE1Skills(game, { actor: owner, action: { type: 'ship', territory: printed.territory,
sector: printed.sector, amount: 3, elite: 1, allyPayment: 0 } }, actions);
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 7 && clean(g), actions);
const collection = structuredClone(game);
for (const reveal of [false, true]) game = stepDiscoveryPairedNexusE1Skills(game,
{ actor: owner, action: { type: 'discovery', token: token.id, reveal } }, actions);
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 8 && clean(g), actions);
reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]);
placePairedDiscoveryNexus(game, owner, `arrakeen:${territory('arrakeen').sectors[0]}`, 1);
placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2);
staging.push('Before real END Mentat, conserve unrelated counters into original reserves, retain paid parent/HMS groups, place one Arrakeen claim and two native Maker-source counters. No wallet, earned Card, training or phase is assigned.');
let firstMentat: PairedDiscoveryNexusTransition | undefined;
while (game.turn === 1) {
const before = structuredClone(game), step = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(step);
game = stepDiscoveryPairedNexusE1Skills(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.phase, 0); assert.equal(game.decision!.player, owner);
return { initial: s.initial, setup: s.setup, offered: s.offered, afterSetup: s.afterSetup, collection, firstMentat,
game, family: 'ix', owner, native, collector: owner, guild, fremen, token: token.id, source, claim: 'arrakeen',
trainer, skill, actions, staging };
}
/** Human typed entry, exact storm-order votes/ride and settled classic alliance
* precede the real end-Spice choice. Both Advanced piles are processed natively. */
export function closeDiscoveryPairedNexusE1Skills(f: DiscoveryPairedNexusE1SkillsEntry): PairedDiscoveryNexusClosing {
const before = structuredClone(f.game), offer = viewGame(before, f.owner).discoveryEntry!;
const step: DiscoveryPairedNexusE1SkillsStep = { actor: f.owner, action: { type: 'decision', event: offer.event,
accept: true, groups: [{ source: f.source, normal: 1, elite: 1 }] } };
let game = stepDiscoveryPairedNexusE1Skills(before, step, f.actions);
const entry = { before, step, after: structuredClone(game) };
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.greatMaker?.stage === 'vote', f.actions);
const vote = structuredClone(game);
while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryPairedNexusE1Skills(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);
const action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(action);
const rideStep = { actor: f.fremen, action }; game = stepDiscoveryPairedNexusE1Skills(game, rideStep, f.actions);
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
game = advanceDiscoveryPairedNexusE1Skills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), f.actions);
for (const [actor, target] of [[f.guild, f.fremen], [f.fremen, f.guild]])
game = stepDiscoveryPairedNexusE1Skills(game, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = structuredClone(game);
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions);
const cards = game.nexusCards!.cards!; assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu'));
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')];
f.staging.push('Conserved undealt all12 singleton order only; native Cunning hands are earned by actual unallied closing choices after both Spice piles and settled classic alliance.');
const drawing = structuredClone(game);
for (const actor of [f.owner, f.native]) game = stepDiscoveryPairedNexusE1Skills(game,
{ actor, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }, f.actions);
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions);
return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) };
}
/** Turn-two physical consumer, retaining original first hands. Eight Ix counters
* (six Suboids/two Cyborgs) make Suk rescue leave a genuine Cyborg Tank loss even
* under full Suboid Cunning. A second conflict keeps cleanup in native Battle. */
export function prepareDiscoveryPairedNexusE1SkillsBattle(f: DiscoveryPairedNexusE1SkillsEntry,
closing = closeDiscoveryPairedNexusE1Skills(f)): DiscoveryPairedNexusE1SkillsBattle {
let game = advanceDiscoveryPairedNexusE1Skills(closing.afterDraw, g => g.phase === 5 && clean(g), f.actions);
reservePairedDiscoveryNexusBoard(game, [MOBILE_LOCATION]);
const location = 'shrine:0';
placePairedDiscoveryNexus(game, f.owner, location, 8, 2);
placePairedDiscoveryNexus(game, f.native, location, 8);
placePairedDiscoveryNexus(game, f.native, f.source, 3);
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, f.native]) placePairedDiscoveryNexus(game, actor, key, 1);
const enemy = pairedDiscoveryNexusPlayer(game, f.native).leaders.filter(l => !l.dead && !isAuditorLeader(l) &&
!game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
const own = pairedDiscoveryNexusPlayer(game, f.owner);
const weapon = own.hand.find(c => c.kind === 'projectile' || c.kind === 'worthless')?.id ?? null;
const defense = own.hand.find(c => c.kind === 'shield')?.id ?? null;
assert.ok(pairedDiscoveryNexusPlayer(game, f.native).faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
f.staging.push('Before native Movement completion, conserve original counters into revealed Shrine, native parent replacement sources and second conflict. All held Treachery remains actually dealt; no card injection or identity exchange.');
game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 6 && clean(g), f.actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === f.native);
const battleStep: DiscoveryPairedNexusE1SkillsStep = { actor,
action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? f.native : f.owner } };
game = stepDiscoveryPairedNexusE1Skills(game, battleStep, f.actions);
const plans: DiscoveryPairedNexusE1SkillsStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 8,
support: game.advanced ? 2 : 0, weapon, defense } },
{ actor: f.native, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
];
return { entry: f, closing, game, beforeBattle, battleStep, owner: f.owner, opponent: f.native,
trainer: f.trainer, skill: f.skill, enemy: enemy.id, location, replacementSource: f.source,
weapon, defense, plans, actions: f.actions, staging: f.staging };
}
export function createDiscoveryPairedNexusE1SkillsBattle(options: DiscoveryPairedNexusE1SkillsOptions = {}): DiscoveryPairedNexusE1SkillsBattle {
return prepareDiscoveryPairedNexusE1SkillsBattle(createDiscoveryPairedNexusE1SkillsEntry(options));
}
export function prepareDiscoveryPairedNexusE1SkillsPlans(f: DiscoveryPairedNexusE1SkillsBattle, state = f.game): Game {
let game = structuredClone(state);
for (let n = 0; n < 160; n++) {
const step = nextDiscoveryPairedNexusE1SkillsStep(game);
if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === f.owner;
game = stepDiscoveryPairedNexusE1Skills(game, step, f.actions);
}
throw Error('Original paired E1 skill posture did not reach private plans');
}
export function beginDiscoveryPairedNexusE1SkillsCunning(f: DiscoveryPairedNexusE1SkillsBattle,
state = prepareDiscoveryPairedNexusE1SkillsPlans(f)): Game {
const offer = viewGame(state, f.owner).nexusSuboids?.offer; assert.ok(offer); assert.equal(offer.blocked, null);
return stepDiscoveryPairedNexusE1Skills(state, { actor: f.owner, action: { type: 'nexusSuboids', event: offer.event } }, f.actions);
}
/** Suggested high-dial plans consume actual Cunning first. Humans may replace
* either suggested plan and explicitly opt out with their own legal dial. */
export function revealDiscoveryPairedNexusE1SkillsBattle(f: DiscoveryPairedNexusE1SkillsBattle,
options: { state?: Game; cunning?: boolean; plans?: DiscoveryPairedNexusE1SkillsStep[] } = {}): Game {
let game = prepareDiscoveryPairedNexusE1SkillsPlans(f, options.state ?? f.game);
if (options.cunning !== false) game = advanceDiscoveryPairedNexusE1Skills(beginDiscoveryPairedNexusE1SkillsCunning(f, game), clean, f.actions);
for (const step of options.plans ?? f.plans) {
game = stepDiscoveryPairedNexusE1Skills(game, step, f.actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(next);
game = stepDiscoveryPairedNexusE1Skills(game, next, f.actions);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishDiscoveryPairedNexusE1SkillsBattle(state: Game,
stop: DiscoveryPairedNexusE1SkillsBoundary = 'complete', actions?: DiscoveryPairedNexusE1SkillsStep[]): Game {
const kinds: Record<DiscoveryPairedNexusE1SkillsBoundary, string> = { losses: 'battleLosses', rescue: 'sukRescue',
substitution: 'ixSubstitution', cards: 'battleCards', tech: 'techToken', faceDance: 'faceDance', complete: '' };
return advanceDiscoveryPairedNexusE1Skills(state, game => game.decision?.kind === kinds[stop] ||
stop === 'losses' && game.decision?.kind === 'sukRescue' ||
stop === 'tech' && game.decision?.kind === 'faceDance' && !game.pendingTech ||
!game.battle && clean(game) && !game.pendingTreacheryDiscard, actions);
}
/** Printed graph search only; the actual movement action validates its range. */
export function discoveryPairedNexusE1SkillsRangeSource(game: Game): string {
const source = gameTerritories(game).flatMap(t => t.type === 'sand' ? t.sectors.map(s => `${t.id}:${s}`) : [])
.find(key => gameDistance(game, key, 'shrine:0', candidate => splitLocation(candidate).sector === game.storm) === 3);
assert.ok(source, 'Revealed original nested graph must supply a three-territory Planetologist consumer');
return source;
}
@@ -0,0 +1,375 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES, territory } from '../game/board';
import type { Card } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import {
completePairedChoamSkillsSetup, initializePairedChoamNexusSkills, nextPairedChoamSkillsStep,
openPairedSkillsBattle, pairedSkillsBattlePlans, pairedSkillsClean,
pairedSkillsPlayer, placePairedSkillsForce, revealPairedSkillsRichese,
type PairedChoamNexusSkillsOptions,
} from './fixture-paired-choam-nexus-skills';
import { nextDiscoveryNativeE2SkillsStep } from './fixture-discovery-native-e2-skills';
import { applyPairedDiscoveryNexusStep, reservePairedDiscoveryNexusBoard } from './fixture-discovery-paired-nexus';
export type DiscoveryPairedNexusE2SkillsStep = { actor: string; action: Action };
export type DiscoveryPairedNexusE2SkillsOptions = PairedChoamNexusSkillsOptions & {
tech?: boolean;
strongholds?: boolean;
kind?: 'shrine' | 'stash';
arrakeenOwner?: 'richese' | 'choam';
};
export type DiscoveryPairedNexusE2SkillsTransition = {
before: Game; step: DiscoveryPairedNexusE2SkillsStep; after: Game;
};
export type DiscoveryPairedNexusE2SkillsAcquisition = DiscoveryPairedNexusE2SkillsTransition & {
buyer: string; card: Card;
};
export type DiscoveryPairedNexusE2SkillsFixture = {
initial: Game; afterSetup: Game; afterFirstStorm: Game; collection: Game;
previousMarker: DiscoveryPairedNexusE2SkillsTransition;
firstMentat: DiscoveryPairedNexusE2SkillsTransition | null;
game: Game; richese: string; choam: string; guild: string; fremen: string;
collector: string; token: string; face: DiscoveryTokenFace; source: string;
trainers: { richese: string; choam: string };
actions: DiscoveryPairedNexusE2SkillsStep[]; staging: string[];
acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[];
};
export type DiscoveryPairedNexusE2SkillsClosing = {
entry: DiscoveryPairedNexusE2SkillsTransition;
vote: Game; ride: DiscoveryPairedNexusE2SkillsTransition;
alliance: Game; drawing: Game; draws: DiscoveryPairedNexusE2SkillsTransition[]; movementStart: Game; game: Game;
};
export type DiscoveryPairedNexusE2SkillsBattle = {
game: Game; plans: DiscoveryPairedNexusE2SkillsStep[]; key: string;
};
export const discoveryPairedNexusE2SkillsClean = pairedSkillsClean;
export const discoveryPairedNexusE2SkillsPlayer = pairedSkillsPlayer;
/** Original selections only. This entry point also serves human two-to-six-seat
* starts; the longer paired-draw programme needs Guild and Fremen seats.
* Existing hands, IDs and all14 offers are never replaced or redealt. */
export function initializeDiscoveryPairedNexusE2Skills(options: DiscoveryPairedNexusE2SkillsOptions = {}): Game {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const factions = options.factions ?? ['richese', 'choam', 'guild', 'fremen'] as const;
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYPAIREDNEXUSE2SKILLS', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['choam']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
const tech = options.tech ?? !!game.techTokens;
const strongholds = options.strongholds ?? !!game.strongholdCards;
assert.equal(game.advanced, advanced);
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced.');
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;
} else {
assert.equal(game.discoveryEnabled, true);
assert.equal(!!game.techTokens, tech, 'Saved native module selection is authoritative.');
assert.equal(!!game.strongholdCards, strongholds);
}
// The existing initializer controls only fresh original offer entropy.
return initializePairedChoamNexusSkills({ ...options, initial: game, advanced,
richeseSkill: options.initial ? options.richeseSkill : options.richeseSkill ?? 'suk-graduate',
choamSkill: options.initial ? options.choamSkill : options.choamSkill ?? 'smuggler' });
}
/** Reuse native response, auction, Guild, skill and original Discovery producers.
* Owned entry, Maker and battle-plan endpoints remain explicit human actions. */
export function nextDiscoveryPairedNexusE2SkillsStep(game: Game): DiscoveryPairedNexusE2SkillsStep | null {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') return nextPairedChoamSkillsStep(game);
if (pairedSkillsClean(game) && game.nexusCards?.phase?.stage === 'drawing') return nextPairedChoamSkillsStep(game);
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 === 'discoveryDiscard') {
const card = pairedSkillsPlayer(game, d.player).hand[0]; assert.ok(card);
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
}
if (d.kind === 'faceDance') return null;
}
return nextDiscoveryNativeE2SkillsStep(game, true);
}
export function stepDiscoveryPairedNexusE2Skills(game: Game, step: DiscoveryPairedNexusE2SkillsStep,
actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
// Existing original scalar entropy; not a fake clock or a fabricated receipt.
return applyPairedDiscoveryNexusStep(game, step, actions);
}
export function advanceDiscoveryPairedNexusE2Skills(game: Game, until: (g: Game) => boolean,
actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
for (let n = 0; n < 2400; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextDiscoveryPairedNexusE2SkillsStep(game);
assert.ok(step, 'Continue the original human plan or Face Dance explicitly.');
game = stepDiscoveryPairedNexusE2Skills(game, step, actions);
}
throw Error('Original paired E2 Discovery/Skills programme did not reach its boundary.');
}
export function settleDiscoveryPairedNexusE2Skills(game: Game, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
return advanceDiscoveryPairedNexusE2Skills(game, g => pairedSkillsClean(g) &&
!g.pendingShipment && !g.pendingTreacheryDiscard && !g.pendingChoamWorthless, actions);
}
export function completeDiscoveryPairedNexusE2SkillsSetup(game: Game,
options: DiscoveryPairedNexusE2SkillsOptions = {}, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
return completePairedChoamSkillsSetup(game, options, actions);
}
function withToken<T>(game: Game, face: DiscoveryTokenFace, run: () => T): T {
const supply = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = supply.findIndex(t => t.face === face); assert.ok(index >= 0);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) * 0x100000000 / supply.length);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function orderSpice(game: Game, printed: string, staging: string[]): void {
const take = (matches: (c: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
const cards = [take(c => 'territory' in c && c.discovery === printed)];
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);
staging.push('Conserved original unplayed printed Discovery, optional Advanced Broken Land, Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings order.');
}
/** Only order an undealt physical card. Actual auction bids acquire it; never
* move cards from other hands/discards or manufacture a held support card. */
export function orderDiscoveryPairedNexusE2SkillsCard(game: Game, matches: (c: Card) => boolean, staging: string[]): Card {
const index = game.deck.findIndex(matches); assert.ok(index >= 0, 'The requested original card must still be undealt.');
const card = game.deck.splice(index, 1)[0]; game.deck.unshift(card);
staging.push(`Conserved original undealt ${card.id} at the next ordinary auction front; no held-card injection.`);
return card;
}
/** Preserve first hands. Buy only still-undealt requested cards through native
* Bidding; a genuine existing Karama holder remains the eligible responder. */
function prepareOrdinaryAcquisitions(game: Game, choam: string, fremen: string, staging: string[]): Map<string, string> {
const buys = new Map<string, string>();
if (!pairedSkillsPlayer(game, choam).hand.some(c => c.name === 'Kulon' && c.kind === 'worthless')) {
const card = game.deck.find(c => c.name === 'Kulon' && c.kind === 'worthless');
if (card) buys.set(orderDiscoveryPairedNexusE2SkillsCard(game, c => c.id === card.id, staging).id, choam);
}
if (!pairedSkillsPlayer(game, choam).hand.some(c => c.kind === 'special' && c.effect !== 'karama')) {
const card = game.deck.find(c => c.kind === 'special' && c.effect !== 'karama');
assert.ok(card);
buys.set(orderDiscoveryPairedNexusE2SkillsCard(game, c => c.id === card.id, staging).id, choam);
}
if (!game.players.some(p => p.faction !== 'richese' && p.hand.some(c => c.effect === 'karama'))) {
const card = orderDiscoveryPairedNexusE2SkillsCard(game, c => c.effect === 'karama', staging);
buys.set(card.id, fremen);
}
return buys;
}
function advanceWithAcquisitions(game: Game, until: (g: Game) => boolean, buys: Map<string, string>,
actions: DiscoveryPairedNexusE2SkillsStep[], acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[]): Game {
for (let n = 0; n < 2400; n++) {
if (until(game)) {
assert.equal(buys.size, 0, 'Every ordered original lot must have been bought by its intended eligible actor.');
return game;
}
let step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step);
const auction = game.auction, card = auction?.cards[auction.index];
const buyer = card && !game.currentAuctionSale ? buys.get(card.id) : undefined;
// The original no-spend producer emits passBid, not bid. Only the intended
// buyer raises; all other eligible actors use the native passBid action.
if (buyer && auction && (step.action.type === 'bid' || step.action.type === 'passBid'))
step = { actor: auction.active, action: auction.active === buyer && auction.bidder !== buyer
? { type: 'bid', amount: auction.bid + 1 } : { type: 'passBid' } };
const before = game;
game = stepDiscoveryPairedNexusE2Skills(game, step, actions);
if (buyer && card && pairedSkillsPlayer(game, buyer).hand.some(c => c.id === card.id)) {
assert.equal(auction!.bidder, buyer);
assert.equal(auction!.bid, 1);
assert.equal(pairedSkillsPlayer(game, buyer).spice, pairedSkillsPlayer(before, buyer).spice - 1);
acquisitions.push({ before: structuredClone(before), step: structuredClone(step),
after: structuredClone(game), buyer, card: structuredClone(card) });
buys.delete(card.id);
}
}
throw Error('Original card acquisition did not reach its boundary.');
}
/** Skill-first original start → zero-marker invoice/reveal → real paid typed
* collection → inspect/reveal. Stops before Stash disposal or next-turn entry.
* Board controls are labeled conserved positions, never claimed played history. */
export function createDiscoveryPairedNexusE2SkillsFixture(options: DiscoveryPairedNexusE2SkillsOptions = {}): DiscoveryPairedNexusE2SkillsFixture {
let game = initializeDiscoveryPairedNexusE2Skills(options);
const initial = structuredClone(game), actions: DiscoveryPairedNexusE2SkillsStep[] = [], staging: string[] = [
'Fresh offers use the existing original all14 shuffle control; saved offers and first hands are authoritative. Later original scalar lotteries use the existing paired Discovery scheduler.',
];
const acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[] = [];
game = completeDiscoveryPairedNexusE2SkillsSetup(game, options, actions);
const afterSetup = structuredClone(game);
const seat = (faction: string) => { const p = game.players.find(p => p.faction === faction); assert.ok(p); return p.id; };
const richese = seat('richese'), choam = seat('choam'), guild = seat('guild'), fremen = seat('fremen');
const face: DiscoveryTokenFace = options.kind === 'stash' ? 'treachery-card-stash' : 'shrine';
const printed = face === 'shrine' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
const placement = DISCOVERY_CARD_PLACEMENTS[printed], source = `${placement.territory}:${placement.sector}`;
const collector = face === 'shrine' ? guild : choam;
const buys = prepareOrdinaryAcquisitions(game, choam, fremen, staging);
orderSpice(game, printed, staging);
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 1 && pairedSkillsClean(g), actions);
const afterFirstStorm = structuredClone(game);
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === face && t.status === 'placed') && n < 100; n++) {
const step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step);
actions.push(structuredClone(step));
// Bypass the generic scalar wrapper for this exact native placement lottery.
game = withToken(game, face, () => applyAction(game, step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'));
game = advanceWithAcquisitions(game, g => g.phase === 5 && pairedSkillsClean(g), buys, actions, acquisitions);
let previousMarker: DiscoveryPairedNexusE2SkillsTransition | undefined;
let collectedShipment = false;
for (let n = 0; (!previousMarker || !collectedShipment) && n < 200; n++) {
assert.equal(game.turn, 1, 'Do not chase an already-passed original shipment seat into another turn.');
assert.equal(game.phase, 5);
if (pairedSkillsClean(game) && game.active === richese && !previousMarker) {
const before = structuredClone(game), p = pairedSkillsPlayer(game, richese);
const step: DiscoveryPairedNexusE2SkillsStep = { actor: richese, action: { type: 'ship',
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0],
noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent, allyPayment: 0 } };
game = settleDiscoveryPairedNexusE2Skills(stepDiscoveryPairedNexusE2Skills(game, step, actions), actions);
previousMarker = { before, step, after: structuredClone(game) };
game = revealPairedSkillsRichese(game, richese, actions);
} else if (pairedSkillsClean(game) && game.active === collector && !collectedShipment) {
game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'ship',
territory: placement.territory, sector: placement.sector, amount: 3, elite: 0, allyPayment: 0 } }, actions);
game = settleDiscoveryPairedNexusE2Skills(game, actions);
collectedShipment = true;
} else {
const step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step);
game = stepDiscoveryPairedNexusE2Skills(game, step, actions);
}
}
assert.ok(previousMarker && collectedShipment);
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 7 && pairedSkillsClean(g), actions);
const collection = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === face)!;
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
// The Stash branch returns the actual disposal endpoint without auto-discard.
if (face === 'treachery-card-stash') return { initial, afterSetup, afterFirstStorm, collection, previousMarker,
firstMentat: null, game, richese, choam, guild, fremen, collector, token: token.id, face, source, trainers, actions, staging, acquisitions };
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 8 && pairedSkillsClean(g), actions);
reservePairedDiscoveryNexusBoard(game, [source]);
placePairedSkillsForce(game, choam, 'hagga_basin:12', 2, staging);
placePairedSkillsForce(game, options.arrakeenOwner === 'richese' ? richese : choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging);
staging.push('Before actual END Mentat conserve unrelated original board groups to their native reserves; retain the paid parent group and stage CHOAM worm-source/Arrakeen counters. No custody, training or earned invoice is assigned.');
let firstMentat: DiscoveryPairedNexusE2SkillsTransition | undefined;
while (game.turn === 1) {
const mentatBefore = structuredClone(game), mentatStep = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(mentatStep);
game = stepDiscoveryPairedNexusE2Skills(game, mentatStep, actions);
if (game.turn === 2) firstMentat = { before: mentatBefore, step: mentatStep, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.phase, 0); assert.equal(game.decision!.player, collector);
return { initial, afterSetup, afterFirstStorm, collection, previousMarker, firstMentat, game,
richese, choam, guild, fremen, collector, token: token.id, face, source, trainers, actions, staging, acquisitions };
}
/** Complete actual end-Mentat free entry, Maker loss/vote/Fremen typed reserve
* ride, both Advanced Spice piles, reciprocal alliance and closing all12 draws. */
export function closeDiscoveryPairedNexusE2Skills(f: DiscoveryPairedNexusE2SkillsFixture): DiscoveryPairedNexusE2SkillsClosing {
assert.equal(f.face, 'shrine');
const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!;
const action = discoveryEntryMoveAction(viewGame(before, f.collector), offer.sources); assert.ok(action);
const step = { actor: f.collector, action };
let game = stepDiscoveryPairedNexusE2Skills(before, step, f.actions);
const entry = { before, step, after: structuredClone(game) };
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.greatMaker?.stage === 'vote', f.actions);
const vote = structuredClone(game);
while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryPairedNexusE2Skills(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), rideAction = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0);
assert.ok(rideAction);
const rideStep = { actor: f.fremen, action: rideAction };
game = stepDiscoveryPairedNexusE2Skills(game, rideStep, f.actions);
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
game = advanceDiscoveryPairedNexusE2Skills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), f.actions);
game = stepDiscoveryPairedNexusE2Skills(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions);
game = stepDiscoveryPairedNexusE2Skills(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions);
const alliance = structuredClone(game);
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), f.actions);
const cards = game.nexusCards!.cards!;
for (const face of ['richese', 'choam'] as const) assert.ok(cards.deck.includes(face));
cards.deck = ['richese', 'choam', ...cards.deck.filter(c => c !== 'richese' && c !== 'choam')];
f.staging.push('Conserved original unused all12 Richese/CHOAM order before actual qualified end-Spice draws; not a held-card grant.');
const drawing = structuredClone(game), draws: DiscoveryPairedNexusE2SkillsTransition[] = [];
for (const actor of [f.richese, f.choam]) {
const drawBefore = structuredClone(game), drawStep: DiscoveryPairedNexusE2SkillsStep = { actor,
action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = stepDiscoveryPairedNexusE2Skills(game, drawStep, f.actions);
draws.push({ before: drawBefore, step: drawStep, after: structuredClone(game) });
}
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 5 && pairedSkillsClean(g), f.actions);
const choam = pairedSkillsPlayer(game, f.choam), claim = `arrakeen:${territory('arrakeen').sectors[0]}`;
if (choam.forces[claim]) {
choam.reserves += choam.forces[claim]; delete choam.forces[claim];
f.staging.push('Conserve the original CHOAM Arrakeen claim counter to native reserves before Movement; its actually held Stronghold Card persists until real END Mentat. This does not claim movement history or add Ornithopter/Kulon mixed entitlement.');
}
const movementStart = structuredClone(game);
game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 5 && g.active === f.richese && pairedSkillsClean(g), f.actions);
return { entry, vote, ride, alliance, drawing, draws, movementStart, game };
}
export function discoveryPairedNexusE2SkillsCunningAction(game: Game, richese: string, destination = 'shrine'): Action {
const p = pairedSkillsPlayer(game, richese), offer = viewGame(game, richese).nexusRicheseCunning;
assert.ok(offer && !offer.blocked);
return { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[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 };
}
/** Keep face-up pair counters separate from the marker until actual voluntary
* reveal; mixed marker/ordinary battle dial remains a source guard. */
export function prepareDiscoveryPairedNexusE2SkillsBattle(f: DiscoveryPairedNexusE2SkillsFixture, shipped: Game,
trained = true): DiscoveryPairedNexusE2SkillsBattle {
const deployed = pairedSkillsPlayer(shipped, f.richese).noField!.deployed; assert.ok(deployed);
const destination = deployed.location.territory, key = `${destination}:${deployed.location.sector}`;
let game = revealPairedSkillsRichese(shipped, f.richese, f.actions);
assert.equal(pairedSkillsPlayer(game, f.richese).noField!.deployed, null);
if (destination !== 'shrine') placePairedSkillsForce(game, f.guild, key, 3, f.staging);
// The actual Guild free-entry group supplies opposing nested counters.
game = openPairedSkillsBattle(game, f.richese, f.guild, { territory: destination, band: trained ? 'skilled' : 'normal', actions: f.actions });
return { game, key, plans: pairedSkillsBattlePlans(game, f.richese, f.guild, 2, trained) };
}
export function finishDiscoveryPairedNexusE2SkillsBattle(game: Game, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game {
return advanceDiscoveryPairedNexusE2Skills(game, g => !g.battle && pairedSkillsClean(g) && !g.pendingTreacheryDiscard, actions);
}
export function discoveryPairedNexusE2SkillsInvoiceAction(game: Game): { action: Action; key: string } {
const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) &&
game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`))));
assert.ok(target);
return { action: { type: 'ship', territory: target.id, sector: target.sectors[0], amount: 6, elite: 0, allyPayment: 0, smuggler: true },
key: `${target.id}:${target.sectors[0]}` };
}
/** Actual holder, not a generic first actor. The receipt caller can stop on its
* signed native response and let each minimal policy pass/cancel a real card. */
export function discoveryPairedNexusE2SkillsKaramaHolder(game: Game, exclude?: string): Player {
const holder = game.players.find(p => p.id !== exclude && p.hand.some(c => c.effect === 'karama'));
assert.ok(holder, 'Acquire an actual original Karama through setup or Bidding.');
return holder;
}
@@ -0,0 +1,492 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { traitorDeck } from '../game/traitors';
import type { StrongholdId } from '../game/stronghold-cards';
import {
nextEcazNexusSkillsModulesStep, ecazNexusSkillsModulesCunningRequest,
} from './fixture-ecaz-nexus-skills-modules';
import {
nextMoritaniNexusSkillsModulesStep, moritaniNexusSkillsModulesPolicy,
moritaniNexusSkillsModulesRequest,
} from './fixture-moritani-nexus-skills-modules';
import { revealDiscoveryNativeE3SkillsPlans } from './fixture-discovery-native-e3-skills';
import { orderDiscoveryClassicNexusSkillsSpice } from './fixture-discovery-classic-nexus-skills';
import {
nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
} from './fixture-discovery-classic-nexus';
export type DiscoveryStandaloneNexusE3SkillsNative = 'ecaz' | 'moritani';
export type DiscoveryStandaloneNexusE3SkillsStep = { actor: string; action: Action };
export type DiscoveryStandaloneNexusE3SkillsTransition = {
before: Game; step: DiscoveryStandaloneNexusE3SkillsStep; after: Game;
};
export type DiscoveryStandaloneNexusE3SkillsOptions = {
/** A fresh lobby or its ORIGINAL unassigned setup. Never a played-save conversion. */
initial?: Game;
native?: DiscoveryStandaloneNexusE3SkillsNative;
rules?: 'basic' | 'advanced';
factions?: readonly FactionId[];
seatIds?: readonly string[];
tech?: boolean;
strongholds?: boolean;
/** Explicit requests must be genuinely offered; absent requests accept the human lottery. */
skill?: LeaderSkillId;
leader?: string;
};
export type DiscoveryStandaloneNexusE3SkillsFixture = {
initial: Game; setup: Game; trained: Game; afterFirstStorm: Game; collection: Game;
firstMentat: DiscoveryStandaloneNexusE3SkillsTransition;
game: Game; native: DiscoveryStandaloneNexusE3SkillsNative;
owner: string; opponent: string; fremen: string | null;
leader: string; skill: LeaderSkillId; target: string | null;
token: string; source: string; wormSource: string; claim: StrongholdId;
actions: DiscoveryStandaloneNexusE3SkillsStep[]; staging: string[];
};
export type DiscoveryStandaloneNexusE3SkillsClosing = {
entered: Game; vote: Game; ride: DiscoveryStandaloneNexusE3SkillsTransition | null;
alliance: Game; draw: DiscoveryStandaloneNexusE3SkillsTransition; game: Game;
};
export type DiscoveryStandaloneNexusE3SkillsBattle = {
beforeBattle: Game; leadChoice: DiscoveryStandaloneNexusE3SkillsTransition | null;
game: Game; plans: DiscoveryStandaloneNexusE3SkillsStep[];
actor: string; opponent: string; leader: string; enemyLeader: string;
location: string; card: string; enemyCard?: string; target: string | null;
};
export type DiscoveryStandaloneNexusE3SkillsBoundary = {
game: Game; kind: 'losses' | 'rescue' | 'assassination' | 'cards' | 'tech' | 'settled';
};
export const discoveryStandaloneNexusE3SkillsPolicy = moritaniNexusSkillsModulesPolicy;
export const discoveryStandaloneNexusE3SkillsClean = (game: Game): boolean =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
export function discoveryStandaloneNexusE3SkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
const copy = (game: Game): Game => structuredClone(game);
/** Scalar entropy only for ONE physical shuffle; preceding scalar draws, UUIDs and later production stay native. */
function shuffled<T>(source: readonly string[], desired: readonly string[], operation: () => T, precedingDraws = 0): T {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], 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) * 0x100000000 / (index + 1)));
[working[index], working[swap]] = [working[swap], working[index]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && precedingDraws > 0) {
precedingDraws--; Reflect.apply(original, crypto, [array]);
} else if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function initializeDiscoveryStandaloneNexusE3Skills(options: DiscoveryStandaloneNexusE3SkillsOptions = {}): Game {
const native = options.native ?? options.initial?.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')?.faction ?? 'moritani';
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
const factions = options.factions ?? [native, 'guild', 'fremen', ...(!advanced && native === 'ecaz' ? ['emperor'] as const : [])];
let game = options.initial ? copy(options.initial) : createGame('DISCOVERYSTANDALONEE3SKILLS',
newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['ecaz']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards;
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length);
assert.equal(game.players.filter(p => p.faction === native).length, 1);
assert.ok(game.players.every(p => p.faction === native || ['atreides', 'guild', 'emperor', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(advanced || native !== 'ecaz' || game.players.length % 2 === 0, 'Basic odd-seat Ecaz remains source guarded.');
assert.ok(!strongholds || advanced);
assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview &&
!game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
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 };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
if (options.initial) game = initializeLeaderSkillsGameForAudit(game);
else {
const skill = options.skill ?? 'warmaster';
const passive: LeaderSkillId[] = ['suk-graduate', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz']
.filter(s => s !== skill) as LeaderSkillId[];
const selected = game.players.map(p => p.faction === native ? skill : passive.shift()!);
const source = LEADER_SKILL_CARDS.map(c => c.id), remainder = source.filter(s => !selected.includes(s));
const desired = selected.flatMap(s => [s, remainder.shift()!]); desired.push(...remainder);
game = shuffled(source, desired, () => initializeLeaderSkillsGameForAudit(game));
}
}
assert.ok(game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
['prediction', 'leaderSkills'].includes(game.setupStage!) && game.leaderSkills?.assignments.length === 0 &&
game.players.every(p => !p.traitors.length && !p.traitorChoices.length), 'Only original unassigned setup may continue; starting hands stay held.');
assert.ok(game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
return game;
}
export function nextDiscoveryStandaloneNexusE3SkillsStep(game: Game): DiscoveryStandaloneNexusE3SkillsStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision &&
['discoveryEntry', 'greatMakerRide', 'greatMakerVote', 'wormRide', 'wormPlacement', 'wormProtection',
'advisor', 'intrusion', 'discoveryDiscard'].includes(game.decision.kind)) return nextClassicDiscoveryNexusStep(game);
if (game.decision?.kind === 'ecazBattleLead' && !game.response && !game.phaseOpening) return null;
if (game.decision?.kind === 'rihani' && !game.response && !game.phaseOpening)
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, draw: false } };
return game.players.some(p => p.faction === 'ecaz')
? nextEcazNexusSkillsModulesStep(game) : nextMoritaniNexusSkillsModulesStep(game);
}
export function stepDiscoveryStandaloneNexusE3Skills(game: Game, step: DiscoveryStandaloneNexusE3SkillsStep,
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceDiscoveryStandaloneNexusE3Skills(game: Game, until: (state: Game) => boolean,
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next, 'Actual owner must select the Occupy lead or seal a Battle Plan.');
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 0;
game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions);
}
throw Error('Original standalone E3 Discovery chronology did not reach its boundary.');
}
export function completeDiscoveryStandaloneNexusE3SkillsSetup(state: Game,
options: DiscoveryStandaloneNexusE3SkillsOptions = {}, actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
let game = copy(state);
const owner = game.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
for (let count = 0; game.status === 'setup' && count < 240; count++) {
if (game.setupStage !== 'leaderSkills') {
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next);
if (game.setupStage === 'traitors' && next.actor === owner) {
const trained = new Set(game.leaderSkills!.assignments.filter(a => a.owner !== owner).map(a => a.leader));
const target = discoveryStandaloneNexusE3SkillsPlayer(game, owner).traitorChoices.find(id => trained.has(id));
if (target) next.action = { type: 'traitor', leader: target };
}
game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions); continue;
}
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const requested = actor === owner ? options.skill : undefined;
const legal = own.offer!.cards.filter(s => !own.unavailableSkills?.[s]);
const skill = requested ?? legal[0]; assert.ok(skill && legal.includes(skill));
const discs = own.eligibleLeaders;
const leader = actor === owner && options.leader ? discs.find(l => l.id === options.leader)
: discoveryStandaloneNexusE3SkillsPlayer(game, actor).faction === 'guild' && skill === 'suk-graduate'
? discs.find(l => l.name === 'Master Bewt') : discs[0];
assert.ok(leader, 'Training uses a genuinely eligible owned native disc, never Duke or a foreign disc.');
const step: DiscoveryStandaloneNexusE3SkillsStep = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
if (!options.initial && game.players.some(p => p.faction === 'moritani') && Object.keys(game.leaderSkills!.offers).length === 1) {
const opponent = (game.players.find(p => p.faction === 'guild') ?? game.players.find(p => p.id !== owner))!.id;
const target = actor === opponent ? leader.id : game.leaderSkills!.assignments.find(a => a.owner === opponent)?.leader;
assert.ok(target, 'The original opposing trained disc must exist before the pending Traitor deal.');
const source = traitorDeck(game.players, false), desired = source.filter(id => id !== target);
desired.splice(game.players.findIndex(p => p.id === owner) * 4, 0, target);
actions?.push(structuredClone(step));
// chooseLeaderSkill first shuffles its rejected card back into the remaining
// skill deck (old deck length draws); only THEN does advanceSetup deal Traitors.
game = shuffled(source, desired, () => applyAction(game, actor, step.action), game.leaderSkills!.deck.length);
} else game = stepDiscoveryStandaloneNexusE3Skills(game, step, actions);
}
assert.equal(game.status, 'playing'); return game;
}
function reserveBoard(game: Game, staging: string[]): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
}
staging.push('Conserved original board counters through OWN typed reserves; no held cards, wallets, earned custody, clocks, deaths or results assigned.');
}
export function positionDiscoveryStandaloneNexusE3Skills(game: Game, actor: string, location: string, normal: number,
staging: string[]): void {
const p = discoveryStandaloneNexusE3SkillsPlayer(game, actor);
assert.ok(Number.isSafeInteger(normal) && normal >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= normal);
p.reserves -= normal; p.forces[location] = (p.forces[location] ?? 0) + normal;
staging.push(`Conserved ${normal} original ordinary ${actor} counters: own reserves → ${location}.`);
}
export function assertDiscoveryStandaloneNexusE3SkillsCustody(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(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
const nexus = game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0), p.faction === 'emperor' ? 5 : 3);
}
}
/** Human endpoint: actual reveal then first END Mentat; free entry is before next Storm. */
export function createDiscoveryStandaloneNexusE3SkillsFixture(options: DiscoveryStandaloneNexusE3SkillsOptions = {}): DiscoveryStandaloneNexusE3SkillsFixture {
const setup = initializeDiscoveryStandaloneNexusE3Skills(options), initial = options.initial ? copy(options.initial) : copy(setup);
const actions: DiscoveryStandaloneNexusE3SkillsStep[] = [], staging: string[] = [];
staging.push('Continuation uses the existing explicit scalar fixture lottery for original Storm/Spice outcomes; byte-array UUID entropy remains native. This is controlled source play, not natural random history.');
if (!options.initial) staging.push('Controlled only the original physical all14 first shuffle; for Moritani, controlled the original pending Traitor shuffle before its real selection. No offered or held card is injected.');
let game = completeDiscoveryStandaloneNexusE3SkillsSetup(setup, options, actions);
const trained = copy(game), owner = game.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
const native = discoveryStandaloneNexusE3SkillsPlayer(game, owner).faction as DiscoveryStandaloneNexusE3SkillsNative;
const opponent = (game.players.find(p => p.faction === 'guild') ?? game.players.find(p => p.id !== owner))!.id;
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
const target = discoveryStandaloneNexusE3SkillsPlayer(game, owner).traitors.find(id =>
game.leaderSkills!.assignments.some(a => a.owner === opponent && a.leader === id)) ?? null;
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), actions);
const afterFirstStorm = copy(game); orderDiscoveryClassicNexusSkillsSpice(game, false, staging);
for (let count = 0; !game.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && count < 120; count++) {
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next); actions.push(structuredClone(next));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
}
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), actions);
reserveBoard(game, staging);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12';
const claim: StrongholdId = native === 'moritani' ? 'tueks_sietch' : 'arrakeen';
positionDiscoveryStandaloneNexusE3Skills(game, owner, source, 3, staging);
positionDiscoveryStandaloneNexusE3Skills(game, owner, wormSource, 6, staging);
if (game.strongholdCards) positionDiscoveryStandaloneNexusE3Skills(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1, staging);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 7 && discoveryStandaloneNexusE3SkillsClean(g), actions);
const collection = copy(game), token = game.discoveries!.tokens.find(t => t.face === 'cistern')!.id;
for (const reveal of [false, true]) game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: owner, action: { type: 'discovery', token, reveal } }, actions);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 8 && discoveryStandaloneNexusE3SkillsClean(g), actions);
let firstMentat: DiscoveryStandaloneNexusE3SkillsTransition | undefined;
while (game.turn === 1) {
const before = copy(game), step = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(step);
game = stepDiscoveryStandaloneNexusE3Skills(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: copy(game) };
}
assert.ok(firstMentat); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner);
return { initial, setup, trained, afterFirstStorm, collection, firstMentat, game, native, owner, opponent,
fremen: game.players.find(p => p.faction === 'fremen')?.id ?? null, leader: assignment.leader, skill: assignment.skill,
target, token, source, wormSource, claim, actions, staging };
}
export function enterDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture, amount = 3): Game {
const game = copy(f.game), view = viewGame(game, f.owner);
const source = view.discoveryEntry!.sources.find(s => s.normal >= amount); assert.ok(source);
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action);
return stepDiscoveryStandaloneNexusE3Skills(game, { actor: f.owner, action }, f.actions);
}
/** Original Maker deaths, all votes, actual typed reserve ride, settled alliance and BOTH piles close before drawing. */
export function closeDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture,
target: FactionId | undefined = f.native): DiscoveryStandaloneNexusE3SkillsClosing {
assert.ok(f.game.players.length >= 3, 'A qualified native draw needs two other real alliance seats.');
let game = enterDiscoveryStandaloneNexusE3Skills(f); const entered = copy(game);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
orderDiscoveryClassicNexusSkillsSpice(game, true, f.staging);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.greatMaker?.stage === 'vote', f.actions);
const vote = copy(game);
while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryStandaloneNexusE3Skills(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
let ride: DiscoveryStandaloneNexusE3SkillsTransition | null = null;
if (game.decision?.kind === 'greatMakerRide') {
const before = copy(game), actor = game.decision.player;
const action = greatMakerRideAction(viewGame(game, actor), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(action);
const step = { actor, action }; game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions);
ride = { before, step, after: copy(game) };
}
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
const others = game.players.filter(p => p.id !== f.owner);
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = copy(game);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexusCards?.phase?.stage === 'drawing' && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
const cards = game.nexusCards!.cards!;
if (target) {
const index = cards.deck.indexOf(target); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
f.staging.push(`Conserved UNPLAYED original ${target} Nexus singleton ordered before actual native closing choice; no held-card injection.`);
}
const before = copy(game), step: DiscoveryStandaloneNexusE3SkillsStep = { actor: f.owner,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions);
return { entered, vote, ride, alliance, draw: { before, step, after: copy(game) }, game };
}
export function cunningDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture,
state: Game): DiscoveryStandaloneNexusE3SkillsTransition {
let game = copy(state);
if (f.native === 'ecaz') {
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
reserveBoard(game, f.staging);
if (game.strongholdCards)
positionDiscoveryStandaloneNexusE3Skills(game, f.owner, `${f.claim}:${territory(f.claim).sectors[0]}`, 1, f.staging);
// With no physical clash, the original engine skips Battle altogether.
// Keep a real chooser pending, but do not open a battle before quiet Cunning.
for (const actor of [f.owner, f.opponent])
positionDiscoveryStandaloneNexusE3Skills(game, actor, 'cistern:0', 1, f.staging);
}
const phase = f.native === 'ecaz' ? 6 : 8;
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === phase &&
(f.native === 'ecaz' ? discoveryStandaloneNexusE3SkillsClean(g) : g.decision?.kind === 'moritaniPlacement') && !g.battle, f.actions);
const before = copy(game), step = f.native === 'ecaz' ? ecazNexusSkillsModulesCunningRequest(game, f.owner)
: { actor: f.owner, action: moritaniNexusSkillsModulesRequest(game, f.owner, 'robbery', 'red_chasm') };
game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => !g.response && !g.phaseOpening && !g.pendingKarama, f.actions);
return { before, step, after: game };
}
/** A requested face reaches the hand ONLY through an actual original auction and payment. */
function buyWorthless(f: DiscoveryStandaloneNexusE3SkillsFixture, state: Game, actor: string,
enemy?: string): { game: Game; card: string; enemyCard?: string } {
let game = advanceDiscoveryStandaloneNexusE3Skills(state, g => g.phase === 2 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
const wanted: { actor: string; card: string }[] = [], front: Game['deck'] = [];
for (const id of [actor, ...(enemy ? [enemy] : [])]) {
const held = discoveryStandaloneNexusE3SkillsPlayer(game, id).hand.find(c => c.kind === 'worthless');
if (held) { wanted.push({ actor: id, card: held.id }); continue; }
const index = game.deck.findIndex(c => c.kind === 'worthless'); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0]; front.push(card); wanted.push({ actor: id, card: card.id });
f.staging.push(`Conserved original UNPLAYED Ecaz33 ${card.id} ordered before auction; actual bidder and payment alone acquire it.`);
}
game.deck.unshift(...front);
for (let count = 0; count < 320; count++) {
if (wanted.every(w => discoveryStandaloneNexusE3SkillsPlayer(game, w.actor).hand.some(c => c.id === w.card)))
return { game, card: wanted[0].card, enemyCard: wanted[1]?.card };
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next);
if (game.phase === 3 && !game.response && !game.phaseOpening && !game.decision && game.auction && !game.auction.bid) {
const buyer = wanted.find(w => w.card === game.auction!.cards[game.auction!.index]?.id && w.actor === game.auction!.active);
if (buyer) next.action = { type: 'bid', amount: 1 };
}
game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions);
}
throw Error('Actual auction did not deliver its physical requested faces.');
}
/** For Ecaz, occupy requests a later REAL Nexus alliance and returns the mandatory lead choice.
* No alliance is assigned and no quiet Duke custody is confused with native training. */
export function openDiscoveryStandaloneNexusE3SkillsBattle(f: DiscoveryStandaloneNexusE3SkillsFixture, state: Game,
options: { occupy?: boolean; lead?: 'native' | 'ally'; normalCall?: boolean } = {}): DiscoveryStandaloneNexusE3SkillsBattle {
let game = copy(state), ally: string | null = null;
if (options.occupy) {
assert.equal(f.native, 'ecaz'); assert.ok(game.advanced && game.players.length >= 3);
// Quiet Cunning left an actual, unopened clash. Resolve it with original
// discs and zero-dial sealed plans before the later Occupy Nexus; never
// erase its chooser or advance a fabricated Battle result.
if (game.phase === 6 && !game.battle) {
const chooser = game.active!;
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: chooser,
action: { type: 'chooseBattle', territory: 'cistern', target: chooser === f.owner ? f.opponent : f.owner } }, f.actions);
for (let count = 0; count < 160; count++) {
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game);
if (!next) break;
game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions);
}
assert.ok(game.battle && !game.battle.revealed && discoveryStandaloneNexusE3SkillsClean(game));
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const defender = discoveryStandaloneNexusE3SkillsPlayer(game, f.opponent).leaders
.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(defender);
game = revealDiscoveryNativeE3SkillsPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.leader, dial: 0, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: defender.id, dial: 0, support: 0 } },
], f.actions);
game = finishDiscoveryStandaloneNexusE3SkillsBattle(game, f.actions);
}
const turn = game.turn;
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.turn > turn && g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
const front: Game['spiceDeck'] = [];
for (const kind of ['worm', 'land', 'land']) {
const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery);
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front); f.staging.push('Conserved original unplayed ordinary worm and both replacement lands for a later actual Occupy alliance.');
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
ally = game.players.find(p => p.id !== f.owner && p.id !== f.opponent)!.id;
if (discoveryStandaloneNexusE3SkillsPlayer(game, ally).ally)
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor, action: { type: 'alliance', target } }, f.actions);
}
const actor = options.lead === 'ally' ? ally! : f.owner;
const bought = buyWorthless(f, game, actor, f.native === 'moritani' ? f.opponent : undefined); game = bought.game;
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
reserveBoard(game, f.staging);
const site = f.native === 'moritani' && game.strongholdCards ? 'tueks_sietch' : 'cistern';
const location = `${site}:${site === 'cistern' ? 0 : territory(site).sectors[0]}`;
for (const id of [f.owner, f.opponent, ...(ally ? [ally] : [])])
positionDiscoveryStandaloneNexusE3Skills(game, id, location, ally && id === f.owner ? 3 : 6, f.staging);
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 6 && discoveryStandaloneNexusE3SkillsClean(g), f.actions);
if (f.native === 'ecaz' && !ally && viewGame(game, f.owner).nexusEcazDuke?.blocked === null)
game = stepDiscoveryStandaloneNexusE3Skills(game, ecazNexusSkillsModulesCunningRequest(game, f.owner), f.actions);
const beforeBattle = copy(game);
const chooser = game.active!; assert.ok([f.owner, f.opponent, ally].includes(chooser));
const choice = viewGame(game, chooser).battleChoices.find(c => c.chooser === chooser && c.territory === site &&
(c.attacker === f.opponent || c.defender === f.opponent));
assert.ok(choice, 'Choose the actual unresolved physical coalition battle at this site.');
const target = chooser === f.opponent
? choice.attacker === chooser ? choice.defender : choice.attacker
: f.opponent;
game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: chooser,
action: { type: 'chooseBattle', territory: site, target } }, f.actions);
let leadChoice: DiscoveryStandaloneNexusE3SkillsTransition | null = null;
if (ally) {
game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.decision?.kind === 'ecazBattleLead' && !g.response && !g.phaseOpening, f.actions);
const decision = game.decision; assert.ok(decision?.kind === 'ecazBattleLead');
const before = copy(game), step: DiscoveryStandaloneNexusE3SkillsStep = { actor: f.owner,
action: { type: 'decision', event: decision.event, lead: actor } };
game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions); leadChoice = { before, step, after: copy(game) };
}
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const own = discoveryStandaloneNexusE3SkillsPlayer(game, actor);
const enemy = discoveryStandaloneNexusE3SkillsPlayer(game, f.opponent);
const leader = f.native === 'ecaz' ? game.leaderSkills!.assignments.find(a => a.owner === actor)!.leader
: own.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0].id;
const enemyLeader = options.normalCall ? own.traitors.find(id => enemy.leaders.some(l => l.id === id && !l.dead))
: enemy.leaders.filter(l => !l.dead && !trained.has(l.id) && l.id !== f.target).sort((a, b) => f.native === 'ecaz' ? a.strength - b.strength : b.strength - a.strength)[0]?.id;
assert.ok(enemyLeader, 'Normal-call case requires an actually held opposing Traitor, never a transfer.');
for (let count = 0; count < 160; count++) {
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game);
if (!next) break;
if (next.action.type === 'leaderSkillVisibility') {
const planned = next.actor === actor ? leader : next.actor === f.opponent ? enemyLeader : null;
next.action.hide = game.leaderSkills!.assignments.some(a => a.owner === next.actor && a.leader === planned);
}
game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions);
}
assert.ok(game.battle && !game.battle.revealed && discoveryStandaloneNexusE3SkillsClean(game));
const dial = f.native === 'ecaz' ? 1 : 4;
const fixedDial = viewGame(game, actor).battle!.ecazOccupy?.profile?.fixedEcazDial ?? 0;
return { beforeBattle, leadChoice, game, actor, opponent: f.opponent, leader, enemyLeader, location, card: bought.card, enemyCard: bought.enemyCard,
target: f.target, plans: [
{ actor, action: { type: 'battlePlan', leader, dial: fixedDial + 1,
support: game.advanced && discoveryStandaloneNexusE3SkillsPlayer(game, ally ?? actor).faction !== 'fremen' ? 1 : 0, weapon: bought.card } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemyLeader, dial, support: game.advanced && enemy.faction !== 'fremen' ? dial : 0, weapon: bought.enemyCard } },
] };
}
export function revealDiscoveryStandaloneNexusE3SkillsBattle(battle: DiscoveryStandaloneNexusE3SkillsBattle,
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
return revealDiscoveryNativeE3SkillsPlans(copy(battle.game), battle.plans, actions);
}
export function settleDiscoveryStandaloneNexusE3SkillsBattle(state: Game, callers: readonly string[] = [],
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): DiscoveryStandaloneNexusE3SkillsBoundary {
let game = copy(state);
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], DiscoveryStandaloneNexusE3SkillsBoundary['kind']>> = {
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
};
for (let count = 0; count < 240; count++) {
const kind = game.decision && choices[game.decision.kind];
if (kind && !game.response && !game.phaseOpening) return { game, kind };
if (!game.battle && discoveryStandaloneNexusE3SkillsClean(game)) return { game, kind: 'settled' };
const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions);
}
throw Error('Original human settlement boundary did not open.');
}
export function finishDiscoveryStandaloneNexusE3SkillsBattle(state: Game,
actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game {
return advanceDiscoveryStandaloneNexusE3Skills(copy(state), g => !g.battle && discoveryStandaloneNexusE3SkillsClean(g), actions);
}