Files
Dune/tests/fixture-richese-skills-tech.ts
T

301 lines
17 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 { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { createTechTokens } from '../game/tech-tokens';
import { nextSkillsTechBattleStep, openSkillsTechBattle, finishSkillsTechBattle } from './fixture-skills-tech-battle';
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
export type RicheseSkillsTechOptions = {
/** Fresh authenticated undealt E2 lobby; retain its original seat IDs/order. */
initial?: Game;
advanced?: boolean;
kind?: 'marker' | 'stone' | 'shipment';
marker?: 0 | 3 | 5;
reserves?: number;
band?: 'normal' | 'skilled';
/** Atreides opponent exposes its original concealed-marker prescience fences. */
opponent?: 'emperor' | 'atreides';
/** A separate actual four-spice revival, never funding fabricated for shipment. */
bankerPayment?: boolean;
precedence?: 'double-traitor' | 'explosion';
};
export type RicheseSkillsTechStep = { actor: string; action: Action };
export type RicheseSkillsTechFixture = {
game: Game;
initial: Game;
setup: Game;
actions: RicheseSkillsTechStep[];
staging: string[];
owner: string;
opponent: string;
banker: string;
trainer: string;
territory: string;
key: string;
marker: 0 | 3 | 5;
kind: 'marker' | 'stone' | 'shipment';
beforeShipment: Game | null;
shipmentAction: Action | null;
afterShipment: Game | null;
acquisition: { before: Game; actions: RicheseSkillsTechStep[]; after: Game };
ownerPlan: Action;
opponentPlan: Action;
};
export function richeseSkillsTechPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
}
/** Control only the original all14 offer shuffle. All later shuffles are native. */
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
const cards = LEADER_SKILL_CARDS.map(c => c.id);
const remaining = cards.filter(c => !selected.includes(c));
const desired = selected.flatMap(c => [c, remaining.shift()!]);
desired.push(...remaining);
const working = [...cards], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[index++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Existing legal no-spend/no-call policy, with the actual Richese unbid producer. */
export function nextRicheseSkillsTechNativeStep(game: Game): RicheseSkillsTechStep {
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision, actor = d.player, event = game.richeseBidding?.event;
if (d.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
if (d.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
if (d.kind === 'richeseCache') {
const card = game.richeseCache!.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache![0];
assert.ok(card);
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
}
if (d.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } };
if (d.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: d.event, mode: 'ignore' } };
}
const lot = game.richeseAuction;
if (!game.response && !game.phaseOpening && !game.decision && lot && !lot.outcome) {
const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
return nextSkillsTechBattleStep(game);
}
function step(game: Game, actions?: RicheseSkillsTechStep[]): Game {
const next = nextRicheseSkillsTechNativeStep(game);
actions?.push(next);
return applyAction(game, next.actor, next.action);
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function advanceRicheseSkillsTechToPhase(state: Game, phase: number, actions?: RicheseSkillsTechStep[]): Game {
let game = state;
for (let n = 0; (game.phase < phase || !clean(game)) && game.turn === state.turn && n < 1200; n++) game = step(game, actions);
assert.equal(game.turn, state.turn);
assert.ok(game.phase >= phase);
assert.ok(clean(game));
return game;
}
export function finishRicheseSkillsTechBattle(state: Game, stop: 'skill' | 'cleanup' | 'tech' | 'complete' = 'complete'): Game {
return finishSkillsTechBattle(state, stop);
}
export function openRicheseSkillsTechBattle(fixture: RicheseSkillsTechFixture, hide = true): Game {
return openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory, hide);
}
function weakestUntrained(game: Game, id: string): string {
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const leader = richeseSkillsTechPlayer(game, id).leaders.filter(l => !assigned.has(l.id) && !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader);
return leader.id;
}
/** Exchange original physical Traitor custody; never mint another identity. */
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = richeseSkillsTechPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
const at = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(at >= 0 || donor);
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved native Traitor ${identity} exchanged for ${returned}.`);
}
function held(game: Game, actor: string, kind: Player['hand'][number]['kind'], staging: string[]): string {
const player = richeseSkillsTechPlayer(game, actor);
const at = game.deck.findIndex(c => c.kind === kind);
assert.ok(at >= 0);
const card = game.deck.splice(at, 1)[0]; player.hand.push(card);
staging.push(`Conserved ${card.id}: undealt native deck to ${actor}'s hand.`);
return card.id;
}
/** Original fresh setup, first Storm and canonical token ownership, real cache
* acquisition and phase transitions. Only skill offers, unplayed Spice order,
* conserved forces/cards/Traitors are controlled and explicitly labelled.
* Never assign wallets, turn, phases, plans, receipts, marker or Tech ownership.
* Shipment stops BEFORE the real marker/companion action for authenticated UI use;
* battle stops BEFORE chooseBattle, with live native private plan controls next. */
export function createRicheseSkillsTechFixture(options: RicheseSkillsTechOptions = {}): RicheseSkillsTechFixture {
const kind = options.kind ?? 'marker', marker = options.marker ?? 5;
const skill = kind === 'shipment' ? 'smuggler' : 'suk-graduate';
let game = options.initial ? structuredClone(options.initial)
: createGame('RICHESKILLTECH', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? true, ['choam']);
if (!options.initial) {
joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
joinGame(game, newPlayer('opponent', 'Emperor', 'emperor'));
}
assert.equal(game.status, 'lobby', 'Only a fresh authenticated undealt lobby can enter.');
assert.deepEqual(game.expansions, ['choam']);
assert.ok(game.players.length >= 3 && game.players.length <= 6);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const owner = game.players.find(p => p.faction === 'richese')!.id;
const opponent = game.players.find(p => p.faction === (options.opponent ?? 'emperor'))!.id;
const banker = game.players.find(p => p.faction === 'atreides')!.id;
assert.ok(owner && opponent && banker);
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
assert.deepEqual(game.techTokens, createTechTokens());
for (const seat of game.players) if (!seat.ready) game = applyAction(game, seat.id, { type: 'ready' });
const initial = structuredClone(game), actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
const safe: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster', 'killer-medic'];
const selected = game.players.map(p => p.id === owner ? skill : p.id === banker ? 'spice-banker' as const : safe.shift()!);
const bySeat: Record<string, LeaderSkillId> = Object.fromEntries(game.players.map((p, i) => [p.id, selected[i]]));
game = withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
const setup = structuredClone(game);
for (let n = 0; game.status === 'setup' && n < 200; n++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const eligible = view.eligibleLeaders.map(l => l.id);
const leaders = richeseSkillsTechPlayer(game, actor).leaders.filter(l => eligible.includes(l.id))
.sort((a, b) => actor === owner ? b.strength - a.strength : a.strength - b.strength);
const selectedSkill = bySeat[actor];
assert.ok(offer.cards.includes(selectedSkill) && !view.unavailableSkills?.[selectedSkill] && leaders[0]);
const action: Action = { type: 'leaderSkill', event: offer.event, skill: selectedSkill, leader: leaders[0].id };
actions.push({ actor, action }); game = applyAction(game, actor, action);
} else game = step(game, actions);
}
assert.equal(game.status, 'playing');
// Two controlled, still-unplayed original blows, not minted cards or resources.
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
for (const [position, card] of blows.entries()) {
const at = game.spiceDeck.indexOf(card);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Original all14 offer shuffle and first two unplayed non-worm Spice Cards are controlled.');
for (let n = 0; game.decision?.kind !== 'richeseCache' && n < 1000; n++) game = step(game, actions);
assert.equal(game.decision?.kind, 'richeseCache');
const acquisitionBefore = structuredClone(game), acquisitionActions: RicheseSkillsTechStep[] = [];
for (let n = 0; !richeseSkillsTechPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner') && n < 100; n++) game = step(game, acquisitionActions);
assert.ok(richeseSkillsTechPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner'));
actions.push(...acquisitionActions);
const acquisition = { before: acquisitionBefore, actions: acquisitionActions, after: structuredClone(game) };
if (options.bankerPayment) {
game = advanceRicheseSkillsTechToPhase(game, 4, actions);
const payer = game.players.find(p => p.faction === 'emperor');
assert.ok(payer);
assert.ok(payer.reserves >= 3);
payer.reserves -= 3; payer.tanks += 3;
staging.push('Three original Emperor reserve counters conserved into Tanks before actual four-spice revival.');
const action: Action = { type: 'revive', amount: 3 };
actions.push({ actor: payer.id, action }); game = applyAction(game, payer.id, action);
}
game = advanceRicheseSkillsTechToPhase(game, 5, actions);
for (let n = 0; game.active !== owner && n < 20; n++) game = step(game, actions);
assert.equal(game.phase, 5); assert.equal(game.active, owner);
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
const kept = p.id === owner ? p.hand.filter(c => c.id === 'richese-stone-burner') : [];
game.deck.push(...p.hand.filter(c => !kept.includes(c))); p.hand = kept;
}
staging.push('Return original board counters to their own reserves and ordinary hands to undealt deck; retain the cache-acquired Stone in Richese custody.');
const destination = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm));
assert.ok(destination);
const territory = destination.id, key = `${territory}:${destination.sectors[0]}`;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === skill)!.leader;
let beforeShipment: Game | null = null, shipmentAction: Action | null = null, afterShipment: Game | null = null;
if (kind !== 'stone') {
const own = richeseSkillsTechPlayer(game, owner), token = own.noField!.tokens.find(t => t.value === marker)!;
shipmentAction = { type: 'ship', territory, sector: destination.sectors[0], noField: token.id, event: own.noFieldEvent,
...(kind === 'shipment' ? { smuggler: true } : {}) };
beforeShipment = structuredClone(game);
if (kind !== 'shipment') {
actions.push({ actor: owner, action: shipmentAction }); game = applyAction(game, owner, shipmentAction);
for (let n = 0; !clean(game) && n < 100; n++) game = step(game, actions);
afterShipment = structuredClone(game);
}
}
const dial = kind === 'marker' ? Math.min(marker, options.reserves ?? 20, 3) : 2;
let ownerPlan: Action = { type: 'battlePlan', dial, support: game.advanced ? dial : 0,
leader: options.band === 'normal' ? weakestUntrained(game, owner) : trainer };
let opponentPlan: Action = { type: 'battlePlan', dial: 0, support: 0, leader: weakestUntrained(game, opponent) };
if (kind !== 'shipment') {
const own = richeseSkillsTechPlayer(game, owner), enemy = richeseSkillsTechPlayer(game, opponent);
const reserveLimit = options.reserves ?? 20;
assert.ok(reserveLimit >= 0 && reserveLimit <= own.reserves);
if (kind === 'marker' && own.reserves > reserveLimit) {
own.forces['polar_sink:0'] = own.reserves - reserveLimit; own.reserves = reserveLimit;
staging.push(`Conserve Richese counters outside battle in Polar Sink, leaving ${reserveLimit} real reserves.`);
}
const enemyAmount = kind === 'stone' ? 4 : 6;
enemy.reserves -= enemyAmount; enemy.forces[key] = enemyAmount;
if (kind === 'stone') {
own.reserves -= 8; own.forces[key] = 8;
ownerPlan = { ...ownerPlan, weapon: 'richese-stone-burner' };
opponentPlan = { ...opponentPlan, dial: 1, support: game.advanced ? 1 : 0 };
if (options.precedence === 'explosion') {
ownerPlan.defense = held(game, owner, 'shield', staging);
opponentPlan.weapon = held(game, opponent, 'lasgun', staging);
}
if (options.precedence === 'double-traitor') {
exchangeTraitor(game, owner, String(opponentPlan.leader), staging);
exchangeTraitor(game, opponent, String(ownerPlan.leader), staging);
}
} else {
ownerPlan.defense = held(game, owner, 'shield', staging);
opponentPlan.weapon = held(game, opponent, 'worthless', staging);
}
staging.push(`Conserved ordinary opponent battle counters: ${enemyAmount}; Stone owner counters: ${kind === 'stone' ? 8 : 0}.`);
game = advanceRicheseSkillsTechToPhase(game, 6, actions);
assert.equal(game.phase, 6);
}
assertAdvancedNativeCustody(game);
return { game, initial, setup, actions, staging, owner, opponent, banker, trainer, territory, key, marker, kind,
beforeShipment, shipmentAction, afterShipment, acquisition, ownerPlan, opponentPlan };
}
export function payRicheseSkillsTechShipment(fixture: RicheseSkillsTechFixture): Game {
assert.equal(fixture.kind, 'shipment'); assert.ok(fixture.shipmentAction);
let game = applyAction(fixture.game, fixture.owner, fixture.shipmentAction);
for (let n = 0; (!clean(game) || game.pendingShipment) && n < 200; n++) game = step(game);
assert.ok(clean(game) && !game.pendingShipment);
return game;
}