246 lines
14 KiB
TypeScript
246 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { type Card } from '../game/cards';
|
|
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { homeworldForceGroups } from '../game/homeworld-custody';
|
|
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
|
|
import { nextNativeSkillsStrongholdStep } from './fixture-native-skills-stronghold';
|
|
|
|
export type DiplomatContextStep = { actor: string; action: Action };
|
|
export type DiplomatContextTransition = { before: Game; step: DiplomatContextStep; after: Game };
|
|
export type DiplomatContextKind = 'homeworld' | 'held-carthag' | 'empty-carthag';
|
|
export type DiplomatContextFixture = {
|
|
initial: Game;
|
|
offered: Game;
|
|
afterSetup: Game;
|
|
training: DiplomatContextTransition[];
|
|
shipments: DiplomatContextTransition[];
|
|
firstMentat: DiplomatContextTransition | null;
|
|
game: Game;
|
|
owner: string;
|
|
opponent: string;
|
|
trainer: string;
|
|
enemyLeader: string;
|
|
territory: string;
|
|
kind: DiplomatContextKind;
|
|
worthless: string;
|
|
source: string | null;
|
|
ownWeapon: string;
|
|
enemyWeapon: string;
|
|
plans: [DiplomatContextStep, DiplomatContextStep];
|
|
steps: DiplomatContextStep[];
|
|
staging: string[];
|
|
};
|
|
|
|
export const diplomatContextQuiet = (game: Game) =>
|
|
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
|
const plansReady = (game: Game) => diplomatContextQuiet(game) && !!game.battle &&
|
|
!game.battle.preparation && game.battle.preLeader?.closed !== false;
|
|
export const diplomatContextPlayer = (game: Game, actor: string) => {
|
|
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
|
};
|
|
|
|
/** Scalar shuffle/Storm draws only; UUID byte arrays retain native entropy.
|
|
* The original all14 skill shuffle is still controlled by its existing helper. */
|
|
function scalarEntropy<T>(run: () => T): T {
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const native = crypto.getRandomValues.bind(crypto);
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
|
else Reflect.apply(native, crypto, [array]);
|
|
return array;
|
|
};
|
|
try { return run(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
export function applyDiplomatContextStep(game: Game, step: DiplomatContextStep): Game {
|
|
return scalarEntropy(() => applyAction(game, step.actor, step.action));
|
|
}
|
|
export function nextDiplomatContextStep(game: Game): DiplomatContextStep {
|
|
const decision = game.decision;
|
|
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
|
|
action: { type: 'decision', event: decision.event, allow: true } };
|
|
if (decision?.kind === 'homeworldDefense' || decision?.kind === 'diplomatDefense')
|
|
throw Error('Stop at the original human late-defense/copy choice.');
|
|
const next = nextNativeSkillsStrongholdStep(game);
|
|
assert.ok(next, 'A genuine contextual programme needs a native continuation.'); return next;
|
|
}
|
|
export function advanceDiplomatContext(
|
|
state: Game, until: (game: Game) => boolean, steps: DiplomatContextStep[] = [],
|
|
): Game {
|
|
let game = state;
|
|
for (let n = 0; !until(game) && n < 1800; n++) {
|
|
assert.equal(game.status, 'playing');
|
|
const step = nextDiplomatContextStep(game); steps.push(structuredClone(step));
|
|
game = applyDiplomatContextStep(game, step);
|
|
}
|
|
assert.ok(until(game), 'Original contextual programme did not reach its requested boundary.'); return game;
|
|
}
|
|
|
|
/** All physical counters, including foreign Homeworld visitors, retain custody. */
|
|
export function assertDiplomatContextCustody(game: Game): void {
|
|
validateLeaderSkills(game.leaderSkills!, game.players);
|
|
if (game.homeworlds) homeworldGameIntegrity(game);
|
|
const homes = game.homeworlds?.custody
|
|
? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) : [];
|
|
for (const player of game.players) {
|
|
const visitors = homes.filter(home => home.native !== player.id)
|
|
.map(home => home.forces[player.id] ?? { normal: 0, elite: 0 });
|
|
const sum = (groups: Record<string, number>) => Object.values(groups).reduce((a, b) => a + b, 0);
|
|
assert.equal(player.reserves + player.tanks + sum(player.forces) +
|
|
visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20);
|
|
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
|
|
visitors.reduce((n, pool) => n + pool.elite, 0), 5);
|
|
}
|
|
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
|
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
|
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
|
}
|
|
|
|
/** Genuine Emperor/Richese/Guild setup, original paid shipment and original
|
|
* END Mentat. No army, wallet, clock, initiative, owner or receipt is assigned.
|
|
* Conserved physical card staging below is deliberately NOT an acquisition. */
|
|
export function createDiplomatContextJourney(options: { initial?: Game; kind?: DiplomatContextKind } = {}): DiplomatContextFixture {
|
|
const kind = options.kind ?? 'homeworld';
|
|
let game = options.initial ? structuredClone(options.initial)
|
|
: createGame('DIPLOMATCONTEXT', newPlayer('emperor', 'Emperor', 'emperor'), true, ['choam']);
|
|
if (!options.initial) for (const faction of ['richese', 'guild'] as const)
|
|
joinGame(game, newPlayer(faction, faction, faction));
|
|
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, true);
|
|
assert.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild', 'richese']);
|
|
if (kind === 'homeworld' && !game.homeworlds)
|
|
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
|
if (kind !== 'homeworld' && !game.strongholdCards)
|
|
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
|
assert.equal(!!game.homeworlds, kind === 'homeworld');
|
|
assert.equal(!!game.strongholdCards, kind !== 'homeworld');
|
|
const initial = structuredClone(game), steps: DiplomatContextStep[] = [];
|
|
const owner = game.players.find(p => p.faction === 'emperor')!.id;
|
|
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
|
game = scalarEntropy(() => initializeAdvancedNativeSkillsSetup({ family: 'richese',
|
|
requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'], initial: game }));
|
|
const offered = structuredClone(game), training: DiplomatContextTransition[] = [];
|
|
const act = (step: DiplomatContextStep) => {
|
|
steps.push(structuredClone(step)); game = applyDiplomatContextStep(game, step); return game;
|
|
};
|
|
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
|
let step: DiplomatContextStep;
|
|
if (game.setupStage === 'leaderSkills') {
|
|
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
|
const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
|
|
const skill = actor === owner ? 'diplomat' : offer.cards.find(card => !view.unavailableSkills?.[card]);
|
|
const leaders = diplomatContextPlayer(game, actor).leaders
|
|
.filter(leader => view.eligibleLeaders.some(candidate => candidate.id === leader.id))
|
|
.sort((a, b) => b.strength - a.strength);
|
|
assert.ok(skill && offer.cards.includes(skill) && leaders[0]);
|
|
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leaders[0].id } };
|
|
} else step = nextDiplomatContextStep(game);
|
|
const before = structuredClone(game); act(step);
|
|
if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
|
|
}
|
|
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
|
|
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'diplomat')!.leader;
|
|
const staging = [
|
|
'Original fresh native all14 shuffle offers Diplomat to Emperor; real setup actions select the strongest eligible trainer. Scalar native shuffle and Storm draws are controlled, UUID entropy is untouched.',
|
|
'Move six original unopened non-worm Spice Cards to the deck front; no spice pile, phase, force, wallet, owner or battle receipt is staged.',
|
|
];
|
|
const land = game.spiceDeck.filter(card => 'territory' in card).slice(0, 6);
|
|
for (const [position, card] of land.entries())
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
|
const reach = (until: (state: Game) => boolean) => {
|
|
game = advanceDiplomatContext(game, until, steps); return game;
|
|
};
|
|
const shipments: DiplomatContextTransition[] = [];
|
|
const ship = (actor: string, action: Action) => {
|
|
const before = structuredClone(game), step = { actor, action }; act(step);
|
|
reach(state => diplomatContextQuiet(state) && !state.pendingShipment && !state.pendingHomeworldShipment);
|
|
shipments.push({ before, step, after: structuredClone(game) });
|
|
};
|
|
let firstMentat: DiplomatContextTransition | null = null;
|
|
if (kind !== 'homeworld') {
|
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && diplomatContextQuiet(state));
|
|
ship(owner, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, allyPayment: 0 });
|
|
reach(state => state.phase === 8 && diplomatContextQuiet(state));
|
|
assert.equal(game.strongholdCards!.owners.carthag, null);
|
|
while (game.turn === 1) {
|
|
const before = structuredClone(game), step = nextDiplomatContextStep(game), after = act(step);
|
|
if (after.turn === 2) firstMentat = { before, step, after: structuredClone(after) };
|
|
}
|
|
assert.ok(firstMentat); assert.equal(game.strongholdCards!.owners.carthag, owner);
|
|
reach(state => state.turn === 2 && state.phase === 5 && state.active === opponent && diplomatContextQuiet(state));
|
|
ship(opponent, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, allyPayment: 0 });
|
|
} else {
|
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === opponent && diplomatContextQuiet(state));
|
|
const choice = homeworldShipmentChoice(viewGame(game, opponent), 'homeworld:emperor',
|
|
{ 'homeworld:guild': { normal: 3, elite: 0 } });
|
|
assert.ok(choice.action, choice.blocked ?? undefined); assert.ok(choice.cost > 0);
|
|
ship(opponent, choice.action);
|
|
}
|
|
reach(state => state.phase === 6 && diplomatContextQuiet(state));
|
|
// Deliberately exchange custody, never duplicate cards or claim a purchase.
|
|
// Keep hand capacity independent of random original starting-card ownership.
|
|
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
|
const held = (actor: string, predicate: (card: Card) => boolean): string => {
|
|
for (const pool of [game.deck, game.richeseCache ?? []]) {
|
|
const index = pool.findIndex(predicate);
|
|
if (index >= 0) {
|
|
const card = pool.splice(index, 1)[0]; diplomatContextPlayer(game, actor).hand.push(card);
|
|
staging.push(`Conserved physical ${card.id}: native stock/cache → ${actor}'s hand; not an acquired or paid card.`);
|
|
return card.id;
|
|
}
|
|
}
|
|
throw Error('Missing original physical card for labelled conserved custody staging.');
|
|
};
|
|
staging.push('At quiet Battle entry return original held Treachery to undealt stock before the labelled stock/cache transfers; all physical IDs and total deck/cache inventory are conserved.');
|
|
const worthless = held(owner, card => card.kind === 'worthless');
|
|
const ownWeapon = kind === 'homeworld' ? held(owner, card => card.kind === 'poison') : worthless;
|
|
const enemyWeapon = held(opponent, card => card.kind === 'poison');
|
|
const source = kind === 'empty-carthag' ? null : held(opponent, card => kind === 'homeworld'
|
|
? card.effect === 'portableSnooper' : card.kind === 'shield');
|
|
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
|
const enemyLeader = diplomatContextPlayer(game, opponent).leaders
|
|
.filter(leader => !leader.dead && !trained.has(leader.id)).sort((a, b) => a.strength - b.strength)[0].id;
|
|
const territory = kind === 'homeworld' ? 'homeworld:emperor' : 'carthag';
|
|
const actor = game.active!;
|
|
const choice = viewGame(game, actor).battleChoices.find(battle => battle.territory === territory &&
|
|
[battle.attacker, battle.defender].includes(owner) && [battle.attacker, battle.defender].includes(opponent));
|
|
assert.ok(choice && choice.chooser === actor);
|
|
act({ actor, action: { type: 'chooseBattle', territory, target: actor === owner ? opponent : owner } });
|
|
reach(plansReady); assert.deepEqual(game.battle!.plans, {});
|
|
const plans: DiplomatContextFixture['plans'] = [
|
|
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0,
|
|
weapon: ownWeapon, ...(kind === 'homeworld' ? { defense: worthless } : {}) } },
|
|
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader, dial: 0, support: 0,
|
|
weapon: enemyWeapon, ...(kind === 'held-carthag' ? { defense: source } : {}) } },
|
|
];
|
|
assertDiplomatContextCustody(game);
|
|
return { initial, offered, afterSetup, training, shipments, firstMentat, game, owner, opponent,
|
|
trainer, enemyLeader, territory, kind, worthless, source, ownWeapon, enemyWeapon, plans, steps, staging };
|
|
}
|
|
|
|
export function revealDiplomatContextJourney(fixture: DiplomatContextFixture, steps: DiplomatContextStep[] = []): Game {
|
|
let game = structuredClone(fixture.game);
|
|
for (const step of fixture.plans) {
|
|
steps.push(step); game = applyDiplomatContextStep(game, step);
|
|
if (!game.battle?.revealed) game = advanceDiplomatContext(game, plansReady, steps);
|
|
}
|
|
assert.ok(game.battle?.revealed); return game;
|
|
}
|
|
export function copyDiplomatContextJourney(state: Game, card: string): Game {
|
|
assert.ok(state.decision?.kind === 'diplomatDefense');
|
|
return applyDiplomatContextStep(state, { actor: state.decision.player,
|
|
action: { type: 'decision', event: state.decision.event, card } });
|
|
}
|
|
export function settleDiplomatContextJourney(state: Game, steps: DiplomatContextStep[] = []): Game {
|
|
return advanceDiplomatContext(state, game => !game.battle && diplomatContextQuiet(game), steps);
|
|
}
|
|
/** Finish original Battle cleanup and stop at the next original Collection phase. */
|
|
export function finishDiplomatContextJourney(state: Game, steps: DiplomatContextStep[] = []): Game {
|
|
return advanceDiplomatContext(state, game => game.phase === 7 && diplomatContextQuiet(game), steps);
|
|
}
|