242 lines
15 KiB
TypeScript
242 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
|
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
|
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
|
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
|
import { createStrongholdCards } from '../game/stronghold-cards';
|
|
|
|
export type ClassicDiscoverySkillsStep = { actor: string; action: Action };
|
|
export type ClassicDiscoverySkillsOptions = {
|
|
/** Authenticated fresh lobby or original staged setup; never a played save. */
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
tech?: boolean;
|
|
/** Preserve the selected, still-unused original Advanced module at setup. */
|
|
strongholdCards?: boolean;
|
|
seats?: 2 | 3 | 4 | 5 | 6;
|
|
collector?: 'guild' | 'harkonnen';
|
|
skill?: LeaderSkillId;
|
|
skillOwner?: 'guild' | 'harkonnen' | 'emperor';
|
|
face?: DiscoveryTokenFace;
|
|
amount?: number;
|
|
};
|
|
export type ClassicDiscoverySkillsFixture = {
|
|
initial: Game; setup: Game; game: Game; actions: ClassicDiscoverySkillsStep[];
|
|
staging: string[]; collector: string; opponent: string; leader: string;
|
|
token: string; face: DiscoveryTokenFace; parent: string; parentSector: number;
|
|
};
|
|
export const classicDiscoverySkillsClean = (game: Game) =>
|
|
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
|
|
|
export function nextClassicDiscoverySkillsStep(game: Game): ClassicDiscoverySkillsStep {
|
|
if (game.decision?.kind === 'discoveryEntry') return { actor: game.decision.player,
|
|
action: { type: 'decision', event: game.decision.event, accept: false } };
|
|
if (game.battle?.preLeader?.closed === false && !game.response && !game.decision && !game.battle.preparation) {
|
|
const frame = game.battle.preLeader;
|
|
return { actor: frame.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker,
|
|
action: { type: 'battlePreparationReady', event: frame.event } };
|
|
}
|
|
const next = nextSpiceBankerIncomeNativeStep(game);
|
|
assert.ok(next, 'The original classic program needs a live owned continuation.');
|
|
return next;
|
|
}
|
|
export function advanceClassicDiscoverySkills(state: Game, until: (game: Game) => boolean,
|
|
actions?: ClassicDiscoverySkillsStep[]): Game {
|
|
let game = state;
|
|
for (let i = 0; !until(game) && i < 1600; i++) {
|
|
assert.notEqual(game.status, 'finished');
|
|
const next = nextClassicDiscoverySkillsStep(game);
|
|
actions?.push(next);
|
|
game = applyAction(game, next.actor, next.action);
|
|
}
|
|
assert.ok(until(game), 'The connected classic program did not reach its original action window.');
|
|
return game;
|
|
}
|
|
|
|
/** Physical offer lottery only: each original seat receives a source-clear skill.
|
|
* The rest of setup entropy, including Treachery and Traitor cards, stays real. */
|
|
function withSkillOffers<T>(skills: LeaderSkillId[], operation: () => T): T {
|
|
const original = crypto.getRandomValues.bind(crypto);
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const current = LEADER_SKILL_CARDS.map(card => card.id) as LeaderSkillId[];
|
|
const desired = Array<LeaderSkillId>(current.length);
|
|
skills.forEach((skill, index) => { desired[index * 2] = skill; });
|
|
const rest = current.filter(skill => !skills.includes(skill));
|
|
for (let i = 0; i < desired.length; i++) if (!desired[i]) desired[i] = rest.shift()!;
|
|
let index = current.length - 1;
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
if (index < 1) return Reflect.apply(original, crypto, [array]) as V;
|
|
assert.ok(array instanceof Uint32Array && array.length === 1);
|
|
const swap = current.indexOf(desired[index]);
|
|
assert.ok(swap <= index);
|
|
array[0] = Math.floor((swap + 0.5) / (index + 1) * 0x100000000);
|
|
[current[index], current[swap]] = [current[swap], current[index]];
|
|
index--;
|
|
return array;
|
|
};
|
|
try { return operation(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
function withDiscoveryPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
|
|
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
|
const index = eligible.findIndex(token => token.face === face);
|
|
assert.ok(index >= 0);
|
|
const original = crypto.getRandomValues.bind(crypto);
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
if (array instanceof Uint32Array && array.length === 1)
|
|
array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000);
|
|
else Reflect.apply(original, crypto, [array]);
|
|
return array;
|
|
};
|
|
try { return operation(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
/** Original base33/all14 staged setup, original Storm/Bidding/Shipment/Collection.
|
|
* Explicit positions: actual skill offers, four unplayed Spice Cards and supply
|
|
* token lottery. Never stage clocks, phases, wallets, forces or pending frames. */
|
|
export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySkillsOptions = {}): ClassicDiscoverySkillsFixture {
|
|
const collectorFaction = options.collector ?? 'guild', requested = options.skill ?? 'planetologist';
|
|
const face = options.face ?? 'cistern';
|
|
let game: Game;
|
|
if (options.initial) game = structuredClone(options.initial);
|
|
else {
|
|
const factions = [collectorFaction, 'emperor', collectorFaction === 'guild' ? 'harkonnen' : 'guild',
|
|
'atreides', 'fremen', 'beneGesserit'] as FactionId[];
|
|
game = createGame('CLASSICDISCOVERYSKILLS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
|
|
for (const faction of factions.slice(1, options.seats ?? 3)) joinGame(game, newPlayer(faction, faction, faction));
|
|
}
|
|
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
|
|
const controlledOffers = game.status === 'lobby';
|
|
assert.deepEqual(game.expansions, []);
|
|
assert.ok(!game.homeworlds && !game.nexusCards);
|
|
if (options.strongholdCards || game.strongholdCards) {
|
|
assert.equal(game.advanced, true);
|
|
if (!game.strongholdCards) {
|
|
assert.equal(game.status, 'lobby', 'Strongholds must be selected before the original deal.');
|
|
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
|
}
|
|
assert.deepEqual(game.strongholdCards, createStrongholdCards(), 'Never accept an earned or injected held-card receipt.');
|
|
}
|
|
const collector = game.players.find(player => player.faction === collectorFaction)!.id;
|
|
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
|
|
const skillOwner = game.players.find(player => player.faction === (options.skillOwner ?? collectorFaction))!.id;
|
|
const choices = (['planetologist', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz',
|
|
'warmaster', 'master-of-assassins', 'bureaucrat'] as LeaderSkillId[]).filter(skill => skill !== requested);
|
|
const skills = game.players.map(player => player.id === skillOwner ? requested : choices.shift()!);
|
|
const actions: ClassicDiscoverySkillsStep[] = [];
|
|
if (game.status === 'lobby') {
|
|
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
|
game.discoveryEnabled = true;
|
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
|
}
|
|
const initial = structuredClone(game);
|
|
if (game.status === 'lobby') game = withSkillOffers(skills, () => initializeLeaderSkillsGameForAudit(game));
|
|
assert.ok(game.discoveryEnabled && game.discoveries && game.leaderSkills);
|
|
const setup = structuredClone(game);
|
|
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
|
if (game.setupStage !== 'leaderSkills') {
|
|
const next = nextClassicDiscoverySkillsStep(game);
|
|
actions.push(next); game = applyAction(game, next.actor, next.action); continue;
|
|
}
|
|
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
|
const view = viewGame(game, actor).leaderSkills!;
|
|
const skill = actor === skillOwner ? requested : offer.cards.find(card =>
|
|
!['spice-banker', 'mentat', 'diplomat'].includes(card) && !view.unavailableSkills?.[card]);
|
|
assert.ok(skill && offer.cards.includes(skill), 'Authenticated offer must contain the requested source-clear skill.');
|
|
const eligible = view.eligibleLeaders.map(candidate => game.players.find(player => player.id === actor)!.leaders.find(leader => leader.id === candidate.id)!);
|
|
const leader = eligible.sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(leader);
|
|
const next: ClassicDiscoverySkillsStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
|
actions.push(next); game = applyAction(game, actor, next.action);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
const leader = game.leaderSkills!.assignments.find(assignment => assignment.owner === collector)!.leader;
|
|
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
|
|
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
|
|
assert.ok(index >= 0);
|
|
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
|
for (let position = 1; position < 4; position++) {
|
|
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
|
assert.ok(ordinary >= position);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
|
|
}
|
|
const staging = [controlledOffers
|
|
? 'Selected original physical all14 offer lottery; reordered four original unplayed Spice Cards; selected original supply-token lottery.'
|
|
: 'Kept actual already-dealt Skill offers and assignments; reordered four original unplayed Spice Cards; selected original supply-token lottery.'];
|
|
game = advanceClassicDiscoverySkills(game, state => state.phase === 1 && classicDiscoverySkillsClean(state), actions);
|
|
game = withDiscoveryPlacement(game, face, () => advanceClassicDiscoverySkills(game,
|
|
state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
|
|
game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === collector && classicDiscoverySkillsClean(state), actions);
|
|
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
|
|
const shipment: ClassicDiscoverySkillsStep = { actor: collector, action: { type: 'ship',
|
|
territory: placement.territory, sector: placement.sector, amount: options.amount ?? 3, elite: 0, allyPayment: 0 } };
|
|
actions.push(shipment); game = applyAction(game, collector, shipment.action);
|
|
game = advanceClassicDiscoverySkills(game, state => state.phase === 7 && classicDiscoverySkillsClean(state), actions);
|
|
const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!;
|
|
assert.equal(game.turn, 1);
|
|
const offer = viewGame(game, collector).discoveries!;
|
|
assert.ok(offer.canInspect.includes(token.id) || offer.canReveal.includes(token.id));
|
|
return { initial, setup, game, actions, staging, collector, opponent, leader,
|
|
token: token.id, face, parent: placement.territory, parentSector: placement.sector };
|
|
}
|
|
export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture, actions?: ClassicDiscoverySkillsStep[]): Game {
|
|
let game = fixture.game;
|
|
if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token)) {
|
|
const inspect: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: false } };
|
|
actions?.push(inspect); game = applyAction(game, inspect.actor, inspect.action);
|
|
}
|
|
const reveal: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: true } };
|
|
actions?.push(reveal); return applyAction(game, reveal.actor, reveal.action);
|
|
}
|
|
export function classicDiscoverySkillsEntryWindow(fixture: ClassicDiscoverySkillsFixture): Game {
|
|
return advanceClassicDiscoverySkills(revealClassicDiscoverySkills(fixture), game =>
|
|
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector);
|
|
}
|
|
export function enterClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game {
|
|
const game = classicDiscoverySkillsEntryWindow(fixture), view = viewGame(game, fixture.collector);
|
|
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
|
|
assert.ok(action);
|
|
return applyAction(game, fixture.collector, action);
|
|
}
|
|
export function classicDiscoverySkillsShipmentWindow(state: Game, owner: string): Game {
|
|
return advanceClassicDiscoverySkills(state, game => game.turn === 2 && game.phase === 5 &&
|
|
game.active === owner && classicDiscoverySkillsClean(game));
|
|
}
|
|
export function settleClassicDiscoverySkillsArrival(state: Game): Game {
|
|
return advanceClassicDiscoverySkills(state, game => !game.pendingShipment && classicDiscoverySkillsClean(game));
|
|
}
|
|
|
|
/** Original two-turn nested battle: collector enters free, Emperor ships one
|
|
* physical normal force at the printed nested rate, then original battle setup. */
|
|
export function openClassicDiscoverySkillsBattle(fixture: ClassicDiscoverySkillsFixture, hide = true): Game {
|
|
let game = classicDiscoverySkillsShipmentWindow(enterClassicDiscoverySkills(fixture), fixture.opponent);
|
|
game = applyAction(game, fixture.opponent, { type: 'ship', territory: fixture.face, sector: 0, amount: 1, elite: 0, allyPayment: 0 });
|
|
game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state));
|
|
const actor = game.active!;
|
|
assert.ok(actor === fixture.collector || actor === fixture.opponent);
|
|
game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.face,
|
|
target: actor === fixture.collector ? fixture.opponent : fixture.collector });
|
|
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
|
|
if (game.decision?.kind === 'leaderSkillVisibility') game = applyAction(game, game.decision.player,
|
|
{ type: 'leaderSkillVisibility', event: game.decision.event, hide: game.decision.player === fixture.collector && hide });
|
|
else {
|
|
const next = nextClassicDiscoverySkillsStep(game); game = applyAction(game, next.actor, next.action);
|
|
}
|
|
}
|
|
assert.ok(game.battle && !game.response && !game.decision);
|
|
return game;
|
|
}
|