Files
Dune/tests/fixture-discovery-skills-strongholds.ts

199 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game, type Player } from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { createStrongholdCards, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
nextClassicDiscoverySkillsStep, revealClassicDiscoverySkills,
type ClassicDiscoverySkillsFixture, type ClassicDiscoverySkillsOptions,
} from './fixture-discovery-classic-skills';
import { finishClassicSkillsStrongholdBattle, nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
export type DiscoverySkillsStrongholdsStep = { actor: string; action: Action };
export type DiscoverySkillsStrongholdsOptions = {
/** Fresh authenticated lobby or original undealt setup, never a played save. */
initial?: Game;
tech?: boolean;
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
holder?: 'owner' | 'unclaimed';
skill?: LeaderSkillId;
hide?: boolean;
nestedBattle?: boolean;
ownerDial?: number;
support?: number;
opponentDial?: number;
opponentSupport?: number;
ownerWeapon?: Card['kind'];
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
opponentDefense?: Card['kind'];
skillOwner?: ClassicDiscoverySkillsOptions['skillOwner'];
};
export type DiscoverySkillsStrongholdsFixture = {
discovery: ClassicDiscoverySkillsFixture;
initial: Game;
setup: Game;
beforeFirstMentat: Game;
firstMentatStep: DiscoverySkillsStrongholdsStep;
entryWindow: Game;
entryStep: DiscoverySkillsStrongholdsStep;
entered: Game;
shipmentWindow: Game;
paidShipmentStep: DiscoverySkillsStrongholdsStep;
game: Game;
owner: string;
opponent: string;
leader: string;
kind: StrongholdId;
key: string;
hide: boolean;
actions: DiscoverySkillsStrongholdsStep[];
planActions: DiscoverySkillsStrongholdsStep[];
staging: string[];
};
export function discoverySkillsStrongholdsPlayer(game: Game, actor: string): Player {
const player = game.players.find(candidate => candidate.id === actor);
assert.ok(player); return player;
}
function reserveExcept(game: Game, keep: string[]): void {
for (const player of game.players) for (const key of Object.keys(player.forces)) {
if (keep.includes(key)) continue;
const elite = player.elites?.forces[key] ?? 0;
player.reserves += player.forces[key];
if (player.elites) { player.elites.reserves += elite; delete player.elites.forces[key]; }
delete player.forces[key];
if (player.advisors) delete player.advisors[key.split(':')[0]];
}
}
function place(game: Game, actor: string, key: string, amount: number): void {
const player = discoverySkillsStrongholdsPlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
}
function held(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[] = []): Card {
const player = discoverySkillsStrongholdsPlayer(game, actor);
const match = (card: Card) => matches(card) && !used.includes(card.id);
const existing = player.hand.find(match); if (existing) return existing;
assert.ok(player.hand.length < handLimit(player));
const index = game.deck.findIndex(match);
const donor = index < 0 ? game.players.find(candidate => candidate.id !== actor && candidate.hand.some(match)) : undefined;
assert.ok(index >= 0 || donor, 'Original base33 card custody must supply the staged card.');
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(match), 1)[0];
player.hand.push(card);
staging.push(`Conserved ${card.id}: ${donor?.id ?? 'original deck'} custody to ${actor}'s hand.`);
return card;
}
/** Exact human sequence: create -> entryStep in entryWindow -> shipping controls
* in shipmentWindow -> prepare -> planActions -> finish. All phases, training,
* reveal, first end-Mentat custody and free entry use original actions. Explicit
* force/card staging conserves counters and base33 custody, not earned receipts. */
export function createDiscoverySkillsStrongholdsFixture(options: DiscoverySkillsStrongholdsOptions = {}): DiscoverySkillsStrongholdsFixture {
let skill = options.skill;
if (!skill && options.initial?.status === 'setup') {
const initial = options.initial;
const actor = initial.players.find(seat => seat.faction === (options.skillOwner ?? 'guild'))?.id;
assert.ok(actor && initial.leaderSkills);
const assignment = initial.leaderSkills.assignments.find(candidate => candidate.owner === actor);
const view = viewGame(initial, actor).leaderSkills!;
skill = assignment?.skill ?? initial.leaderSkills.offers[actor]?.cards.find(candidate =>
!['spice-banker', 'mentat', 'diplomat'].includes(candidate) && !view.unavailableSkills?.[candidate]);
assert.ok(skill, 'Actual undealt offers need an available source-clear original skill; never substitute an injected offer.');
}
const discovery = createClassicDiscoverySkillsFixture({ initial: options.initial, advanced: true,
strongholdCards: true, tech: options.tech, skill: skill ?? 'suk-graduate', skillOwner: options.skillOwner,
face: 'cistern', amount: 3 });
const owner = discovery.collector, opponent = discovery.opponent;
const kind = options.kind ?? 'arrakeen', hide = options.hide ?? true;
const strongholdKey = `${kind}:${territory(kind).sectors[0]}`;
const actions: DiscoverySkillsStrongholdsStep[] = [...discovery.actions];
const staging = [...discovery.staging];
let game = revealClassicDiscoverySkills(discovery, actions);
game = advanceClassicDiscoverySkills(game, state => state.phase === 8 && classicDiscoverySkillsClean(state), actions);
assert.deepEqual(game.strongholdCards, createStrongholdCards());
reserveExcept(game, [`${discovery.parent}:${discovery.parentSector}`]);
if (options.holder !== 'unclaimed') place(game, owner, strongholdKey, 1);
staging.push('Before original first end-Mentat, conserved all non-source board counters through their own reserves; preserved the three Discovery source forces and placed one ordinary claimant in the named stronghold.');
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoverySkillsStrongholdsStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextClassicDiscoverySkillsStep(game), before = structuredClone(game);
actions.push(next); game = applyAction(game, next.actor, next.action);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, false));
assert.equal(game.strongholdCards!.owners[kind], options.holder === 'unclaimed' ? null : owner);
game = advanceClassicDiscoverySkills(game, state => state.turn === 2 && state.decision?.kind === 'discoveryEntry' && state.decision.player === owner, actions);
const entryWindow = structuredClone(game), entryView = viewGame(game, owner);
const entryAction = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entryAction);
const entryStep: DiscoverySkillsStrongholdsStep = { actor: owner, action: entryAction };
actions.push(entryStep); game = applyAction(game, owner, entryAction);
const entered = structuredClone(game);
// Original Weather Control owns a deterministic safe storm, not a staged dial.
const weather = held(game, owner, card => card.effect === 'weather', staging);
game = advanceClassicDiscoverySkills(game, state => state.phase === 0 && classicDiscoverySkillsClean(state), actions);
const weatherStep: DiscoverySkillsStrongholdsStep = { actor: owner, action: { type: 'card', card: weather.id, amount: 0 } };
actions.push(weatherStep); game = applyAction(game, owner, weatherStep.action);
game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === owner && classicDiscoverySkillsClean(state), actions);
const shipmentWindow = structuredClone(game);
reserveExcept(game, ['cistern:0']);
const key = options.nestedBattle ? 'cistern:0' : strongholdKey;
const ownerForces = discoverySkillsStrongholdsPlayer(game, owner).forces[key] ?? 0;
place(game, owner, key, 8 - ownerForces); place(game, opponent, key, 8);
staging.push(`Before original turn-two movement completion, conserved eight ordinary counters per combatant at ${key}; retained three actually entered Cistern forces and original first-Mentat custody.`);
const used: string[] = [];
const card = (actor: string, kind: Card['kind'] | undefined): string | null => {
if (!kind) return null;
const physical = held(game, actor, candidate => candidate.kind === kind, staging, used);
used.push(physical.id); return physical.id;
};
const ownerWeapon = card(owner, options.ownerWeapon), ownerDefense = card(owner, options.ownerDefense);
const opponentWeapon = card(opponent, options.opponentWeapon), opponentDefense = card(opponent, options.opponentDefense);
const untrained = (actor: string) => {
const eligible = discoverySkillsStrongholdsPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
const leader = eligible.find(candidate => candidate.strength === 3) ?? eligible[0]; assert.ok(leader); return leader.id;
};
const leader = hide ? discovery.leader : untrained(owner);
const enemyLeader = untrained(opponent);
game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state), actions);
const actor = game.active!; assert.ok(actor === owner || actor === opponent);
const battleStep: DiscoverySkillsStrongholdsStep = { actor, action: { type: 'chooseBattle', territory: options.nestedBattle ? 'cistern' : kind, target: actor === owner ? opponent : owner } };
actions.push(battleStep); game = applyAction(game, actor, battleStep.action);
const planActions: DiscoverySkillsStrongholdsStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 4, support: options.support ?? 2, leader, weapon: ownerWeapon, defense: ownerDefense } },
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: enemyLeader, weapon: opponentWeapon, defense: opponentDefense } },
];
const paidShipmentStep: DiscoverySkillsStrongholdsStep = { actor: owner,
action: { type: 'ship', territory: 'cistern', sector: 0, amount: 1, elite: 0, allyPayment: 0 } };
return { discovery, initial: discovery.initial, setup: discovery.setup, beforeFirstMentat, firstMentatStep,
entryWindow, entryStep, entered, shipmentWindow, paidShipmentStep, game, owner, opponent, leader, kind, key, hide, actions, planActions, staging };
}
export function prepareDiscoverySkillsStrongholdsBattle(fixture: DiscoverySkillsStrongholdsFixture): Game {
let game = structuredClone(fixture.game);
for (let count = 0; count < 160; count++) {
let next = nextClassicSkillsStrongholdStep(game);
if (!next && game.battle?.preLeader?.closed === false) next = nextClassicDiscoverySkillsStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.hide;
game = applyAction(game, next.actor, next.action);
}
throw Error('Original Discovery skills/Strongholds pre-plan continuation did not open.');
}
export function revealDiscoverySkillsStrongholdsBattle(fixture: DiscoverySkillsStrongholdsFixture): Game {
let game = prepareDiscoverySkillsStrongholdsBattle(fixture);
for (const next of fixture.planActions) {
game = applyAction(game, next.actor, next.action);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const continuation = nextClassicDiscoverySkillsStep(game);
game = applyAction(game, continuation.actor, continuation.action);
}
}
assert.ok(game.battle?.revealed); return game;
}
export const finishDiscoverySkillsStrongholdsBattle = finishClassicSkillsStrongholdBattle;