252 lines
17 KiB
TypeScript
252 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, joinGame, newPlayer, viewGame,
|
|
type Action, type Game,
|
|
} from '../game/engine';
|
|
import type { Difficulty } from '../game/bot-profiles';
|
|
import { botActions } from '../game/bots';
|
|
import type { FactionId } from '../game/catalog';
|
|
import type { Card } from '../game/cards';
|
|
import { TERRITORIES, territory } from '../game/board';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
|
|
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
|
|
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
|
|
export type MoritaniSkillsModulesOptions = {
|
|
initial?: Game;
|
|
rules?: 'basic' | 'advanced';
|
|
tech?: boolean;
|
|
strongholds?: boolean;
|
|
holder?: 'moritani' | 'guild';
|
|
normalCall?: boolean;
|
|
};
|
|
export type MoritaniSkillsModulesFixture = {
|
|
initial: Game; afterSetup: Game; afterFirstStorm: Game;
|
|
beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game;
|
|
beforeShipment: Game; shipmentStep: StrongholdFactionsNativeStep; afterShipment: Game; afterMovement: Game;
|
|
beforeBattle: Game; game: Game; revealed: Game; pending: Game;
|
|
moritani: string; opponent: string; holder: string; target: string;
|
|
moritaniLeader: string; opponentLeader: string;
|
|
moritaniCards: string[]; opponentCards: string[];
|
|
battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[];
|
|
sourceActions: StrongholdFactionsNativeStep[]; staging: string[];
|
|
};
|
|
export function moritaniSkillsModulesPlayer(game: Game, id: string) {
|
|
const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player;
|
|
}
|
|
export function moritaniSkillsModulesPolicy(game: Game, actor: string, difficulty: Difficulty = 'Easy') {
|
|
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
|
return botActions(view);
|
|
}
|
|
/** A response pass queue takes precedence over every simultaneous native choice.
|
|
* A null result is the genuine boundary requiring a human sealed Battle Plan. */
|
|
export function nextMoritaniSkillsModulesStep(game: Game): StrongholdFactionsNativeStep | null {
|
|
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard)
|
|
return nextStrongholdFactionsNativeStep(game);
|
|
const d = game.decision;
|
|
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
|
|
if (d?.kind === 'mentatQuestion') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
|
if (d?.kind === 'moritaniSetup') {
|
|
const action = moritaniSkillsModulesPolicy(game, d.player)[0]; assert.ok(action);
|
|
return { actor: d.player, action };
|
|
}
|
|
if (d?.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } };
|
|
if (d?.kind === 'moritaniAssassinate') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
|
if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
|
return nextStrongholdFactionsNativeStep(game);
|
|
}
|
|
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]) {
|
|
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
|
}
|
|
export function advanceMoritaniSkillsModules(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[] = []): Game {
|
|
for (let count = 0; count < 2000; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next, 'A real original sealed Battle Plan is required');
|
|
game = run(game, next, actions);
|
|
}
|
|
throw Error('Original Moritani skills/modules continuation did not reach its requested boundary');
|
|
}
|
|
export const moritaniSkillsModulesClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
|
|
|
|
/** Native offers and starting wallets; never convert a played save or redeal a
|
|
* supplied original setup. Lobby modules are enabled by their real host actions. */
|
|
export function initializeMoritaniSkillsModulesOffers(options: MoritaniSkillsModulesOptions = {}): Game {
|
|
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
|
|
const tech = options.tech ?? true;
|
|
const strongholds = options.strongholds ?? advanced;
|
|
let game: Game;
|
|
if (options.initial) game = structuredClone(options.initial);
|
|
else {
|
|
game = createGame('MORITANISKILLSMODULES', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']);
|
|
joinGame(game, newPlayer('guild', 'Guild', 'guild')); joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
|
}
|
|
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
|
|
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
|
|
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
|
|
assert.equal(game.players.filter(p => p.faction === 'guild').length, 1);
|
|
const allowed: FactionId[] = ['moritani', 'atreides', 'emperor', 'fremen', 'guild', 'beneGesserit'];
|
|
assert.ok(game.players.every(p => allowed.includes(p.faction)));
|
|
assert.ok(tech || strongholds); assert.ok(!strongholds || advanced);
|
|
if (game.status === 'lobby') {
|
|
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
|
|
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
|
|
} else {
|
|
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
|
}
|
|
game = initializeAdvancedNativeSkillsSetup({ family: 'moritani', skillOwner: 'guild', requestedSkill: 'suk-graduate',
|
|
rules: advanced ? 'advanced' : 'basic', initial: game });
|
|
while (game.setupStage === 'prediction') {
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
|
game = applyAction(game, next.actor, next.action);
|
|
}
|
|
return game;
|
|
}
|
|
export function completeMoritaniSkillsModulesSetup(state: Game): Game {
|
|
let game = state;
|
|
const guild = game.players.find(p => p.faction === 'guild')!.id;
|
|
for (let count = 0; game.status === 'setup' && count < 200; count++) {
|
|
if (game.setupStage === 'leaderSkills') {
|
|
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
|
const offer = game.leaderSkills!.offers[actor];
|
|
const skill = actor === guild ? 'suk-graduate' : offer.cards.find(c => !own.unavailableSkills?.[c]);
|
|
const leader = actor === guild ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0];
|
|
assert.ok(skill && offer.cards.includes(skill) && leader);
|
|
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
|
|
} else {
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
|
game = applyAction(game, next.actor, next.action);
|
|
}
|
|
}
|
|
assert.equal(game.status, 'playing'); return game;
|
|
}
|
|
function reserveBoard(game: Game) {
|
|
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 = {};
|
|
}
|
|
}
|
|
function place(game: Game, actor: string, location: string, count: number) {
|
|
const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count);
|
|
p.reserves -= count; p.forces[location] = count;
|
|
}
|
|
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[], excluded: string[] = []) {
|
|
const p = moritaniSkillsModulesPlayer(game, actor);
|
|
const held = p.hand.find(c => c.kind === kind && !excluded.includes(c.id)); if (held) return held.id;
|
|
const index = game.deck.findIndex(c => c.kind === kind && !excluded.includes(c.id)); assert.ok(index >= 0);
|
|
assert.ok(p.hand.length < 4); const [card] = game.deck.splice(index, 1); p.hand.push(card);
|
|
staging.push(`Conserved native ecaz33 ${card.id}: remaining deck → ${actor}'s hand`); return card.id;
|
|
}
|
|
export function stageMoritaniSkillsModulesBattle(game: Game, moritani: string, opponent: string, staging: string[] = []) {
|
|
assert.equal(game.phase, 5); assert.ok(moritaniSkillsModulesClean(game));
|
|
reserveBoard(game);
|
|
const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`;
|
|
place(game, moritani, location, 6); place(game, opponent, location, 6);
|
|
staging.push('Before actual movement completion: relocate six original counters per combatant through own reserves; no currency, storm, phase, turn or card-owner edits.');
|
|
return game;
|
|
}
|
|
export function revealMoritaniSkillsModulesPlans(game: Game, plans: StrongholdFactionsNativeStep[], actions: StrongholdFactionsNativeStep[] = []): Game {
|
|
for (const plan of plans) {
|
|
game = run(game, plan, actions);
|
|
while (game.response || game.decision?.kind === 'fullPlanOffer') {
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); game = run(game, next, actions);
|
|
}
|
|
}
|
|
assert.ok(game.battle?.revealed); return game;
|
|
}
|
|
/** Stop BEFORE original Suk rescue or original assassination; do not erase the
|
|
* winner's casualties, card decisions or saved continuation to reach a choice. */
|
|
export function resolveMoritaniSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions: StrongholdFactionsNativeStep[] = []): Game {
|
|
for (let count = 0; count < 160; count++) {
|
|
if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' ||
|
|
!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game;
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
|
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
|
|
game = run(game, next, actions);
|
|
}
|
|
throw Error('Original Moritani skills/modules battle suffix did not settle');
|
|
}
|
|
export function createMoritaniSkillsModulesFixture(options: MoritaniSkillsModulesOptions = {}): MoritaniSkillsModulesFixture {
|
|
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
|
|
let game = initializeMoritaniSkillsModulesOffers(options);
|
|
const initial = structuredClone(game);
|
|
game = completeMoritaniSkillsModulesSetup(game); const afterSetup = structuredClone(game);
|
|
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
|
|
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
|
const holder = options.holder === 'moritani' ? moritani : opponent;
|
|
while (game.phase === 0) {
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
|
if (next.action.type === 'stormDial') next.action.amount = 4;
|
|
game = run(game, next, actions);
|
|
}
|
|
const afterFirstStorm = structuredClone(game);
|
|
game = advanceMoritaniSkillsModules(game, s => s.phase === 8 && moritaniSkillsModulesClean(s), actions);
|
|
reserveBoard(game);
|
|
place(game, holder, `tueks_sietch:${territory('tueks_sietch').sectors[0]}`, 1);
|
|
place(game, holder === moritani ? opponent : moritani, `carthag:${territory('carthag').sectors[0]}`, 1);
|
|
staging.push('Before real first END Mentat: conserved native counters, one controlled stronghold each; card ownership remains entirely engine-derived.');
|
|
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
|
while (game.turn === 1) {
|
|
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
|
const before = structuredClone(game); game = run(game, next, actions);
|
|
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
|
}
|
|
assert.ok(beforeFirstMentat && firstMentatStep);
|
|
const afterFirstMentat = structuredClone(game);
|
|
game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions);
|
|
stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging);
|
|
const m = moritaniSkillsModulesPlayer(game, moritani), guild = moritaniSkillsModulesPlayer(game, opponent);
|
|
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
|
const ownLeader = m.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
|
const enemyLeader = guild.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
|
const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader;
|
|
assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target);
|
|
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging);
|
|
const moritaniCards = [heldCard(game, moritani, 'worthless', staging)];
|
|
moritaniCards.push(heldCard(game, moritani, 'worthless', staging, moritaniCards));
|
|
const opponentCards = [heldCard(game, opponent, 'worthless', staging)];
|
|
// Empty one selected battle deposit by returning it to the unoccupied Polar
|
|
// Sink. This is explicit conserved board-spice staging, not minted bank money.
|
|
const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`;
|
|
const deposit = game.spice[location] ?? 0;
|
|
if (deposit) { delete game.spice[location]; game.spice['polar_sink:0'] = (game.spice['polar_sink:0'] ?? 0) + deposit; }
|
|
staging.push(`Conserved board deposit ${deposit}: battle sector → unoccupied Polar Sink; isolate real battle/income wallet legs.`);
|
|
// One actual non-Guild shipment triggers printed Heighliners accrual. Keep
|
|
// native active/order/shipped fields intact and reach Moritani by actions.
|
|
game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && s.active === moritani && moritaniSkillsModulesClean(s), actions);
|
|
const destination = TERRITORIES.find(site => site.type === 'sand' && !site.sectors.includes(game.storm) &&
|
|
!Object.entries(game.spice).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`)) &&
|
|
game.players.every(p => !Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`))));
|
|
assert.ok(destination, 'The original invoice needs an empty, spice-free desert destination.');
|
|
const beforeShipment = structuredClone(game);
|
|
const shipmentStep: StrongholdFactionsNativeStep = { actor: moritani, action: { type: 'ship',
|
|
territory: destination.id, sector: destination.sectors[0], amount: 1 } };
|
|
game = run(game, shipmentStep, actions);
|
|
game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s) && !s.pendingShipment, actions);
|
|
const afterShipment = structuredClone(game);
|
|
game = advanceMoritaniSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions);
|
|
const afterMovement = structuredClone(game), beforeBattle = structuredClone(game);
|
|
const actor = game.active!; assert.ok([moritani, opponent].includes(actor));
|
|
const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } };
|
|
game = run(game, battleAction, actions);
|
|
game = advanceMoritaniSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniSkillsModulesStep(s), actions);
|
|
const planActions: StrongholdFactionsNativeStep[] = [
|
|
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, ...(game.advanced ? { support: 1 } : {}), weapon: moritaniCards[0], defense: moritaniCards[1] } },
|
|
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, ...(game.advanced ? { support: 3 } : {}), weapon: opponentCards[0] } },
|
|
];
|
|
const prepared = structuredClone(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions);
|
|
const pending = resolveMoritaniSkillsModulesBattle(structuredClone(revealed), moritani, options.normalCall, actions);
|
|
// Three-seat originals get one token per seat; larger originals must not
|
|
// fabricate missing ownership merely to offer a post-battle Tech choice.
|
|
if (afterFirstStorm.techTokens && afterFirstStorm.players.length === 3) assert.equal(ownedTech(afterFirstStorm.techTokens, opponent).length, 1);
|
|
return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
|
|
beforeShipment, shipmentStep, afterShipment, afterMovement, beforeBattle, game: prepared, revealed, pending,
|
|
moritani, opponent, holder, target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id,
|
|
moritaniCards, opponentCards, battleAction, planActions, sourceActions: actions, staging };
|
|
}
|
|
export function rejectMoritaniSkillsModulesAction(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
|
}
|