247 lines
13 KiB
TypeScript
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;
|
|
}
|