172 lines
10 KiB
TypeScript
172 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { viewGame, type Game } from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { territory } from '../game/board';
|
|
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
|
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
|
|
type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
|
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
|
import { classicDiscoveryNexusClean as clean, nextClassicDiscoveryNexusStep,
|
|
applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
|
|
|
export { reactionPlayer as nexusCoreStrongholdPlayer };
|
|
export type NexusCoreStrongholdOptions = { independent?: boolean; skills?: boolean };
|
|
export type NexusCoreStrongholdFixture = {
|
|
game: Game; setup: Game; actions: ClassicIndependentNexusStep[]; staging: string[];
|
|
claimed: Game; beforeShipment: Game; afterShipment: Game;
|
|
owner: string; opponent: string; key: string; ownerLeader: string; opponentLeader: string;
|
|
weapon: string | null; defense: string | null;
|
|
};
|
|
|
|
function prepare(game: Game, actions: ClassicIndependentNexusStep[]): Game {
|
|
for (let count = 0; count < 120; count++) {
|
|
const next = nextClassicDiscoveryNexusStep(game);
|
|
if (!next) {
|
|
assert.ok(game.battle && !game.battle.revealed && clean(game));
|
|
return game;
|
|
}
|
|
// The original decision must put the trained disc behind the shield
|
|
// before it can be selected in a sealed plan.
|
|
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
|
|
game = applyClassicDiscoveryNexusStep(game, next, actions);
|
|
}
|
|
throw Error('Original Stronghold battle did not reach its sealed-plan boundary');
|
|
}
|
|
function bestLeader(game: Game, actor: string): string {
|
|
const leader = reactionPlayer(game, actor).leaders.filter(disc => !disc.dead && !disc.usedAt)
|
|
.sort((one, two) => two.strength - one.strength)[0];
|
|
assert.ok(leader);
|
|
return leader.id;
|
|
}
|
|
function chooseBattle(game: Game, location: string, actions: ClassicIndependentNexusStep[]): Game {
|
|
return prepare(reactionAction(game, game.active!, { type: 'chooseBattle', territory: location,
|
|
target: game.active === 'emperor' ? 'harkonnen' : 'emperor' }, actions), actions);
|
|
}
|
|
|
|
/** Emperor genuinely ships five original reserves into empty Arrakeen on turn
|
|
* one. Only unopened Spice/Nexus decks are ordered; every counter, payment,
|
|
* alliance and first end-Mentat card claim comes from original actions. */
|
|
export function createNexusCoreArrakeenFixture(options: NexusCoreStrongholdOptions = {}): NexusCoreStrongholdFixture {
|
|
const independent = options.independent ?? true;
|
|
const original = createClassicIndependentNexusFixture({ advanced: true, independent,
|
|
skills: options.skills, strongholds: true, roster: ['emperor', 'harkonnen', 'guild'],
|
|
startingCards: independent ? { emperor: [REINFORCEMENTS_CARD] } : undefined });
|
|
const actions = original.actions;
|
|
let game = original.game;
|
|
const take = (worm: boolean) => {
|
|
const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card);
|
|
assert.ok(index >= 0);
|
|
return game.spiceDeck.splice(index, 1)[0];
|
|
};
|
|
game.spiceDeck.unshift(take(false), take(false), take(true), take(false), take(false));
|
|
game = advanceClassicIndependentNexus(game, state => state.turn === 1 && state.phase === 5 &&
|
|
state.active === 'emperor' && clean(state), actions);
|
|
const sector = territory('arrakeen').sectors[0], key = `arrakeen:${sector}`;
|
|
assert.ok(!territory('arrakeen').sectors.includes(game.storm));
|
|
game = reactionAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector,
|
|
amount: 5, elite: 0, allyPayment: 0 }, actions);
|
|
game = advanceClassicIndependentNexus(game, state => state.strongholdCards?.claimedTurn === 1, actions);
|
|
const claimed = structuredClone(game);
|
|
assert.equal(claimed.strongholdCards!.owners.arrakeen, 'emperor');
|
|
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus &&
|
|
!state.spiceWindow && !state.spiceResolution && clean(state), actions);
|
|
game = reactionAction(game, 'harkonnen', { type: 'alliance', target: 'guild' }, actions);
|
|
game = reactionAction(game, 'guild', { type: 'alliance', target: 'harkonnen' }, actions);
|
|
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions);
|
|
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('emperor');
|
|
assert.ok(index >= 0);
|
|
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
|
game = reactionAction(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn,
|
|
card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 }, actions);
|
|
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'harkonnen' && clean(state), actions);
|
|
assert.ok(!territory('arrakeen').sectors.includes(game.storm));
|
|
const beforeShipment = structuredClone(game);
|
|
game = reactionAction(game, 'harkonnen', { type: 'ship', territory: 'arrakeen', sector,
|
|
amount: 5, elite: 0, allyPayment: 0 }, actions);
|
|
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && clean(state), actions);
|
|
const afterShipment = structuredClone(game);
|
|
game = chooseBattle(game, 'arrakeen', actions);
|
|
return { ...original, game, actions, claimed, beforeShipment, afterShipment,
|
|
owner: 'emperor', opponent: 'harkonnen', key, ownerLeader: bestLeader(game, 'emperor'),
|
|
opponentLeader: bestLeader(game, 'harkonnen'), weapon: independent ? REINFORCEMENTS_CARD : null,
|
|
defense: null };
|
|
}
|
|
|
|
/** Harkonnen retains its genuinely earned Carthag card. Atreides draws the
|
|
* physical Harkonnen Nexus, ships one counter and plays an actually offered
|
|
* Harkonnen Traitor. Optional all14 uses the original trained-disc choice. */
|
|
export function createNexusCoreCarthagFixture(options: NexusCoreStrongholdOptions = {}): NexusCoreStrongholdFixture {
|
|
const poison = baseDeck().find(card => card.kind === 'poison')!.id;
|
|
const shield = baseDeck().find(card => card.kind === 'shield')!.id;
|
|
const original = drawClassicNexusReaction('harkonnen', 'atreides', { advanced: true,
|
|
independent: options.independent ?? true, skills: options.skills, strongholds: true,
|
|
training: options.skills ? { harkonnen: 'prana-bindu-adept' } : undefined,
|
|
startingCards: { atreides: [poison], harkonnen: [shield] } });
|
|
const actions = original.actions;
|
|
const claimed = structuredClone(original.beforeDraw);
|
|
assert.equal(claimed.strongholdCards!.owners.carthag, 'harkonnen');
|
|
let game = advanceClassicIndependentNexus(original.game, state => state.phase === 5 &&
|
|
state.active === 'atreides' && clean(state), actions);
|
|
const opponentLeader = reactionPlayer(game, 'atreides').leaders.find(leader =>
|
|
reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
|
|
assert.ok(opponentLeader, 'Use the actual offered Traitor, never assign a leader identity');
|
|
const sector = territory('carthag').sectors[0], key = `carthag:${sector}`;
|
|
assert.ok(!territory('carthag').sectors.includes(game.storm));
|
|
const beforeShipment = structuredClone(game);
|
|
game = reactionAction(game, 'atreides', { type: 'ship', territory: 'carthag', sector,
|
|
amount: 1, elite: 0, allyPayment: 0 }, actions);
|
|
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && clean(state), actions);
|
|
const afterShipment = structuredClone(game);
|
|
game = reactionAction(game, game.active!, { type: 'chooseBattle', territory: 'carthag',
|
|
target: game.active === 'atreides' ? 'harkonnen' : 'atreides' }, actions);
|
|
game = prepare(game, actions);
|
|
const trained = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'harkonnen');
|
|
return { ...original, game, claimed, beforeShipment, afterShipment, owner: 'harkonnen',
|
|
opponent: 'atreides', key, ownerLeader: trained?.leader ?? bestLeader(game, 'harkonnen'),
|
|
opponentLeader, weapon: poison, defense: shield };
|
|
}
|
|
|
|
/** Return the revealed original battle, before any Traitor vote or cleanup. */
|
|
export function revealNexusCoreStrongholdBattle(fixture: NexusCoreStrongholdFixture,
|
|
options: { cunning?: boolean; shield?: boolean; ownerDial?: number; opponentDial?: number } = {}): Game {
|
|
let game = structuredClone(fixture.game);
|
|
const arrakeen = fixture.key.startsWith('arrakeen:');
|
|
if (arrakeen && options.cunning !== false) {
|
|
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
|
|
assert.equal(offer.blocked, null);
|
|
game = reactionAction(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
|
|
game = advanceClassicIndependentNexus(game, clean);
|
|
}
|
|
const ownerDial = options.ownerDial ?? (arrakeen ? 6 : 0.5);
|
|
const opponentDial = options.opponentDial ?? (arrakeen ? 1 : 0);
|
|
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
|
const owner = actor === fixture.owner;
|
|
game = reactionAction(game, actor, { type: 'battlePlan',
|
|
dial: owner ? ownerDial : opponentDial,
|
|
support: owner ? arrakeen ? options.cunning === false ? ownerDial : ownerDial / 2 : 0 : opponentDial,
|
|
leader: owner ? fixture.ownerLeader : fixture.opponentLeader,
|
|
weapon: arrakeen ? owner ? fixture.weapon : null : owner ? null : fixture.weapon,
|
|
defense: !arrakeen && owner && options.shield !== false ? fixture.defense : null });
|
|
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
|
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
|
|
game = applyClassicDiscoveryNexusStep(game, next);
|
|
}
|
|
}
|
|
assert.ok(game.battle?.revealed);
|
|
return game;
|
|
}
|
|
|
|
/** Original votes and native cleanup; no plan or strategy is invented. */
|
|
export function finishNexusCoreStrongholdBattle(game: Game, stopAtCleanup = false): Game {
|
|
return advanceClassicIndependentNexus(game, state => (!state.battle && clean(state)) ||
|
|
(stopAtCleanup && state.decision?.kind === 'battleCards'));
|
|
}
|
|
|
|
/** Preserve the original native Harkonnen call and pending cancellation window. */
|
|
export function callNexusCoreCarthagTraitor(game: Game): Game {
|
|
for (const voter of viewGame(game, 'atreides').battle!.traitorVoters)
|
|
if (voter !== 'harkonnen' && game.battle!.traitorCalls[voter] === undefined)
|
|
game = reactionAction(game, voter, { type: 'traitorCall', call: false });
|
|
return reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true });
|
|
}
|