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

247 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { TERRITORIES, splitLocation } from '../game/board';
import type { Card } from '../game/cards';
import { ownedTech } from '../game/tech-tokens';
import {
initializeAdvancedNativeSkillsSetup, completeAdvancedNativeSkillsSetup,
advancedNativePlayer, type AdvancedNativeSkillsOptions,
} from './fixture-advanced-native-skills';
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
export type TleilaxuSkillsTechOptions = {
/** Fresh authenticated, undealt original lobby; preserve its IDs and order. */
initial?: Game;
advanced?: boolean;
winner?: 'guild' | 'ixians';
skill?: 'suk-graduate' | 'rihani-decipherer';
winnerDies?: boolean;
};
export type TleilaxuSkillsTechStep = { actor: string; action: Action };
export type TleilaxuSkillsTechFixture = {
initial: Game;
afterSetup: Game;
beforePlans: Game;
revealed: Game;
skillChoice: Game | null;
winnerCards: Game;
faceDance: Game;
actions: TleilaxuSkillsTechStep[];
winner: string;
tleilaxu: string;
winnerLeader: string;
loserLeader: string;
territory: string;
location: string;
sector: number;
boardSource: string;
weapon: string;
defense: string;
loserCard: string;
skill: 'suk-graduate' | 'rihani-decipherer';
winnerDies: boolean;
ownerPlan: Action;
opponentPlan: Action;
faceDanceAction: Action;
};
export const tleilaxuSkillsTechPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
};
export function tleilaxuSkillsTechForces(game: Game, id: string, territory: string): number {
return Object.entries(tleilaxuSkillsTechPlayer(game, id).forces)
.filter(([key]) => splitLocation(key).territory === territory).reduce((sum, [, count]) => sum + count, 0);
}
export function nextTleilaxuSkillsTechStep(game: Game): TleilaxuSkillsTechStep {
if (game.decision?.kind === 'rihani') {
assert.equal(game.decision.stage, 'offer');
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, draw: false } };
}
if (game.decision?.kind === 'ixSubstitution')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.decision?.kind === 'sukRescue') {
const decision = game.decision;
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
return { actor: decision.player, action: { type: 'decision', event: decision.event,
choice: decision.options.findIndex(o => o.normal + o.elite === maximum) } };
}
return nextSkillsTechBattleStep(game);
}
function step(game: Game, actions?: TleilaxuSkillsTechStep[]): Game {
const next = nextTleilaxuSkillsTechStep(game);
actions?.push(next);
return applyAction(game, next.actor, next.action);
}
/** Original transitions only. Empty Battle is allowed to skip into Collection. */
export function advanceTleilaxuSkillsTechToPhase(state: Game, target: number): Game {
let game = state;
for (let i = 0; (game.phase < target || game.phaseOpening || game.response || game.decision)
&& game.turn === state.turn && i < 1000; i++) game = step(game);
assert.equal(game.turn, state.turn);
assert.ok(game.phase >= target && !game.phaseOpening && !game.response && !game.decision);
return game;
}
function setup(options: TleilaxuSkillsTechOptions & { revivalTleilaxuFirst?: boolean }) {
const faction = options.winner ?? 'guild';
let initial = options.initial ? structuredClone(options.initial)
: createGame('TLEISKILLTECH', newPlayer(faction, faction, faction), options.advanced ?? true, ['ix']);
if (!options.initial) {
joinGame(initial, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
joinGame(initial, newPlayer('emperor', 'Emperor', 'emperor'));
if (options.revivalTleilaxuFirst && initial.advanced) {
// Original lobby circles keep the native Tleilaxu opportunity before
// Guild, allowing both free returns without reopening a completed turn.
initial = applyAction(initial, 'tleilaxu', { type: 'seatPosition', position: 5 });
initial = applyAction(initial, faction, { type: 'seatPosition', position: 6 });
}
}
assert.equal(initial.status, 'lobby');
assert.ok(initial.players.length >= 3 && initial.players.length <= 6);
assert.equal(new Set(initial.players.map(p => p.faction)).size, initial.players.length);
assert.deepEqual(initial.expansions, ['ix']);
if (options.advanced !== undefined) assert.equal(initial.advanced, options.advanced);
advancedNativePlayer(initial, faction);
advancedNativePlayer(initial, 'tleilaxu');
const original = structuredClone(initial);
if (!initial.techTokens) initial = applyAction(initial, initial.host, { type: 'techTokens', enabled: true });
const native: AdvancedNativeSkillsOptions = { family: 'tleilaxu', skillOwner: faction,
requestedSkill: options.skill ?? 'suk-graduate', rules: initial.advanced ? 'advanced' : 'basic', initial };
const game = completeAdvancedNativeSkillsSetup(initializeAdvancedNativeSkillsSetup(native), native);
return { initial: original, game };
}
function held(game: Game, id: string, matches: (card: Card) => boolean): string {
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'An original physical card must remain available.');
const card = game.deck.splice(index, 1)[0];
tleilaxuSkillsTechPlayer(game, id).hand.push(card);
return card.id;
}
/** Conserved physical Face Dancer/Traitor exchange, not a natural draw claim. */
function matchingFaceDancer(game: Game, owner: string, leader: string) {
const cards = tleilaxuSkillsTechPlayer(game, owner).faceDancers!;
if (cards.some(card => card.leader === leader && !card.revealed)) return;
const dancer = cards.find(card => !card.revealed)!;
assert.ok(dancer);
const reserveIndex = game.traitorReserve!.indexOf(leader);
if (reserveIndex >= 0) game.traitorReserve![reserveIndex] = dancer.leader;
else {
const holder = game.players.find(p => p.traitors.includes(leader));
assert.ok(holder, 'Matching original physical Traitor must have a real custodian.');
holder.traitors[holder.traitors.indexOf(leader)] = dancer.leader;
}
dancer.leader = leader;
}
/** Genuine native setup and phase transitions, with explicitly controlled
* conserved board/cards/Face Dancer custody at Shipment. No wallet, phase,
* plan, receipt, token owner, or played-game retrofit is assigned. */
export function createTleilaxuSkillsTechFixture(options: TleilaxuSkillsTechOptions = {}): TleilaxuSkillsTechFixture {
const { initial, game: started } = setup(options);
const afterSetup = structuredClone(started);
let game = advanceTleilaxuSkillsTechToPhase(started, 5);
const winner = advancedNativePlayer(game, options.winner ?? 'guild').id;
const tleilaxu = advancedNativePlayer(game, 'tleilaxu').id;
assert.equal(game.techTokens!.axlotl.owner, tleilaxu, 'Original printed native owner is the reward donor.');
assert.deepEqual(ownedTech(game.techTokens, tleilaxu), ['axlotl']);
const skill = options.skill ?? 'suk-graduate';
const winnerDies = options.winnerDies ?? false;
const winnerLeader = game.leaderSkills!.assignments.find(a => a.owner === winner && a.skill === skill)!.leader;
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const loserLeader = tleilaxuSkillsTechPlayer(game, tleilaxu).leaders
.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0].id;
const sites = TERRITORIES.filter(t => t.type === 'sand' && !t.sectors.includes(game.storm)
&& !Object.keys(game.spice).some(key => splitLocation(key).territory === t.id && game.spice[key] > 0));
assert.ok(sites.length >= 2);
const territory = sites[0].id, sector = sites[0].sectors[0], location = `${territory}:${sector}`;
const boardSource = `${sites[1].id}:${sites[1].sectors[0]}`;
for (const p of game.players) {
game.deck.push(...p.hand); p.hand = [];
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 = {};
}
for (const id of [winner, tleilaxu]) {
const p = tleilaxuSkillsTechPlayer(game, id);
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= 8);
p.reserves -= 8; p.forces[location] = 8;
}
if (tleilaxuSkillsTechPlayer(game, winner).faction === 'ixians') {
const ix = tleilaxuSkillsTechPlayer(game, winner);
ix.elites!.reserves -= 2;
ix.elites!.forces[location] = 2;
}
const dancers = tleilaxuSkillsTechPlayer(game, tleilaxu);
dancers.reserves -= 3; dancers.forces[boardSource] = 3;
matchingFaceDancer(game, tleilaxu, winnerLeader);
const weapon = held(game, winner, c => c.kind === 'projectile');
const defense = held(game, winner, c => c.kind === 'shield');
const loserCard = held(game, tleilaxu, c => c.kind === (winnerDies ? 'poison' : 'worthless'));
game = advanceTleilaxuSkillsTechToPhase(game, 6);
assert.equal(game.phase, 6);
game = openSkillsTechBattle(game, winner, tleilaxu, territory, true);
const beforePlans = structuredClone(game);
const ixWinner = tleilaxuSkillsTechPlayer(game, winner).faction === 'ixians';
const ownerPlan: Action = { type: 'battlePlan', leader: winnerLeader, dial: game.advanced && ixWinner ? 5 : 4,
support: game.advanced ? ixWinner ? 1 : 4 : 0, weapon, defense };
const opponentPlan: Action = { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: loserCard };
game = applyAction(game, winner, ownerPlan);
game = applyAction(game, tleilaxu, opponentPlan);
const revealed = structuredClone(game), actions: TleilaxuSkillsTechStep[] = [];
let skillChoice: Game | null = null, winnerCards: Game | null = null;
for (let i = 0; game.decision?.kind !== 'faceDance' && i < 200; i++) {
if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'rihani') skillChoice = structuredClone(game);
if (game.decision?.kind === 'battleCards') {
assert.equal(game.decision.player, winner);
winnerCards = structuredClone(game);
const next: TleilaxuSkillsTechStep = { actor: winner, action: { type: 'decision', discard: [defense] } };
actions.push(next); game = applyAction(game, next.actor, next.action);
} else game = step(game, actions);
}
assert.ok(winnerCards, 'Original winner card cleanup must precede Tech and Face Dance.');
assert.equal(game.decision?.kind, 'faceDance');
assert.equal(game.techTokens!.axlotl.owner, winner);
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { reserves: 1, [boardSource]: 2 }, sector };
return { initial, afterSetup, beforePlans, revealed, skillChoice, winnerCards, faceDance: game, actions,
winner, tleilaxu, winnerLeader, loserLeader, territory, location, sector, boardSource,
weapon, defense, loserCard, skill, winnerDies, ownerPlan, opponentPlan, faceDanceAction };
}
export type TleilaxuSkillsTechRevivalFixture = {
game: Game; tleilaxu: string; other: string; nativeTrainer: string; foreignLeader: string;
ownFreeAction: Action; otherFreeAction: Action; foreignGholaAction: Action;
};
/** Original Revival boundary. Only conserved reserve->Tanks counters and two
* untrained original discs' dead states are controlled, never funding. The
* native dead disc makes genuine room in Tleilaxu's five-leader active pool. */
export function createTleilaxuSkillsTechRevivalFixture(options: Pick<TleilaxuSkillsTechOptions, 'initial' | 'advanced'> = {}): TleilaxuSkillsTechRevivalFixture {
const { game: started } = setup({ ...options, winner: 'guild', revivalTleilaxuFirst: true });
const game = advanceTleilaxuSkillsTechToPhase(started, 4);
const tleilaxu = advancedNativePlayer(game, 'tleilaxu').id;
const other = advancedNativePlayer(game, 'guild').id;
for (const id of [tleilaxu, other]) {
const p = tleilaxuSkillsTechPlayer(game, id);
assert.ok(p.reserves >= 1); p.reserves--; p.tanks++;
}
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const foreign = advancedNativePlayer(game, 'emperor').leaders.filter(l => !trained.has(l.id))
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(foreign); foreign.dead = true; foreign.deaths++;
const ownDead = advancedNativePlayer(game, 'tleilaxu').leaders.find(l => !trained.has(l.id));
assert.ok(ownDead); ownDead.dead = true; ownDead.deaths++;
const nativeTrainer = game.leaderSkills!.assignments.find(a => a.owner === tleilaxu)!.leader;
return { game, tleilaxu, other, nativeTrainer, foreignLeader: foreign.id,
ownFreeAction: { type: 'revive', amount: 1 }, otherFreeAction: { type: 'revive', amount: 1 },
foreignGholaAction: { type: 'reviveForeignGhola', leader: foreign.id } };
}
export function settleTleilaxuSkillsTechRevival(state: Game): Game {
let game = state;
for (let i = 0; (game.response || game.decision || game.pendingRevival) && i < 100; i++) game = step(game);
assert.ok(!game.response && !game.decision && !game.pendingRevival);
return game;
}