492 lines
36 KiB
TypeScript
492 lines
36 KiB
TypeScript
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);
|
|
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);
|
|
}
|