Files
Dune/tests/fixture-discovery-paired-nexus-e1-skills.ts
T

363 lines
25 KiB
TypeScript

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;
}