406 lines
26 KiB
TypeScript
406 lines
26 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game, type GameView, type Player,
|
|
} from '../game/engine';
|
|
import { gameDistance, splitLocation, TERRITORIES, territory } from '../game/board';
|
|
import { treacheryDeck, type Card } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
|
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
|
import { nextNativeSkillsStrongholdStep } from './fixture-native-skills-stronghold';
|
|
|
|
export type PairedChoamNexusSkillsStep = { actor: string; action: Action };
|
|
export type PairedChoamNexusSkillsOptions = {
|
|
/** Original fresh lobby OR initialized turn-one setup; retain hands, offers and IDs. */
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
/** Fresh-lobby roster only. Setup supports every original two-to-six-seat pair. */
|
|
factions?: readonly FactionId[];
|
|
seatIds?: readonly string[];
|
|
/** Omit on a supplied setup to choose its actual available offer, never replace it. */
|
|
richeseSkill?: LeaderSkillId;
|
|
choamSkill?: LeaderSkillId;
|
|
reserveCap?: number;
|
|
};
|
|
export type PairedChoamNexusSkillsFixture = {
|
|
initial: Game;
|
|
afterSetup: Game;
|
|
afterFirstStorm: Game;
|
|
firstShipment: { before: Game; step: PairedChoamNexusSkillsStep; after: Game };
|
|
beforeFirstMentat: Game;
|
|
firstMentatStep: PairedChoamNexusSkillsStep;
|
|
afterFirstMentat: Game;
|
|
beforeAlliance: Game;
|
|
allianceActions: PairedChoamNexusSkillsStep[];
|
|
beforeClosingDraw: Game;
|
|
closingDraws: { before: Game; step: PairedChoamNexusSkillsStep; after: Game }[];
|
|
movementStart: Game;
|
|
game: Game;
|
|
richese: string;
|
|
choam: string;
|
|
guild: string;
|
|
observer: string;
|
|
location: string;
|
|
trainers: { richese: string; choam: string };
|
|
actions: PairedChoamNexusSkillsStep[];
|
|
staging: string[];
|
|
};
|
|
export const pairedSkillsSnapshot = (game: Game): Game => structuredClone(game);
|
|
export const pairedSkillsClean = (game: Game): boolean => !game.phaseOpening && !game.response && !game.decision;
|
|
export function pairedSkillsPlayer(game: Game, actor: string): Player {
|
|
const player = game.players.find(p => p.id === actor);
|
|
assert.ok(player, `Missing original paired E2 skill actor ${actor}`);
|
|
return player;
|
|
}
|
|
|
|
/** Control only the original all14 shuffle before native starting deals. Generic
|
|
* requests use the bound native method and still return the supplied array. */
|
|
function setupEntropy<T>(operation: () => T, selected: LeaderSkillId[]): T {
|
|
assert.equal(new Set(selected).size, selected.length);
|
|
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(c => c.id);
|
|
const remainder = working.filter(c => !selected.includes(c));
|
|
const desired: LeaderSkillId[] = selected.flatMap(c => [c, remainder.shift()!]);
|
|
desired.push(...remainder);
|
|
const 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) * 0x100000000 / (i + 1)));
|
|
[working[i], working[j]] = [working[j], working[i]];
|
|
}
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const native = crypto.getRandomValues.bind(crypto);
|
|
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 initializePairedChoamNexusSkills(options: PairedChoamNexusSkillsOptions = {}): Game {
|
|
const factions: readonly FactionId[] = options.factions ?? ['richese', 'choam', 'guild', 'atreides'];
|
|
let game = options.initial ? pairedSkillsSnapshot(options.initial)
|
|
: createGame('PAIREDCHOAMNEXUSSKILLS', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? true, ['choam']);
|
|
if (!options.initial) for (const faction of factions.slice(1))
|
|
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
|
|
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
|
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.leaderSkills?.assignments.length === 0,
|
|
'Continue only the original lobby or initialized unassigned turn-one skill setup');
|
|
if (game.status === 'lobby' && !game.nexusCards) game.nexusCards = { cards: null, phase: null };
|
|
assert.ok(pairedNexusLeaderSkillsProfile(game));
|
|
assert.deepEqual(game.expansions, ['choam']);
|
|
if (game.status === 'setup') {
|
|
assert.equal(game.setupStage, 'leaderSkills');
|
|
assertPairedChoamSkillsCustody(game);
|
|
return game;
|
|
}
|
|
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
|
const richeseSkill = options.richeseSkill ?? 'suk-graduate';
|
|
const choamSkill = options.choamSkill ?? 'planetologist';
|
|
const passive: LeaderSkillId[] = ['sandmaster', 'warmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz']
|
|
.filter(c => c !== richeseSkill && c !== choamSkill) as LeaderSkillId[];
|
|
const selected: LeaderSkillId[] = game.players.map(p => p.faction === 'richese' ? richeseSkill : p.faction === 'choam' ? choamSkill : passive.shift()!);
|
|
return setupEntropy(() => initializeLeaderSkillsGameForAudit(game), selected);
|
|
}
|
|
|
|
export function nextPairedChoamSkillsStep(game: Game): PairedChoamNexusSkillsStep | null {
|
|
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
|
|
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
|
|
assert.ok(actor);
|
|
const offer: NonNullable<GameView['leaderSkills']> = viewGame(game, actor).leaderSkills!;
|
|
const skill: LeaderSkillId | undefined = offer.offer!.cards.find(c => !offer.unavailableSkills?.[c]);
|
|
const eligible = new Set(offer.eligibleLeaders.map(l => l.id));
|
|
const leader = pairedSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(skill && leader);
|
|
return { actor, action: { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id } };
|
|
}
|
|
if (pairedSkillsClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
|
const phase = game.nexusCards.phase;
|
|
const actor: string | undefined = phase.eligible.find(id => !phase.done.includes(id));
|
|
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
|
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
|
|
}
|
|
if (!game.phaseOpening && !game.response) {
|
|
const decision = game.decision;
|
|
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
|
|
action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
|
|
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
|
|
if (decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
|
|
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
|
if (decision?.kind === 'nexusChoamInspection')
|
|
return { actor: decision.player, action: { type: 'decision', event: decision.event, inspect: false } };
|
|
}
|
|
return nextNativeSkillsStrongholdStep(game);
|
|
}
|
|
export function stepPairedChoamSkills(game: Game, next = nextPairedChoamSkillsStep(game), actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
assert.ok(next, 'The original paired skill continuation needs a human plan');
|
|
actions?.push(structuredClone(next));
|
|
return applyAction(game, next.actor, next.action);
|
|
}
|
|
export function advancePairedChoamSkills(game: Game, until: (state: Game) => boolean, actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
for (let n = 0; n < 1800; n++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
game = stepPairedChoamSkills(game, undefined, actions);
|
|
}
|
|
throw Error('Original paired E2 Skills continuation did not reach its requested boundary');
|
|
}
|
|
export function advancePairedChoamSkillsToPhase(game: Game, phase: number, actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
const turn = game.turn;
|
|
return advancePairedChoamSkills(game, state => {
|
|
assert.equal(state.turn, turn, 'Do not chase an auto-skipped phase into the next turn');
|
|
return state.phase >= phase && pairedSkillsClean(state);
|
|
}, actions);
|
|
}
|
|
export function completePairedChoamSkillsSetup(game: Game, options: PairedChoamNexusSkillsOptions = {}, actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
|
if (game.setupStage === 'leaderSkills') {
|
|
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
|
|
const p = pairedSkillsPlayer(game, actor);
|
|
const requested: LeaderSkillId | undefined = p.faction === 'richese' ? options.richeseSkill : p.faction === 'choam' ? options.choamSkill : undefined;
|
|
const offer: NonNullable<GameView['leaderSkills']> = viewGame(game, actor).leaderSkills!;
|
|
const skill: LeaderSkillId | undefined = requested ?? offer.offer!.cards.find(c => !offer.unavailableSkills?.[c]);
|
|
assert.ok(skill && offer.offer!.cards.includes(skill) && !offer.unavailableSkills?.[skill], 'Requested skill must genuinely exist in the saved original offer');
|
|
const eligible = new Set(offer.eligibleLeaders.map(l => l.id));
|
|
const leader = p.leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(leader);
|
|
game = stepPairedChoamSkills(game, { actor, action: { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id } }, actions);
|
|
} else game = stepPairedChoamSkills(game, undefined, actions);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
return game;
|
|
}
|
|
export function pairedSkillsPhysicalCards(game: Game): string[] {
|
|
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
|
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
|
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort();
|
|
}
|
|
export function assertPairedChoamSkillsCustody(game: Game): void {
|
|
validateLeaderSkills(game.leaderSkills!, game.players);
|
|
const skills = game.leaderSkills!;
|
|
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
|
|
validateNexusCards(game.nexusCards!.cards!, game.players);
|
|
const cards = game.nexusCards!.cards!;
|
|
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
|
assert.deepEqual(pairedSkillsPhysicalCards(game), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
|
|
for (const p of game.players) {
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
|
|
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5);
|
|
}
|
|
}
|
|
function reserveBoard(game: Game, staging: string[]): void {
|
|
for (const p of game.players) {
|
|
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
|
|
if (p.elites) {
|
|
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {};
|
|
}
|
|
if (p.advisors) p.advisors = {};
|
|
}
|
|
staging.push('Conserved original board counters into their own reserves; wallets, turns, phases, skills and custody receipts are unchanged.');
|
|
}
|
|
export function placePairedSkillsForce(game: Game, actor: string, key: string, count: number, staging?: string[]): void {
|
|
const p = pairedSkillsPlayer(game, actor);
|
|
assert.ok(Number.isSafeInteger(count) && count >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= count);
|
|
p.reserves -= count;
|
|
if (count) p.forces[key] = (p.forces[key] ?? 0) + count;
|
|
staging?.push(`Conserved ${count} ordinary ${p.faction} counters: native reserves → ${key}; not played movement history.`);
|
|
}
|
|
function orderUndealtSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void {
|
|
const front = kinds.map(kind => {
|
|
const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c : 'territory' in c);
|
|
assert.ok(index >= 0, 'Only genuine still-undealt Spice Cards can be ordered');
|
|
return game.spiceDeck.splice(index, 1)[0];
|
|
});
|
|
game.spiceDeck.unshift(...front);
|
|
staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; played Spice Discards remain untouched.`);
|
|
}
|
|
export function holdPairedSkillsCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[] = []): Card {
|
|
const p = pairedSkillsPlayer(game, actor), held = p.hand.find(matches);
|
|
if (held) return held;
|
|
const index = game.deck.findIndex(matches);
|
|
assert.ok(index >= 0 && p.hand.length < handLimit(p), 'Required original physical card and native hand capacity must exist');
|
|
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
|
|
staging.push(`Conserved original ${card.id}: unused E2 deck → ${p.faction} hand; not a natural deal.`);
|
|
return card;
|
|
}
|
|
|
|
/** Native starts/first deals → actual Storm → previous zero-marker shipment →
|
|
* actual END Mentat → undealt worm → reciprocal classic alliance → two own
|
|
* closing Cunning draws. No skill effects, marker history or wallets are granted. */
|
|
export function createPairedChoamNexusSkillsFixture(options: PairedChoamNexusSkillsOptions = {}): PairedChoamNexusSkillsFixture {
|
|
const actions: PairedChoamNexusSkillsStep[] = [], staging: string[] = [];
|
|
let game = initializePairedChoamNexusSkills(options);
|
|
const initial = pairedSkillsSnapshot(game);
|
|
game = completePairedChoamSkillsSetup(game, options, actions);
|
|
const afterSetup = pairedSkillsSnapshot(game);
|
|
const richese = game.players.find(p => p.faction === 'richese')!.id;
|
|
const choam = game.players.find(p => p.faction === 'choam')!.id;
|
|
const guild = game.players.find(p => p.faction === 'guild')?.id;
|
|
const observer = game.players.find(p => p.faction !== 'richese' && p.faction !== 'choam' && p.faction !== 'guild')?.id;
|
|
assert.ok(guild && observer, 'Both own Cunning deals require two other real alliance seats including native Guild');
|
|
const location = `habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`;
|
|
game = advancePairedChoamSkillsToPhase(game, 1, actions);
|
|
const afterFirstStorm = pairedSkillsSnapshot(game);
|
|
orderUndealtSpice(game, ['land', 'land'], staging);
|
|
game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions);
|
|
reserveBoard(game, staging);
|
|
const before = pairedSkillsSnapshot(game), p = pairedSkillsPlayer(game, richese);
|
|
const shipmentStep: PairedChoamNexusSkillsStep = { actor: richese, action: { type: 'ship',
|
|
noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent,
|
|
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } };
|
|
game = stepPairedChoamSkills(game, shipmentStep, actions);
|
|
game = allowPairedChoamSkills(game, actions);
|
|
const firstShipment = { before, step: shipmentStep, after: pairedSkillsSnapshot(game) };
|
|
game = revealPairedSkillsRichese(game, richese, actions);
|
|
game = advancePairedChoamSkillsToPhase(game, 8, actions);
|
|
reserveBoard(game, staging);
|
|
placePairedSkillsForce(game, richese, location, 1, staging);
|
|
placePairedSkillsForce(game, choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging);
|
|
let beforeFirstMentat: Game | undefined, firstMentatStep: PairedChoamNexusSkillsStep | undefined;
|
|
while (game.turn === 1) {
|
|
const previous: Game = pairedSkillsSnapshot(game);
|
|
const next: PairedChoamNexusSkillsStep | null = nextPairedChoamSkillsStep(game); assert.ok(next);
|
|
game = stepPairedChoamSkills(game, next, actions);
|
|
if (game.turn === 2) { beforeFirstMentat = previous; firstMentatStep = next; }
|
|
}
|
|
assert.ok(beforeFirstMentat && firstMentatStep);
|
|
const afterFirstMentat = pairedSkillsSnapshot(game);
|
|
game = advancePairedChoamSkillsToPhase(game, 1, actions);
|
|
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
|
|
game = advancePairedChoamSkills(game, g => g.nexus === true && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), actions);
|
|
const beforeAlliance = pairedSkillsSnapshot(game), allianceActions: PairedChoamNexusSkillsStep[] = [
|
|
{ actor: guild, action: { type: 'alliance', target: observer } },
|
|
{ actor: observer, action: { type: 'alliance', target: guild } },
|
|
];
|
|
for (const next of allianceActions) game = stepPairedChoamSkills(game, next, actions);
|
|
game = advancePairedChoamSkills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), actions);
|
|
const beforeClosingDraw = pairedSkillsSnapshot(game), closingDraws: PairedChoamNexusSkillsFixture['closingDraws'] = [];
|
|
for (const [actor, card] of [[richese, 'richese'], [choam, 'choam']] as const) {
|
|
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); assert.ok(index >= 0);
|
|
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
|
staging.push(`Conserved original unused ${card} Nexus singleton placed first before its actual closing draw.`);
|
|
const drawBefore = pairedSkillsSnapshot(game);
|
|
const next: PairedChoamNexusSkillsStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
|
|
game = stepPairedChoamSkills(game, next, actions);
|
|
closingDraws.push({ before: drawBefore, step: next, after: pairedSkillsSnapshot(game) });
|
|
}
|
|
game = advancePairedChoamSkillsToPhase(game, 5, actions);
|
|
reserveBoard(game, staging);
|
|
const movementStart = pairedSkillsSnapshot(game);
|
|
game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions);
|
|
if (options.reserveCap !== undefined) {
|
|
const p = pairedSkillsPlayer(game, richese);
|
|
assert.ok(Number.isSafeInteger(options.reserveCap) && options.reserveCap >= 0 && options.reserveCap <= p.reserves);
|
|
placePairedSkillsForce(game, richese, 'polar_sink:0', p.reserves - options.reserveCap, staging);
|
|
}
|
|
assertPairedChoamSkillsCustody(game);
|
|
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
|
|
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
|
|
return { initial, afterSetup, afterFirstStorm, firstShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat,
|
|
beforeAlliance, allianceActions, beforeClosingDraw, closingDraws, movementStart, game, richese, choam, guild, observer, location, trainers, actions, staging };
|
|
}
|
|
/** Exact failure-before API for the parent's real signed-pair validator integration.
|
|
* This is the original action shape, with no fabricated skill proof/companion. */
|
|
export function pairedSkillsRicheseCunningRequest(f: PairedChoamNexusSkillsFixture, game = f.game): Action {
|
|
const p = pairedSkillsPlayer(game, f.richese), offer = viewGame(game, f.richese).nexusRicheseCunning;
|
|
assert.ok(offer && !offer.blocked);
|
|
return { type: 'ship', noField: p.noField!.tokens.find(t => t.value === 3)!.id,
|
|
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id, nexus: offer.event, event: p.noFieldEvent,
|
|
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0], allyPayment: 0 };
|
|
}
|
|
export function allowPairedChoamSkills(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
return advancePairedChoamSkills(game, pairedSkillsClean, actions);
|
|
}
|
|
export function revealPairedSkillsRichese(game: Game, actor: string, actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
const p = pairedSkillsPlayer(game, actor); assert.ok(p.noField!.deployed);
|
|
return stepPairedChoamSkills(game, { actor, action: { type: 'revealNoField', event: p.noFieldEvent, token: p.noField!.deployed.tokenId } }, actions);
|
|
}
|
|
export function openPairedSkillsBattle(game: Game, owner: string, opponent: string,
|
|
options: { territory?: string; band?: 'normal' | 'skilled'; actions?: PairedChoamNexusSkillsStep[] } = {}): Game {
|
|
const actions = options.actions;
|
|
game = advancePairedChoamSkillsToPhase(game, 6, actions);
|
|
assert.equal(game.phase, 6, 'The genuine battle needs conserved opposing physical forces');
|
|
assert.ok(game.active === owner || game.active === opponent);
|
|
game = stepPairedChoamSkills(game, { actor: game.active!, action: { type: 'chooseBattle',
|
|
territory: options.territory ?? 'habbanya_ridge_sietch', target: game.active === owner ? opponent : owner } }, actions);
|
|
for (let n = 0; n < 100; n++) {
|
|
if (pairedSkillsClean(game) && game.battle && (!game.battle.preLeader || game.battle.preLeader.closed) && !game.battle.preparation) return game;
|
|
const decision = game.decision;
|
|
const next: PairedChoamNexusSkillsStep | null = decision?.kind === 'leaderSkillVisibility'
|
|
? { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
|
|
hide: decision.player === owner && options.band === 'skilled' } }
|
|
: nextPairedChoamSkillsStep(game);
|
|
game = stepPairedChoamSkills(game, next, actions);
|
|
}
|
|
throw Error('The original battle did not reach its actual human plan boundary');
|
|
}
|
|
export function pairedSkillsBattlePlans(game: Game, owner: string, opponent: string, dial = 2,
|
|
trained = true): PairedChoamNexusSkillsStep[] {
|
|
const p = pairedSkillsPlayer(game, owner), trainedLeader = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
|
const leader = trained ? p.leaders.find(l => l.id === trainedLeader && !l.dead)
|
|
: p.leaders.filter(l => !l.dead && l.id !== trainedLeader).sort((a, b) => b.strength - a.strength)[0];
|
|
const targetTrainer = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader;
|
|
const target = pairedSkillsPlayer(game, opponent).leaders.filter(l => !l.dead && l.id !== targetTrainer).sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(leader && target && leader.strength + dial > target.strength);
|
|
return [
|
|
{ actor: owner, action: { type: 'battlePlan', leader: leader.id, dial, support: game.advanced ? dial : 0, weapon: null, defense: null } },
|
|
{ actor: opponent, action: { type: 'battlePlan', leader: target.id, dial: 0, support: 0, weapon: null, defense: null } },
|
|
];
|
|
}
|
|
export function finishPairedSkillsBattle(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
|
|
return advancePairedChoamSkills(game, g => !g.battle && pairedSkillsClean(g) && !g.pendingTreacheryDiscard, actions);
|
|
}
|
|
/** Original skill posture and native bank/support legs on the real revealed source. */
|
|
export function quotePairedSkillsBattle(game: Game) {
|
|
const battle = game.battle; assert.ok(battle?.revealed);
|
|
const side = (actor: string): ResolutionCombatant => {
|
|
const p = pairedSkillsPlayer(game, actor), view = viewGame(game, actor).battle!;
|
|
assert.ok(view.ownForces);
|
|
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
|
|
plan: battle.plans[actor], leader: p.leaders.find(l => l.id === battle.plans[actor].leader),
|
|
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
|
|
leaderSkills: game.leaderSkills!.assignments.filter(a => a.owner === actor).map(a => ({
|
|
skill: a.skill, leader: a.leader, faceUp: !battle.leaderSkillHidden?.[actor], captured: false,
|
|
})) };
|
|
};
|
|
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
|
|
aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
|
|
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
|
|
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : pairedSkillsPlayer(game, actor).ally!,
|
|
called: battle.traitorCalls[actor] ?? false, traitors: pairedSkillsPlayer(game, actor).traitors })),
|
|
participants: game.players,
|
|
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])],
|
|
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false });
|
|
}
|
|
export function pairedSkillsChoamCunningRequest(game: Game, actor: string, card: string): Action {
|
|
const offer = viewGame(game, actor).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === card && p.effect === 'kulon');
|
|
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
|
|
return { type: 'card', mode: 'choam', card, effect: 'kulon', nexus: offer.event };
|
|
}
|
|
/** Ordinary three-territory route, not No-Field or a special movement-card route.
|
|
* Kulon plus the original Planetologist range is tested through native actions. */
|
|
export function stagePairedSkillsPlanetologistRoute(game: Game, actor: string, staging?: string[]): { from: string; to: string; action: Action } {
|
|
const keys = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
|
|
let to: string | undefined;
|
|
const from = keys.find(origin => {
|
|
if (splitLocation(origin).sector === game.storm) return false;
|
|
to = keys.find(target => splitLocation(target).sector !== game.storm &&
|
|
gameDistance(game, origin, target, key => splitLocation(key).sector === game.storm) === 3);
|
|
return !!to;
|
|
});
|
|
assert.ok(from && to, 'A clear ordinary three-territory Planetologist route must exist');
|
|
placePairedSkillsForce(game, actor, from, 1, staging);
|
|
return { from, to, action: { type: 'move', planetologist: 'range', forces: { [from]: 1 }, ...splitLocation(to) } };
|
|
}
|