Files
Dune/tests/fixture-discovery-classic-nexus-skills.ts

319 lines
20 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 type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { createStrongholdCards } from '../game/stronghold-cards';
import {
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance,
nextClassicDiscoveryNexusStep, openClassicDiscoveryNexusAlliance, revealClassicDiscoveryNexus,
voteClassicDiscoveryNexus, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
} from './fixture-discovery-classic-nexus';
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
export type DiscoveryClassicNexusSkillsStep = { actor: string; action: Action };
export type DiscoveryClassicNexusSkillsOptions = {
/** Authenticated ready lobby or ORIGINAL unassigned turn-one setup, never a played save. */
initial?: Game;
gameId?: string;
seatIds?: string[];
seats?: 2 | 3 | 4 | 5 | 6;
factions?: FactionId[];
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Only a new, locally generated setup may position its physical offer lottery. */
skill?: LeaderSkillId;
};
export type DiscoveryClassicNexusSkillsFixture = {
initial: Game;
setup: Game;
trained: Game;
collection: Game;
firstMentat: { before: Game; step: DiscoveryClassicNexusSkillsStep; after: Game };
game: Game;
owner: string;
opponent: string;
leader: string;
skill: LeaderSkillId;
token: string;
source: string;
wormSource: string;
actions: DiscoveryClassicNexusSkillsStep[];
staging: string[];
};
export type DiscoveryClassicNexusSkillsClosing = {
vote: Game;
alliance: Game;
beforeDraw: Game;
drawStep: DiscoveryClassicNexusSkillsStep;
game: Game;
};
export type DiscoveryClassicNexusSkillsBattle = {
shipmentWindow: Game;
shipmentStep: DiscoveryClassicNexusSkillsStep;
arrived: Game;
game: Game;
weapon: string;
plans: DiscoveryClassicNexusSkillsStep[];
};
export const discoveryClassicNexusSkillsClean = classicDiscoveryNexusClean;
export const advanceDiscoveryClassicNexusSkills = advanceClassicDiscoveryNexus;
export const stepDiscoveryClassicNexusSkills = applyClassicDiscoveryNexusStep;
export function discoveryClassicNexusSkillsPlayer(game: Game, actor: string): Player {
const seat = game.players.find(player => player.id === actor);
assert.ok(seat); return seat;
}
/** The thirteen scalar draws belong only to the original all14 shuffle. Native
* UUID bytes and subsequent original component production remain unchanged. */
function offeredSkills<T>(selected: LeaderSkillId[], operation: () => T): T {
const working = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(skill => !selected.includes(skill));
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
desired.push(...remainder);
const rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const swap = working.indexOf(desired[index]); assert.ok(swap >= 0 && swap <= index);
rolls.push(Math.floor((swap + 0.5) / (index + 1) * 0x100000000));
[working[index], working[swap]] = [working[swap], working[index]];
}
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function initializeDiscoveryClassicNexusSkillsSetup(options: DiscoveryClassicNexusSkillsOptions = {}): Game {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = options.factions ?? ['atreides', 'harkonnen', 'beneGesserit', 'guild', 'emperor', 'fremen'];
const count = options.seats ?? options.seatIds?.length ?? options.factions?.length ?? 3;
assert.ok(count >= 2 && count <= 6 && factions.length >= count);
if (options.seatIds) assert.equal(options.seatIds.length, count);
game = createGame(options.gameId ?? 'DISCOVERYCLASSICNEXUSSKILLS',
newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? false);
for (let index = 1; index < count; index++)
joinGame(game, newPlayer(options.seatIds?.[index] ?? factions[index], factions[index], factions[index]));
}
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.deepEqual(game.expansions, []);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills && !game.discoveries);
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
assert.ok(classicNexusLeaderSkillsProfile(game));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
// A supplied human lobby must keep the real lottery; never demand one skill.
if (options.initial) game = withClassicDiscoveryNexusLottery(() => initializeLeaderSkillsGameForAudit(game));
else {
const requested = options.skill ?? 'warmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist']
.filter(skill => skill !== requested) as LeaderSkillId[];
const selected = game.players.map((_, index) => index === 0 ? requested : passive.shift()!);
game = offeredSkills(selected, () => initializeLeaderSkillsGameForAudit(game));
}
}
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(['prediction', 'leaderSkills'].includes(game.setupStage!));
assert.ok(game.leaderSkills && game.discoveries && game.nexusCards?.cards);
assert.ok(classicNexusLeaderSkillsProfile(game));
assert.equal(game.leaderSkills.assignments.length, 0);
assert.ok(game.players.every(player => !player.traitors.length && !player.traitorChoices.length));
if (game.strongholdCards) assert.deepEqual(game.strongholdCards, createStrongholdCards());
if (options.tech !== undefined) assert.equal(!!game.techTokens, options.tech);
if (options.strongholds !== undefined) assert.equal(!!game.strongholdCards, options.strongholds);
return game;
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
staging.push('Conserved original board counters into their OWN reserves before actual movement completion; no death, wallet, clock, alliance or earned receipt assigned.');
}
export function positionDiscoveryClassicNexusSkillsOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
const player = discoveryClassicNexusSkillsPlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
staging.push(`Conserved ${amount} original ordinary ${actor} reserve counters at ${key}; subsequent native actions alone determine survival.`);
}
export function orderDiscoveryClassicNexusSkillsSpice(game: Game, secondTurn: boolean, staging: string[]): void {
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
const front: Game['spiceDeck'] = [];
if (secondTurn) front.push(take(card => 'worm' in card && !!card.greatMaker));
front.push(take(card => 'territory' in card && (secondTurn ? !card.discovery && card.territory === 'broken_land'
: card.discovery === 'discovery-hagga-basin')));
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery &&
(secondTurn ? card.territory !== 'broken_land' : card.territory === 'rock_outcroppings')));
game.spiceDeck.unshift(...front);
staging.push(secondTurn ? 'Conserved original unplayed Great Maker followed by BOTH Advanced land replacements; original discard history untouched.'
: 'Conserved original undealt Hagga Discovery and optional Advanced Rock Outcroppings positions; original token lottery selects Cistern.');
}
/** Human endpoint is actual trained next-turn free entry BEFORE Storm. Supplied
* offers/hands/IDs survive verbatim; only source-clear legal offered training is
* selected. Original owner clocks, reveal and first Mentat own every transition. */
export function createDiscoveryClassicNexusSkillsFixture(options: DiscoveryClassicNexusSkillsOptions = {}): DiscoveryClassicNexusSkillsFixture {
const setup = initializeDiscoveryClassicNexusSkillsSetup(options);
const initial = options.initial ? structuredClone(options.initial) : structuredClone(setup);
const owner = setup.players[0].id, opponent = setup.players[1].id;
const actions: DiscoveryClassicNexusSkillsStep[] = [], staging: string[] = [];
let game = structuredClone(setup);
while (game.status === 'setup') {
let step: DiscoveryClassicNexusSkillsStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const legal = view.offer!.cards.filter(skill => !view.unavailableSkills?.[skill]);
const desired = !options.initial && actor === owner ? options.skill ?? 'warmaster' : undefined;
const skill = desired && legal.includes(desired) ? desired : legal[0];
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = discoveryClassicNexusSkillsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader, 'Human continuation requires genuinely offered legal training, never a substitute offer.');
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
} else { const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); step = next; }
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
const trained = structuredClone(game), assignment = game.leaderSkills!.assignments.find(item => item.owner === owner)!;
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
orderDiscoveryClassicNexusSkillsSpice(game, false, staging);
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); actions.push(structuredClone(next));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
}
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
reserveBoard(game, staging);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12';
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, source, 3, staging);
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, wormSource, 6, staging);
if (game.strongholdCards) positionDiscoveryClassicNexusSkillsOrdinary(game, owner, 'arrakeen:10', 1, staging);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
const collection = structuredClone(game), token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
game = revealClassicDiscoveryNexus(game, owner, token.id, actions);
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions); game = firstMentat.after;
assert.equal(game.turn, 2); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner);
return { initial, setup, trained, collection, firstMentat, game, owner, opponent,
leader: assignment.leader, skill: assignment.skill, token: token.id, source, wormSource, actions, staging };
}
export function enterDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, amount = 3): Game {
const game = structuredClone(fixture.game), view = viewGame(game, fixture.owner);
const source = view.discoveryEntry!.sources.find(source => source.normal >= amount); assert.ok(source);
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action);
return applyClassicDiscoveryNexusStep(game, { actor: fixture.owner, action }, fixture.actions);
}
/** Actual Great Maker death -> all votes -> reserve ride -> settled alliance ->
* ENTIRE Spice Blow closure. Omitting target preserves the genuine original draw
* for human continuation; targeting only reorders an unplayed deck singleton. */
export function closeDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, target?: FactionId): DiscoveryClassicNexusSkillsClosing {
assert.ok(fixture.game.players.length >= 3, 'An unallied draw needs two other actual alliance seats.');
let game = enterDiscoveryClassicNexusSkills(fixture);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), fixture.actions);
orderDiscoveryClassicNexusSkillsSpice(game, true, fixture.staging);
game = advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
const vote = structuredClone(game);
game = voteClassicDiscoveryNexus(game, game.players.map(() => true), fixture.actions);
game = openClassicDiscoveryNexusAlliance(game);
const others = game.players.filter(player => player.id !== fixture.owner);
game = formClassicDiscoveryNexusAlliance(game, others[0].id, others[1].id);
const alliance = structuredClone(game);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', fixture.actions);
const cards = game.nexusCards!.cards!;
if (target) {
const index = cards.deck.indexOf(target); assert.ok(index >= 0, 'Only an original unplayed singleton may supply the targeted draw.');
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
fixture.staging.push(`Positioned original UNPLAYED ${target} Nexus singleton before actual closing draw; never a held-hand assignment.`);
}
const beforeDraw = structuredClone(game), drawStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[fixture.owner], choice: 'draw', ownRedraws: 0 } };
game = applyClassicDiscoveryNexusStep(game, drawStep, fixture.actions);
return { vote, alliance, beforeDraw, drawStep, game };
}
/** Borrowed Guild tariff is spent through an actual nested shipment. The trained
* Warmaster remains the physical actor; Arrakeen custody is not nested support. */
export function openDiscoveryClassicNexusSkillsBattle(fixture: DiscoveryClassicNexusSkillsFixture, state: Game): DiscoveryClassicNexusSkillsBattle {
assert.equal(fixture.skill, 'warmaster');
let game = advanceClassicDiscoveryNexus(state, game => game.phase === 5 && game.active === fixture.owner && classicDiscoveryNexusClean(game), fixture.actions);
const shipmentWindow = structuredClone(game), offer = viewGame(game, fixture.owner).nexusGuildSecretAlly;
assert.ok(offer && !offer.blocked);
const shipmentStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner, action: {
type: 'ship', territory: 'cistern', sector: 0, amount: 3, elite: 0, nexus: offer.event } };
game = applyClassicDiscoveryNexusStep(game, shipmentStep, fixture.actions);
game = advanceClassicDiscoveryNexus(game, game => !game.pendingShipment && classicDiscoveryNexusClean(game), fixture.actions);
const arrived = structuredClone(game);
positionDiscoveryClassicNexusSkillsOrdinary(game, fixture.opponent, 'cistern:0', 2, fixture.staging);
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.owner, 'worthless', fixture.staging).id;
const enemy = discoveryClassicNexusSkillsPlayer(game, fixture.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(enemy);
game = advanceClassicDiscoveryNexus(game, game => game.phase === 6 && classicDiscoveryNexusClean(game), fixture.actions);
const actor = game.active!; assert.ok(actor === fixture.owner || actor === fixture.opponent);
game = applyClassicDiscoveryNexusStep(game, { actor, action: { type: 'chooseBattle', territory: 'cistern',
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
for (let count = 0; count < 160; count++) {
const next = nextClassicDiscoveryNexusStep(game);
if (!next) break;
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner;
game = applyClassicDiscoveryNexusStep(game, next, fixture.actions);
}
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
return { shipmentWindow, shipmentStep, arrived, game, weapon, plans: [
{ actor: fixture.owner, action: { type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0,
leader: fixture.leader, weapon, defense: null } },
{ actor: fixture.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
] };
}
export function revealDiscoveryClassicNexusSkillsBattle(battle: DiscoveryClassicNexusSkillsBattle): Game {
let game = structuredClone(battle.game);
for (const step of battle.plans) {
game = applyClassicDiscoveryNexusStep(game, step);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); game = applyClassicDiscoveryNexusStep(game, next);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishDiscoveryClassicNexusSkillsBattle(state: Game, stopAtCleanup = false): Game {
let game = structuredClone(state);
for (let count = 0; count < 240; count++) {
if (stopAtCleanup && game.decision?.kind === 'battleCards') return game;
if (!game.battle && classicDiscoveryNexusClean(game) && !game.pendingTreacheryDiscard) return game;
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = false;
game = applyClassicDiscoveryNexusStep(game, next);
}
throw Error('Original trained nested battle did not finish its cleanup.');
}