Compose native E1/E2 Leader Skills with original Tech Tokens
This commit is contained in:
1 parent
7a6972cd1b
commit
219489ae04
20 files changed
+1734
-29
No files matched your search
@@ -0,0 +1,277 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
|
||||
import { advanceSkillsTechPaymentsToPhase, skillsTechPaymentsEmptyDestination } from './fixture-skills-tech-payments';
|
||||
|
||||
export type IxChoamSkillsTechOptions = {
|
||||
/** Fresh authenticated undealt lobby. Seat IDs, order and native offers survive. */
|
||||
initial?: Game;
|
||||
family?: 'ixians' | 'choam';
|
||||
advanced?: boolean;
|
||||
kind?: 'shipment' | 'suk-battle' | 'charity';
|
||||
band?: 'normal' | 'skilled';
|
||||
amount?: number;
|
||||
elite?: number;
|
||||
smuggler?: boolean;
|
||||
/** Separate normal-income audit opt-in; needs an actual Atreides seat. */
|
||||
bankerIncome?: boolean;
|
||||
};
|
||||
export type IxChoamSkillsTechStep = { actor: string; action: Action };
|
||||
export type IxChoamSkillsTechFixture = {
|
||||
setup: Game;
|
||||
game: Game;
|
||||
actions: IxChoamSkillsTechStep[];
|
||||
family: 'ixians' | 'choam';
|
||||
kind: 'shipment' | 'suk-battle' | 'charity';
|
||||
actor: string;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
banker?: string;
|
||||
trainer: string;
|
||||
action: Action;
|
||||
opponentPlan?: Action;
|
||||
beforeCharityIncome?: Game;
|
||||
afterCharityIncome?: Game;
|
||||
key?: string;
|
||||
cost: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
band: 'normal' | 'skilled';
|
||||
};
|
||||
|
||||
export function ixChoamSkillsTechPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/** Control only the first all14 shuffle. Native offers are dealt in retained seat
|
||||
* order; subsequent Traitor, treachery and Tech randomness remains original. */
|
||||
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
|
||||
const cards: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remaining = cards.filter(card => !selected.includes(card));
|
||||
const desired = selected.flatMap(skill => [skill, remaining.shift()!]);
|
||||
desired.push(...remaining);
|
||||
const working = [...cards];
|
||||
const rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
let index = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (index < rolls.length) {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
array[0] = rolls[index++];
|
||||
} else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Native continuation only; decline optional substitution so it cannot conceal
|
||||
* Suk's reserve/Tanks result. Human snapshots stop before this policy runs. */
|
||||
export function nextIxChoamSkillsTechStep(game: Game): IxChoamSkillsTechStep {
|
||||
if (game.decision?.kind === 'ixSubstitution')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
return nextSkillsTechBattleStep(game);
|
||||
}
|
||||
function step(game: Game, actions?: IxChoamSkillsTechStep[]): Game {
|
||||
const next = nextIxChoamSkillsTechStep(game);
|
||||
actions?.push(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
function act(game: Game, actor: string, action: Action, actions: IxChoamSkillsTechStep[]): Game {
|
||||
actions.push({ actor, action });
|
||||
return applyAction(game, actor, action);
|
||||
}
|
||||
|
||||
export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptions = {}): Game {
|
||||
const family = options.family ?? 'ixians';
|
||||
const expansion = family === 'ixians' ? 'ix' : 'choam';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('IXCHOAMSKILLTECH', newPlayer('native', family, family), options.advanced ?? false, [expansion]);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
|
||||
if (options.bankerIncome) joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby', 'Only the original fresh undealt lobby is accepted.');
|
||||
assert.ok(game.expansions.includes(expansion));
|
||||
assert.ok(game.players.length >= 3 && game.players.length <= 6);
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
assert.ok(game.players.some(p => p.faction === family));
|
||||
assert.ok(game.players.some(p => p.faction === 'emperor'));
|
||||
if (options.bankerIncome) assert.ok(game.players.some(p => p.faction === 'atreides'));
|
||||
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
assert.deepEqual(game.techTokens, createTechTokens());
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'planetologist'];
|
||||
const selected = game.players.map(p => p.faction === family
|
||||
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
|
||||
: options.bankerIncome && p.faction === 'atreides' ? 'spice-banker' : passive.shift()!);
|
||||
return withOffers(selected, () => options.bankerIncome
|
||||
? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
|
||||
}
|
||||
|
||||
export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkillsTechOptions = {}, actions?: IxChoamSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const faction = ixChoamSkillsTechPlayer(game, actor).faction;
|
||||
const skill = faction === (options.family ?? 'ixians')
|
||||
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
|
||||
: options.bankerIncome && faction === 'atreides' ? 'spice-banker' : offer.cards[0];
|
||||
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leader = ixChoamSkillsTechPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader && leader.id !== 'choam-auditor');
|
||||
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: leader.id };
|
||||
actions?.push({ actor, action });
|
||||
game = applyAction(game, actor, action);
|
||||
} else game = step(game, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Original setup, first Storm and phase history. Only unplayed Spice Card
|
||||
* order and (for battle) conserved board/hand custody are controlled. No wallet,
|
||||
* token owner, phase, turn, plan or earned receipt is assigned. */
|
||||
export function createIxChoamSkillsTechFixture(options: IxChoamSkillsTechOptions = {}): IxChoamSkillsTechFixture {
|
||||
const family = options.family ?? 'ixians';
|
||||
const kind = options.kind ?? 'shipment';
|
||||
assert.ok(kind !== 'charity' || family === 'choam');
|
||||
const band = options.band ?? 'skilled';
|
||||
const actions: IxChoamSkillsTechStep[] = [];
|
||||
const setup = initializeIxChoamSkillsTechSetup(options);
|
||||
let game = completeIxChoamSkillsTechSetup(setup, options, actions);
|
||||
const owner = game.players.find(p => p.faction === family)!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')!.id : undefined;
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
||||
// Four conserved non-worm cards avoid optional riders during the two-turn
|
||||
// charity continuation. They are not asserted to be a natural shuffle history.
|
||||
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4);
|
||||
for (let i = 0; i < blows.length; i++) {
|
||||
const index = game.spiceDeck.indexOf(blows[i]);
|
||||
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = advanceSkillsTechPaymentsToPhase(game, 5, actions);
|
||||
if (family === 'ixians') assert.equal(game.techTokens!.heighliners.owner, owner);
|
||||
if (kind === 'suk-battle') {
|
||||
// Explicit physical battle rule position: return board counters/cards to
|
||||
// native reserves/deck, then place eight counters (two Ix cyborgs).
|
||||
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 = {};
|
||||
}
|
||||
const territory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm));
|
||||
assert.ok(territory);
|
||||
const key = `${territory.id}:${territory.sectors[0]}`;
|
||||
for (const id of [owner, opponent]) {
|
||||
const p = ixChoamSkillsTechPlayer(game, id);
|
||||
assert.ok(p.reserves >= 8); p.reserves -= 8; p.forces[key] = 8;
|
||||
}
|
||||
if (family === 'ixians') {
|
||||
const p = ixChoamSkillsTechPlayer(game, owner);
|
||||
p.elites!.reserves -= 2; p.elites!.forces[key] = 2;
|
||||
}
|
||||
const shield = game.deck.findIndex(card => card.kind === 'shield');
|
||||
assert.ok(shield >= 0);
|
||||
const defense = game.deck.splice(shield, 1)[0];
|
||||
ixChoamSkillsTechPlayer(game, owner).hand.push(defense);
|
||||
game = advanceSkillsTechPaymentsToPhase(game, 6, actions);
|
||||
game = openSkillsTechBattle(game, owner, opponent, territory.id, band === 'skilled');
|
||||
const untrained = (id: string, strongest: boolean) => ixChoamSkillsTechPlayer(game, id).leaders
|
||||
.filter(l => !l.dead && l.id !== 'choam-auditor' && !game.leaderSkills!.assignments.some(a => a.leader === l.id))
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
|
||||
const dial = game.advanced && family === 'ixians' ? 5 : 4;
|
||||
const support = game.advanced ? family === 'ixians' ? 1 : 4 : 0;
|
||||
return { setup, game, actions, family, kind, actor: owner, owner, opponent, banker, trainer, key,
|
||||
action: { type: 'battlePlan', leader: band === 'skilled' ? trainer : untrained(owner, true), dial, support, defense: defense.id },
|
||||
opponentPlan: { type: 'battlePlan', leader: untrained(opponent, false), dial: 0, support: 0 },
|
||||
cost: support, amount: 8, elite: family === 'ixians' ? 2 : 0, band };
|
||||
}
|
||||
const actor = kind === 'charity' ? opponent : owner;
|
||||
for (let i = 0; game.active !== actor && i < 30; i++) game = step(game, actions);
|
||||
assert.equal(game.active, actor);
|
||||
const amount = kind === 'charity' ? 5 : options.amount ?? 5;
|
||||
const elite = kind === 'charity' ? 0 : options.elite ?? (family === 'ixians' ? 2 : 0);
|
||||
let target: { territory: string; sector: number };
|
||||
if (kind === 'charity') {
|
||||
const territory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm)
|
||||
&& game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`)))
|
||||
&& t.sectors.every(s => !game.spice[`${t.id}:${s}`])
|
||||
&& blows.every(card => !('territory' in card) || card.territory !== t.id));
|
||||
assert.ok(territory); target = { territory: territory.id, sector: territory.sectors[0] };
|
||||
game = act(game, actor, { type: 'ship', ...target, amount, elite }, actions);
|
||||
for (let i = 0; (game.response || game.decision || game.pendingShipment) && i < 200; i++) game = step(game, actions);
|
||||
// Actual ten-spice desert invoice exhausts Emperor's original bank wallet.
|
||||
// Any native Heighliners payout can add at most one; no Collection at this pile.
|
||||
const counter = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(counter >= 0);
|
||||
ixChoamSkillsTechPlayer(game, actor).hand.push(game.deck.splice(counter, 1)[0]);
|
||||
let beforeCharityIncome: Game | undefined;
|
||||
let afterCharityIncome: Game | undefined;
|
||||
for (let i = 0; (game.turn !== 2 || game.phase !== 2 || game.phaseOpening || game.response || game.decision)
|
||||
&& game.status === 'playing' && i < 2000; i++) {
|
||||
if (game.turn === 2 && game.response?.kind === 'choamCharity' && !beforeCharityIncome)
|
||||
beforeCharityIncome = structuredClone(game);
|
||||
game = step(game, actions);
|
||||
if (beforeCharityIncome && !afterCharityIncome && game.response?.kind !== 'choamCharity')
|
||||
afterCharityIncome = structuredClone(game);
|
||||
}
|
||||
assert.ok(beforeCharityIncome && afterCharityIncome);
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 2);
|
||||
assert.ok(ixChoamSkillsTechPlayer(game, actor).spice < 2);
|
||||
return { setup, game, actions, family, kind, actor, owner, opponent, banker, trainer,
|
||||
action: { type: 'charity' }, cost: 10, amount: 2 - ixChoamSkillsTechPlayer(game, actor).spice,
|
||||
elite: 0, band, key: `${target.territory}:${target.sector}`, beforeCharityIncome, afterCharityIncome };
|
||||
}
|
||||
target = skillsTechPaymentsEmptyDestination(game);
|
||||
const smuggler = options.smuggler !== false;
|
||||
return { setup, game, actions, family, kind, actor, owner, opponent, banker, trainer,
|
||||
action: { type: 'ship', ...target, amount, elite, ...(smuggler ? { smuggler: true } : {}) },
|
||||
cost: amount - (smuggler ? 1 : 0), amount, elite, band, key: `${target.territory}:${target.sector}` };
|
||||
}
|
||||
|
||||
/** Settle native response/arrival windows, or stop at the real Suk choice. */
|
||||
export function settleIxChoamSkillsTech(state: Game, stopAtSuk = false): Game {
|
||||
let game = state;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
if (stopAtSuk && (game.decision?.kind === 'sukRescue' ||
|
||||
game.decision?.kind === 'battleCards' && game.lastBattleContext?.sukRescue?.completed)) return game;
|
||||
if (!game.response && !game.decision && !game.pendingShipment && !game.battle && !game.pendingTreacheryDiscard) return game;
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const options = game.decision.options;
|
||||
const choice = options.findIndex(o => o.elite === 2);
|
||||
assert.ok(choice >= 0);
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', choice });
|
||||
} else game = step(game);
|
||||
}
|
||||
throw new Error('Original Ix/CHOAM continuation did not settle.');
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { nextSkillsTechBattleStep, openSkillsTechBattle, finishSkillsTechBattle } from './fixture-skills-tech-battle';
|
||||
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
|
||||
|
||||
export type RicheseSkillsTechOptions = {
|
||||
/** Fresh authenticated undealt E2 lobby; retain its original seat IDs/order. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
kind?: 'marker' | 'stone' | 'shipment';
|
||||
marker?: 0 | 3 | 5;
|
||||
reserves?: number;
|
||||
band?: 'normal' | 'skilled';
|
||||
/** Atreides opponent exposes its original concealed-marker prescience fences. */
|
||||
opponent?: 'emperor' | 'atreides';
|
||||
/** A separate actual four-spice revival, never funding fabricated for shipment. */
|
||||
bankerPayment?: boolean;
|
||||
precedence?: 'double-traitor' | 'explosion';
|
||||
};
|
||||
export type RicheseSkillsTechStep = { actor: string; action: Action };
|
||||
export type RicheseSkillsTechFixture = {
|
||||
game: Game;
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
actions: RicheseSkillsTechStep[];
|
||||
staging: string[];
|
||||
owner: string;
|
||||
opponent: string;
|
||||
banker: string;
|
||||
trainer: string;
|
||||
territory: string;
|
||||
key: string;
|
||||
marker: 0 | 3 | 5;
|
||||
kind: 'marker' | 'stone' | 'shipment';
|
||||
beforeShipment: Game | null;
|
||||
shipmentAction: Action | null;
|
||||
afterShipment: Game | null;
|
||||
acquisition: { before: Game; actions: RicheseSkillsTechStep[]; after: Game };
|
||||
ownerPlan: Action;
|
||||
opponentPlan: Action;
|
||||
};
|
||||
|
||||
export function richeseSkillsTechPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/** Control only the original all14 offer shuffle. All later shuffles are native. */
|
||||
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
|
||||
const cards = LEADER_SKILL_CARDS.map(c => c.id);
|
||||
const remaining = cards.filter(c => !selected.includes(c));
|
||||
const desired = selected.flatMap(c => [c, remaining.shift()!]);
|
||||
desired.push(...remaining);
|
||||
const working = [...cards], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
let index = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (index < rolls.length) {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
array[0] = rolls[index++];
|
||||
} else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing legal no-spend/no-call policy, with the actual Richese unbid producer. */
|
||||
export function nextRicheseSkillsTechNativeStep(game: Game): RicheseSkillsTechStep {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const d = game.decision, actor = d.player, event = game.richeseBidding?.event;
|
||||
if (d.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
|
||||
if (d.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
|
||||
if (d.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache![0];
|
||||
assert.ok(card);
|
||||
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (d.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } };
|
||||
if (d.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: d.event, mode: 'ignore' } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
if (!game.response && !game.phaseOpening && !game.decision && lot && !lot.outcome) {
|
||||
const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
return nextSkillsTechBattleStep(game);
|
||||
}
|
||||
function step(game: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
actions?.push(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
export function advanceRicheseSkillsTechToPhase(state: Game, phase: number, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; (game.phase < phase || !clean(game)) && game.turn === state.turn && n < 1200; n++) game = step(game, actions);
|
||||
assert.equal(game.turn, state.turn);
|
||||
assert.ok(game.phase >= phase);
|
||||
assert.ok(clean(game));
|
||||
return game;
|
||||
}
|
||||
export function finishRicheseSkillsTechBattle(state: Game, stop: 'skill' | 'cleanup' | 'tech' | 'complete' = 'complete'): Game {
|
||||
return finishSkillsTechBattle(state, stop);
|
||||
}
|
||||
export function openRicheseSkillsTechBattle(fixture: RicheseSkillsTechFixture, hide = true): Game {
|
||||
return openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory, hide);
|
||||
}
|
||||
|
||||
function weakestUntrained(game: Game, id: string): string {
|
||||
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const leader = richeseSkillsTechPlayer(game, id).leaders.filter(l => !assigned.has(l.id) && !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader);
|
||||
return leader.id;
|
||||
}
|
||||
/** Exchange original physical Traitor custody; never mint another identity. */
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = richeseSkillsTechPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0];
|
||||
assert.ok(returned);
|
||||
const at = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(at >= 0 || donor);
|
||||
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved native Traitor ${identity} exchanged for ${returned}.`);
|
||||
}
|
||||
function held(game: Game, actor: string, kind: Player['hand'][number]['kind'], staging: string[]): string {
|
||||
const player = richeseSkillsTechPlayer(game, actor);
|
||||
const at = game.deck.findIndex(c => c.kind === kind);
|
||||
assert.ok(at >= 0);
|
||||
const card = game.deck.splice(at, 1)[0]; player.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: undealt native deck to ${actor}'s hand.`);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
/** Original fresh setup, first Storm and canonical token ownership, real cache
|
||||
* acquisition and phase transitions. Only skill offers, unplayed Spice order,
|
||||
* conserved forces/cards/Traitors are controlled and explicitly labelled.
|
||||
* Never assign wallets, turn, phases, plans, receipts, marker or Tech ownership.
|
||||
* Shipment stops BEFORE the real marker/companion action for authenticated UI use;
|
||||
* battle stops BEFORE chooseBattle, with live native private plan controls next. */
|
||||
export function createRicheseSkillsTechFixture(options: RicheseSkillsTechOptions = {}): RicheseSkillsTechFixture {
|
||||
const kind = options.kind ?? 'marker', marker = options.marker ?? 5;
|
||||
const skill = kind === 'shipment' ? 'smuggler' : 'suk-graduate';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('RICHESKILLTECH', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? true, ['choam']);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
|
||||
joinGame(game, newPlayer('opponent', 'Emperor', 'emperor'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby', 'Only a fresh authenticated undealt lobby can enter.');
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.ok(game.players.length >= 3 && game.players.length <= 6);
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
const owner = game.players.find(p => p.faction === 'richese')!.id;
|
||||
const opponent = game.players.find(p => p.faction === (options.opponent ?? 'emperor'))!.id;
|
||||
const banker = game.players.find(p => p.faction === 'atreides')!.id;
|
||||
assert.ok(owner && opponent && banker);
|
||||
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
assert.deepEqual(game.techTokens, createTechTokens());
|
||||
for (const seat of game.players) if (!seat.ready) game = applyAction(game, seat.id, { type: 'ready' });
|
||||
const initial = structuredClone(game), actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
const safe: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster', 'killer-medic'];
|
||||
const selected = game.players.map(p => p.id === owner ? skill : p.id === banker ? 'spice-banker' as const : safe.shift()!);
|
||||
const bySeat: Record<string, LeaderSkillId> = Object.fromEntries(game.players.map((p, i) => [p.id, selected[i]]));
|
||||
game = withOffers(selected, () => initializeSpiceBankerIncomeGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const eligible = view.eligibleLeaders.map(l => l.id);
|
||||
const leaders = richeseSkillsTechPlayer(game, actor).leaders.filter(l => eligible.includes(l.id))
|
||||
.sort((a, b) => actor === owner ? b.strength - a.strength : a.strength - b.strength);
|
||||
const selectedSkill = bySeat[actor];
|
||||
assert.ok(offer.cards.includes(selectedSkill) && !view.unavailableSkills?.[selectedSkill] && leaders[0]);
|
||||
const action: Action = { type: 'leaderSkill', event: offer.event, skill: selectedSkill, leader: leaders[0].id };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
} else game = step(game, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
// Two controlled, still-unplayed original blows, not minted cards or resources.
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
|
||||
for (const [position, card] of blows.entries()) {
|
||||
const at = game.spiceDeck.indexOf(card);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Original all14 offer shuffle and first two unplayed non-worm Spice Cards are controlled.');
|
||||
for (let n = 0; game.decision?.kind !== 'richeseCache' && n < 1000; n++) game = step(game, actions);
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
const acquisitionBefore = structuredClone(game), acquisitionActions: RicheseSkillsTechStep[] = [];
|
||||
for (let n = 0; !richeseSkillsTechPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner') && n < 100; n++) game = step(game, acquisitionActions);
|
||||
assert.ok(richeseSkillsTechPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
actions.push(...acquisitionActions);
|
||||
const acquisition = { before: acquisitionBefore, actions: acquisitionActions, after: structuredClone(game) };
|
||||
if (options.bankerPayment) {
|
||||
game = advanceRicheseSkillsTechToPhase(game, 4, actions);
|
||||
const payer = game.players.find(p => p.faction === 'emperor');
|
||||
assert.ok(payer);
|
||||
assert.ok(payer.reserves >= 3);
|
||||
payer.reserves -= 3; payer.tanks += 3;
|
||||
staging.push('Three original Emperor reserve counters conserved into Tanks before actual four-spice revival.');
|
||||
const action: Action = { type: 'revive', amount: 3 };
|
||||
actions.push({ actor: payer.id, action }); game = applyAction(game, payer.id, action);
|
||||
}
|
||||
game = advanceRicheseSkillsTechToPhase(game, 5, actions);
|
||||
for (let n = 0; game.active !== owner && n < 20; n++) game = step(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, owner);
|
||||
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 = {};
|
||||
const kept = p.id === owner ? p.hand.filter(c => c.id === 'richese-stone-burner') : [];
|
||||
game.deck.push(...p.hand.filter(c => !kept.includes(c))); p.hand = kept;
|
||||
}
|
||||
staging.push('Return original board counters to their own reserves and ordinary hands to undealt deck; retain the cache-acquired Stone in Richese custody.');
|
||||
const destination = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm));
|
||||
assert.ok(destination);
|
||||
const territory = destination.id, key = `${territory}:${destination.sectors[0]}`;
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === skill)!.leader;
|
||||
let beforeShipment: Game | null = null, shipmentAction: Action | null = null, afterShipment: Game | null = null;
|
||||
if (kind !== 'stone') {
|
||||
const own = richeseSkillsTechPlayer(game, owner), token = own.noField!.tokens.find(t => t.value === marker)!;
|
||||
shipmentAction = { type: 'ship', territory, sector: destination.sectors[0], noField: token.id, event: own.noFieldEvent,
|
||||
...(kind === 'shipment' ? { smuggler: true } : {}) };
|
||||
beforeShipment = structuredClone(game);
|
||||
if (kind !== 'shipment') {
|
||||
actions.push({ actor: owner, action: shipmentAction }); game = applyAction(game, owner, shipmentAction);
|
||||
for (let n = 0; !clean(game) && n < 100; n++) game = step(game, actions);
|
||||
afterShipment = structuredClone(game);
|
||||
}
|
||||
}
|
||||
const dial = kind === 'marker' ? Math.min(marker, options.reserves ?? 20, 3) : 2;
|
||||
let ownerPlan: Action = { type: 'battlePlan', dial, support: game.advanced ? dial : 0,
|
||||
leader: options.band === 'normal' ? weakestUntrained(game, owner) : trainer };
|
||||
let opponentPlan: Action = { type: 'battlePlan', dial: 0, support: 0, leader: weakestUntrained(game, opponent) };
|
||||
if (kind !== 'shipment') {
|
||||
const own = richeseSkillsTechPlayer(game, owner), enemy = richeseSkillsTechPlayer(game, opponent);
|
||||
const reserveLimit = options.reserves ?? 20;
|
||||
assert.ok(reserveLimit >= 0 && reserveLimit <= own.reserves);
|
||||
if (kind === 'marker' && own.reserves > reserveLimit) {
|
||||
own.forces['polar_sink:0'] = own.reserves - reserveLimit; own.reserves = reserveLimit;
|
||||
staging.push(`Conserve Richese counters outside battle in Polar Sink, leaving ${reserveLimit} real reserves.`);
|
||||
}
|
||||
const enemyAmount = kind === 'stone' ? 4 : 6;
|
||||
enemy.reserves -= enemyAmount; enemy.forces[key] = enemyAmount;
|
||||
if (kind === 'stone') {
|
||||
own.reserves -= 8; own.forces[key] = 8;
|
||||
ownerPlan = { ...ownerPlan, weapon: 'richese-stone-burner' };
|
||||
opponentPlan = { ...opponentPlan, dial: 1, support: game.advanced ? 1 : 0 };
|
||||
if (options.precedence === 'explosion') {
|
||||
ownerPlan.defense = held(game, owner, 'shield', staging);
|
||||
opponentPlan.weapon = held(game, opponent, 'lasgun', staging);
|
||||
}
|
||||
if (options.precedence === 'double-traitor') {
|
||||
exchangeTraitor(game, owner, String(opponentPlan.leader), staging);
|
||||
exchangeTraitor(game, opponent, String(ownerPlan.leader), staging);
|
||||
}
|
||||
} else {
|
||||
ownerPlan.defense = held(game, owner, 'shield', staging);
|
||||
opponentPlan.weapon = held(game, opponent, 'worthless', staging);
|
||||
}
|
||||
staging.push(`Conserved ordinary opponent battle counters: ${enemyAmount}; Stone owner counters: ${kind === 'stone' ? 8 : 0}.`);
|
||||
game = advanceRicheseSkillsTechToPhase(game, 6, actions);
|
||||
assert.equal(game.phase, 6);
|
||||
}
|
||||
assertAdvancedNativeCustody(game);
|
||||
return { game, initial, setup, actions, staging, owner, opponent, banker, trainer, territory, key, marker, kind,
|
||||
beforeShipment, shipmentAction, afterShipment, acquisition, ownerPlan, opponentPlan };
|
||||
}
|
||||
|
||||
export function payRicheseSkillsTechShipment(fixture: RicheseSkillsTechFixture): Game {
|
||||
assert.equal(fixture.kind, 'shipment'); assert.ok(fixture.shipmentAction);
|
||||
let game = applyAction(fixture.game, fixture.owner, fixture.shipmentAction);
|
||||
for (let n = 0; (!clean(game) || game.pendingShipment) && n < 200; n++) game = step(game);
|
||||
assert.ok(clean(game) && !game.pendingShipment);
|
||||
return game;
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export type SpiceBankerIncomeFixture = {game:Game;beforePayment:Game;actor:strin
|
||||
export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNativeStep|null {
|
||||
if(game.status==='finished') return null;
|
||||
if(game.pendingTreacheryDiscard) return {actor:game.host,action:{type:'advanceBots'}};
|
||||
if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
|
||||
if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}};
|
||||
if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
|
||||
if(game.decision?.kind==='bureaucratPayment') return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,redirect:false}};
|
||||
if(game.decision?.kind==='auctionPayment') return {actor:game.decision.player,action:{type:'decision',karama:false}};
|
||||
if(game.decision?.kind==='guildShipment') return {actor:game.decision.player,action:{type:'decision',allow:true}};
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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) {
|
||||
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'));
|
||||
}
|
||||
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' });
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { advanceSkillsTechPaymentsToPhase } from './fixture-skills-tech-payments';
|
||||
import {
|
||||
createIxChoamSkillsTechFixture, initializeIxChoamSkillsTechSetup,
|
||||
ixChoamSkillsTechPlayer as player, nextIxChoamSkillsTechStep, settleIxChoamSkillsTech,
|
||||
} from './fixture-ix-choam-skills-tech';
|
||||
|
||||
function custody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
||||
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected native action cannot mutate custody or money.');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const rules = advanced ? 'Advanced' : 'Basic';
|
||||
for (const family of ['ixians', 'choam'] as const) {
|
||||
for (const smuggler of [true, false]) void test(`${rules} ${family}: real typed four-counter invoice ${smuggler ? 'includes the free companion' : 'declines it'} and pays only native Heighliners at phase end`, () => {
|
||||
const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler, bankerIncome: true });
|
||||
const before = fixture.game;
|
||||
const payer = player(before, fixture.actor);
|
||||
const tokenOwner = before.techTokens!.heighliners.owner!;
|
||||
const paid = settleIxChoamSkillsTech(applyAction(before, fixture.actor, fixture.action));
|
||||
const delivered = player(paid, fixture.actor);
|
||||
assert.equal(delivered.spice, payer.spice - (smuggler ? 3 : 4));
|
||||
assert.equal(delivered.reserves, payer.reserves - 4);
|
||||
assert.equal(delivered.forces[fixture.key!], 4, 'Free means a delivered physical counter, not a price-only fiction.');
|
||||
if (family === 'ixians') {
|
||||
assert.equal(tokenOwner, fixture.owner, 'Original setup assigns printed Heighliners to Ixians.');
|
||||
assert.equal(delivered.elites!.reserves, payer.elites!.reserves - 2);
|
||||
assert.equal(delivered.elites!.forces[fixture.key!], 2);
|
||||
}
|
||||
for (const p of before.players.filter(p => p.id !== fixture.actor))
|
||||
assert.equal(player(paid, p.id).spice, p.spice, 'Neither deferred store is spendable upon arrival.');
|
||||
const techAmount = ownedTech(before.techTokens, tokenOwner).length;
|
||||
assert.equal(paid.techTokens!.heighliners.spice, techAmount);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, before.techTokens!.axlotl.spice);
|
||||
const bankerAmount = smuggler ? 0 : 1;
|
||||
assert.deepEqual(viewGame(paid, fixture.banker!).spiceBankerIncome!.deferred,
|
||||
bankerAmount ? [{ owner: fixture.banker, amount: bankerAmount }] : []);
|
||||
const sources = structuredClone(paid.spiceBankerIncome!.sources);
|
||||
const end = advanceSkillsTechPaymentsToPhase(paid, 6);
|
||||
// Empty Battle may auto-skip into native Collection. Quote that separately,
|
||||
// rather than treating stronghold income as a discounted shipment fee.
|
||||
const expected = end.phase === 7
|
||||
? Object.fromEntries(quoteSpiceCollection(paid).receipts.map(r => [r.player, r.balance]))
|
||||
: Object.fromEntries(paid.players.map(p => [p.id, p.spice]));
|
||||
expected[tokenOwner] += techAmount;
|
||||
assert.deepEqual(Object.fromEntries(end.players.map(p => [p.id, p.spice])), expected);
|
||||
assert.equal(end.techTokens!.heighliners.spice, 0);
|
||||
assert.deepEqual(end.spiceBankerIncome!.sources, sources, 'Tech and Collection income are not Banker bank payments.');
|
||||
const collection = advanceSkillsTechPaymentsToPhase(end, 7);
|
||||
const mentat = advanceSkillsTechPaymentsToPhase(collection, 8);
|
||||
for (const p of collection.players)
|
||||
assert.equal(player(mentat, p.id).spice, p.spice + (p.id === fixture.banker ? bankerAmount : 0));
|
||||
assert.deepEqual(viewGame(mentat, fixture.banker!).spiceBankerIncome!.deferred, []);
|
||||
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources);
|
||||
reject(paid, fixture.actor, fixture.action);
|
||||
custody(mentat);
|
||||
});
|
||||
|
||||
for (const band of ['normal', 'skilled'] as const) void test(`${rules} ${family} ${band} Suk: native typed casualties rescue to board/reserves, remaining counters enter Tanks, and the original loser token follows victory`, () => {
|
||||
const fixture = createIxChoamSkillsTechFixture({ advanced, family, kind: 'suk-battle', band });
|
||||
const before = fixture.game;
|
||||
const owner = player(before, fixture.owner);
|
||||
const enemyToken = ownedTech(before.techTokens, fixture.opponent);
|
||||
assert.equal(enemyToken.length, 1, 'Three original seats receive one original token each.');
|
||||
let game = applyAction(before, fixture.owner, fixture.action);
|
||||
game = applyAction(game, fixture.opponent, fixture.opponentPlan!);
|
||||
game = settleIxChoamSkillsTech(game, true);
|
||||
const pending = game;
|
||||
const decision = game.decision?.kind === 'sukRescue' ? game.decision : null;
|
||||
const losses = decision ? game.pendingSukRescue!.losses!
|
||||
: family === 'ixians' ? { normal: 0, elite: 2 } : { normal: 4, elite: 0 };
|
||||
assert.equal(player(game, fixture.owner).spice, owner.spice - fixture.cost,
|
||||
'CHOAM own support earns no CHOAM payment income; Tech reward does not mint money.');
|
||||
assert.equal(game.techTokens![enemyToken[0]].owner, fixture.opponent, 'Suk resolves before original Tech custody reward.');
|
||||
if (family === 'ixians' && advanced)
|
||||
assert.deepEqual(losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 1 });
|
||||
if (!decision) {
|
||||
assert.equal(band, 'normal');
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true, 'A uniquely determined normal rescue is automatic.');
|
||||
}
|
||||
const choice = decision?.options.findIndex(o => band === 'normal'
|
||||
? o.normal === (family === 'ixians' ? 0 : 1) && o.elite === (family === 'ixians' ? 1 : 0) && !o.kept
|
||||
: family === 'ixians' && !advanced
|
||||
? o.normal === 0 && o.elite === 2 && o.kept?.kind === 'elite'
|
||||
: o.normal === (family === 'ixians' ? 2 : 3) && o.elite === (family === 'ixians' ? 1 : 0) && o.kept?.kind === 'normal') ?? -1;
|
||||
if (decision) assert.ok(choice >= 0);
|
||||
const selected = decision ? decision.options[choice]
|
||||
: { normal: family === 'ixians' ? 0 : 1, elite: family === 'ixians' ? 1 : 0, kept: null };
|
||||
if (decision) {
|
||||
reject(pending, fixture.opponent, { type: 'decision', event: decision.event, choice });
|
||||
reject(pending, fixture.owner, { type: 'decision', event: decision.event, choice: decision.options.length });
|
||||
game = applyAction(pending, fixture.owner, { type: 'decision', event: decision.event, choice });
|
||||
}
|
||||
const rescued = player(game, fixture.owner);
|
||||
const kept = selected.kept ? 1 : 0;
|
||||
assert.equal(rescued.reserves, owner.reserves + selected.normal + selected.elite - kept);
|
||||
assert.equal(rescued.tanks, owner.tanks + losses.normal + losses.elite - selected.normal - selected.elite);
|
||||
assert.equal(rescued.forces[fixture.key!], 8 - losses.normal - losses.elite + kept);
|
||||
if (family === 'ixians') {
|
||||
assert.equal(rescued.elites!.reserves, owner.elites!.reserves + selected.elite - (selected.kept?.kind === 'elite' ? 1 : 0));
|
||||
assert.equal(rescued.elites!.tanks, owner.elites!.tanks + losses.elite - selected.elite);
|
||||
assert.equal(rescued.elites!.forces[fixture.key!] ?? 0, 2 - losses.elite + (selected.kept?.kind === 'elite' ? 1 : 0));
|
||||
}
|
||||
custody(game);
|
||||
game = settleIxChoamSkillsTech(game);
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(game.techTokens![enemyToken[0]].owner, fixture.owner);
|
||||
assert.equal(ownedTech(game.techTokens, fixture.owner).length, 2);
|
||||
assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0);
|
||||
assert.equal(game.techTokens![enemyToken[0]].spice, 0, 'Original reward transfers custody, not a new income event.');
|
||||
custody(game);
|
||||
|
||||
if (decision) for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, fixture.owner);
|
||||
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
const continued = applyAction(pending, fixture.owner, action);
|
||||
assert.equal(continued.pendingSukRescue, null);
|
||||
assert.equal(player(continued, fixture.owner).spice, owner.spice - fixture.cost);
|
||||
custody(continued);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${rules} CHOAM: real exhausted Emperor shipment makes next-turn charity eligible; only original Spice Production accrues, never Axlotl`, () => {
|
||||
const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity', bankerIncome: true });
|
||||
const before = fixture.game;
|
||||
assert.ok(fixture.actions.some(step => step.actor === fixture.actor && step.action.type === 'ship'
|
||||
&& step.action.amount === 5), 'Eligibility comes from an actual original ten-spice desert invoice.');
|
||||
const incomeBefore = fixture.beforeCharityIncome!;
|
||||
const incomeAfter = fixture.afterCharityIncome!;
|
||||
assert.equal(incomeBefore.response!.kind, 'choamCharity');
|
||||
for (const p of incomeBefore.players) assert.equal(player(incomeAfter, p.id).spice,
|
||||
p.spice + (p.id === fixture.owner ? 2 * incomeBefore.players.length : 0));
|
||||
assert.deepEqual(incomeAfter.techTokens, incomeBefore.techTokens,
|
||||
'Original CHOAM opening income is not a Charity claim or Tech trigger.');
|
||||
assert.deepEqual(incomeAfter.spiceBankerIncome, incomeBefore.spiceBankerIncome,
|
||||
'Opening income is not an actual four-spice bank payment.');
|
||||
const productionOwner = before.techTokens!.production.owner!;
|
||||
const after = applyAction(before, fixture.actor, fixture.action);
|
||||
assert.equal(player(after, fixture.actor).spice, 2);
|
||||
assert.equal(player(after, fixture.owner).spice, player(before, fixture.owner).spice - fixture.amount);
|
||||
assert.equal(after.techTokens!.production.spice, ownedTech(before.techTokens, productionOwner).length);
|
||||
assert.deepEqual(after.techTokens!.axlotl, before.techTokens!.axlotl);
|
||||
assert.deepEqual(after.spiceBankerIncome, before.spiceBankerIncome, 'Charity is CHOAM-funded income, not a skill bank payment.');
|
||||
reject(after, fixture.actor, fixture.action);
|
||||
const end = advanceSkillsTechPaymentsToPhase(after, 3);
|
||||
for (const p of after.players) assert.equal(player(end, p.id).spice,
|
||||
p.spice + (p.id === productionOwner ? ownedTech(before.techTokens, productionOwner).length : 0));
|
||||
assert.equal(end.techTokens!.production.spice, 0);
|
||||
assert.deepEqual(end.techTokens!.axlotl, before.techTokens!.axlotl);
|
||||
custody(end);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced CHOAM Auditor remains unskilled in original Tech setup and an eligible Bene Gesserit charity claim is expressly excluded from Spice Production', () => {
|
||||
const initial = createGame('AUTHCHOAMTECH', newPlayer('human-e', 'Human Emperor', 'emperor'), true, ['choam']);
|
||||
joinGame(initial, newPlayer('human-c', 'Human CHOAM', 'choam'));
|
||||
joinGame(initial, newPlayer('human-bg', 'Human BG', 'beneGesserit'));
|
||||
const identities = initial.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }));
|
||||
let setup = initializeIxChoamSkillsTechSetup({ initial, family: 'choam', kind: 'charity' });
|
||||
while (setup.setupStage === 'prediction') {
|
||||
const next = nextIxChoamSkillsTechStep(setup);
|
||||
setup = applyAction(setup, next.actor, next.action);
|
||||
}
|
||||
const offer = setup.leaderSkills!.offers['human-c'];
|
||||
assert.ok(!viewGame(setup, 'human-c').leaderSkills!.eligibleLeaders.some(l => l.id === 'choam-auditor'));
|
||||
reject(setup, 'human-c', { type: 'leaderSkill', event: offer.event, skill: 'smuggler', leader: 'choam-auditor' });
|
||||
const fixture = createIxChoamSkillsTechFixture({ initial, family: 'choam', kind: 'charity' });
|
||||
assert.equal(fixture.actor, 'human-e');
|
||||
assert.equal(fixture.owner, 'human-c');
|
||||
assert.deepEqual(fixture.game.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), identities);
|
||||
assert.ok(!fixture.game.leaderSkills!.assignments.some(a => a.leader === 'choam-auditor'));
|
||||
const before = fixture.game;
|
||||
const claimed = settleIxChoamSkillsTech(applyAction(before, 'human-bg', { type: 'charity' }));
|
||||
assert.equal(player(claimed, 'human-bg').spice, player(before, 'human-bg').spice + 2);
|
||||
assert.equal(player(claimed, 'human-c').spice, player(before, 'human-c').spice - 2);
|
||||
assert.deepEqual(claimed.techTokens!.production, before.techTokens!.production, 'Advanced BG income is not the original production trigger.');
|
||||
assert.deepEqual(claimed.techTokens!.axlotl, before.techTokens!.axlotl);
|
||||
const eligible = applyAction(claimed, fixture.actor, fixture.action);
|
||||
assert.equal(eligible.techTokens!.production.spice, 1);
|
||||
custody(eligible);
|
||||
});
|
||||
@@ -880,7 +880,7 @@ void test('Banker income rejects incompatible profiles and public activation wit
|
||||
game => { game.players.splice(1); },
|
||||
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
||||
];
|
||||
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
|
||||
const modules = ['homeworlds', 'strongholdCards'].map(type => {
|
||||
const game = applyAction(native, native.host, { type, enabled: true });
|
||||
for (const player of game.players) player.ready = true;
|
||||
return game;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
|
||||
import {
|
||||
advanceRicheseSkillsTechToPhase, createRicheseSkillsTechFixture, finishRicheseSkillsTechBattle,
|
||||
nextRicheseSkillsTechNativeStep, openRicheseSkillsTechBattle, payRicheseSkillsTechShipment,
|
||||
richeseSkillsTechPlayer as player, type RicheseSkillsTechFixture,
|
||||
} from './fixture-richese-skills-tech';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function cards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])].map(c => c.id).sort();
|
||||
}
|
||||
function wallets(game: Game): Record<string, number> {
|
||||
return Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
}
|
||||
function changedTokens(before: Game, after: Game): string[] {
|
||||
return TECH_TOKENS.filter(t => before.techTokens![t.id].owner !== after.techTokens![t.id].owner).map(t => t.id);
|
||||
}
|
||||
function reveal(fixture: RicheseSkillsTechFixture, hide = true): Game {
|
||||
let game = openRicheseSkillsTechBattle(fixture, hide);
|
||||
game = applyAction(game, fixture.owner, fixture.ownerPlan);
|
||||
return applyAction(game, fixture.opponent, fixture.opponentPlan);
|
||||
}
|
||||
function calls(state: Game, callers: string[] = []): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.battle?.revealed && !game.decision && !game.response && n < 20; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
if (next.action.type !== 'traitorCall') break;
|
||||
next.action.call = callers.includes(next.actor);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function reward(fixture: RicheseSkillsTechFixture, state: Game): Game {
|
||||
const token = ownedTech(fixture.game.techTokens, fixture.opponent)[0];
|
||||
assert.ok(token, 'Original three-seat first Storm gives the defeated seat one physical token.');
|
||||
assert.deepEqual(changedTokens(fixture.game, state), [], 'Physical rescue/cleanup must precede token reward.');
|
||||
const settled = finishRicheseSkillsTechBattle(state);
|
||||
assert.deepEqual(changedTokens(fixture.game, settled), [token]);
|
||||
assert.equal(settled.techTokens![token].owner, fixture.owner);
|
||||
assert.equal(settled.pendingTech, null);
|
||||
assert.deepEqual(cards(settled), cards(fixture.game));
|
||||
assertAdvancedNativeCustody(settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native Richese ${marker} marker reveals only after both plans, caps real Suk casualties, then rewards the original loser token`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ advanced, marker, ...(marker === 5 ? { reserves: 3 } : {}) });
|
||||
const beforeShipment = fixture.beforeShipment!, shipped = fixture.afterShipment!;
|
||||
assert.equal(player(shipped, fixture.owner).spice, player(beforeShipment, fixture.owner).spice - 1);
|
||||
assert.equal(player(shipped, fixture.owner).reserves, player(beforeShipment, fixture.owner).reserves);
|
||||
let game = openRicheseSkillsTechBattle(fixture);
|
||||
const pool = marker === 5 ? 3 : marker;
|
||||
reject(game, fixture.owner, { ...fixture.ownerPlan, dial: pool + 1, support: advanced ? pool + 1 : 0 });
|
||||
game = applyAction(game, fixture.owner, fixture.ownerPlan);
|
||||
assert.ok(player(game, fixture.owner).noField!.deployed);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
|
||||
assert.equal(viewGame(game, fixture.opponent).richeseNoField!.private, null);
|
||||
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
|
||||
assert.equal(player(game, fixture.owner).noField!.deployed, null);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, pool);
|
||||
assert.equal(player(game, fixture.owner).reserves, (marker === 5 ? 3 : 20) - pool);
|
||||
const before = player(fixture.game, fixture.owner).spice;
|
||||
game = finishRicheseSkillsTechBattle(calls(game), 'skill');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(player(game, fixture.owner).spice, before - (advanced ? pool : 0));
|
||||
assert.equal(player(game, fixture.opponent).tanks, 6);
|
||||
const losingCard = String(fixture.opponentPlan.weapon);
|
||||
assert.equal(game.discard.filter(c => c.id === losingCard).length, 1);
|
||||
if (pool) {
|
||||
const d = game.decision;
|
||||
assert.ok(d?.kind === 'sukRescue');
|
||||
assert.equal(d.mode, 'skilled');
|
||||
assert.equal(player(game, fixture.owner).tanks, 0, 'Do not pre-apply declared casualties before real rescue.');
|
||||
const choice = d.options.findIndex(o => o.normal === pool && o.elite === 0 && o.kept?.key === fixture.key);
|
||||
assert.ok(choice >= 0);
|
||||
reject(game, fixture.opponent, { type: 'decision', event: d.event, choice });
|
||||
reject(game, fixture.owner, { type: 'decision', event: `${d.event}-stale`, choice });
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: d.event, choice });
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 1);
|
||||
assert.equal(player(game, fixture.owner).reserves, (marker === 5 ? 3 : 20) - 1);
|
||||
assert.equal(player(game, fixture.owner).tanks, 0);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
} else {
|
||||
assert.equal(player(game, fixture.owner).reserves, 20);
|
||||
assert.equal(player(game, fixture.owner).tanks, 0);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
|
||||
}
|
||||
reward(fixture, game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} normal Suk saves one actual three-marker casualty, never all declared losses`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ advanced, marker: 3, band: 'normal' });
|
||||
let game = finishRicheseSkillsTechBattle(calls(reveal(fixture, false)), 'cleanup');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
|
||||
assert.equal(player(game, fixture.owner).tanks, 2);
|
||||
assert.equal(player(game, fixture.owner).reserves, 18);
|
||||
game = reward(fixture, game);
|
||||
assert.equal(player(game, fixture.opponent).tanks, 6);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Smuggler adds one real companion to No-Field ${marker} without changing its one-marker fee or earning Banker`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ advanced, kind: 'shipment', marker });
|
||||
const before = fixture.game, paid = payRicheseSkillsTechShipment(fixture);
|
||||
const own = player(paid, fixture.owner);
|
||||
assert.equal(own.spice, player(before, fixture.owner).spice - 1);
|
||||
assert.equal(own.reserves, player(before, fixture.owner).reserves - 1);
|
||||
assert.equal(own.forces[fixture.key], 1);
|
||||
assert.equal(own.noField!.deployed!.tokenId, String(fixture.shipmentAction!.noField));
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 1);
|
||||
assert.deepEqual(viewGame(paid, fixture.banker).spiceBankerIncome!.deferred, []);
|
||||
assert.deepEqual(cards(paid), cards(before));
|
||||
assertAdvancedNativeCustody(paid);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a real qualifying revival earns Banker independently; No-Field companion triggers Heighliners and each collects at its original final leg exactly once', () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', bankerPayment: true });
|
||||
const before = fixture.game;
|
||||
assert.deepEqual(viewGame(before, fixture.banker).spiceBankerIncome!.deferred, [{ owner: fixture.banker, amount: 1 }]);
|
||||
const grants = before.spiceBankerIncome!.sources.filter(row => row.grant);
|
||||
assert.deepEqual(grants.map(row => row.source.bankLegs), [[{ payer: fixture.opponent, amount: 4 }]]);
|
||||
const paid = payRicheseSkillsTechShipment(fixture);
|
||||
assert.equal(player(paid, fixture.owner).spice, player(before, fixture.owner).spice - 1);
|
||||
assert.deepEqual(paid.spiceBankerIncome!.sources.filter(row => row.grant), grants);
|
||||
const sources = structuredClone(paid.spiceBankerIncome!.sources);
|
||||
const expected = Object.fromEntries(quoteSpiceCollection(paid).receipts.map(r => [r.player, r.balance]));
|
||||
const techOwner = paid.techTokens!.heighliners.owner!;
|
||||
expected[techOwner] += 1;
|
||||
const collection = advanceRicheseSkillsTechToPhase(paid, 7);
|
||||
assert.equal(collection.phase, 7, 'Empty Battle may correctly auto-skip from Movement to Collection.');
|
||||
assert.deepEqual(wallets(collection), expected);
|
||||
assert.equal(collection.techTokens!.heighliners.spice, 0);
|
||||
assert.deepEqual(viewGame(collection, fixture.banker).spiceBankerIncome!.deferred, [{ owner: fixture.banker, amount: 1 }]);
|
||||
const mentat = advanceRicheseSkillsTechToPhase(collection, 8);
|
||||
expected[fixture.banker] += 1;
|
||||
assert.deepEqual(wallets(mentat), expected);
|
||||
assert.deepEqual(viewGame(mentat, fixture.banker).spiceBankerIncome!.deferred, []);
|
||||
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources, 'Neither Tech income nor Banker collection creates another bank-payment source.');
|
||||
const next = nextRicheseSkillsTechNativeStep(mentat), continued = applyAction(mentat, next.actor, next.action);
|
||||
assert.deepEqual(wallets(continued), expected);
|
||||
assert.deepEqual(continued.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections);
|
||||
assertAdvancedNativeCustody(continued);
|
||||
});
|
||||
|
||||
void test('original mixed own-marker battle guard survives Skills + Tech; only native voluntary reveal before Battle permits physical combat', () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', marker: 5 });
|
||||
let paid = payRicheseSkillsTechShipment(fixture);
|
||||
const enemy = player(paid, fixture.opponent);
|
||||
enemy.reserves -= 6; enemy.forces[fixture.key] = 6; // Explicit conserved opponent position, not a played shipment claim.
|
||||
const battle = advanceRicheseSkillsTechToPhase(paid, 6);
|
||||
const actor = battle.active!, target = actor === fixture.owner ? fixture.opponent : fixture.owner;
|
||||
reject(battle, actor, { type: 'chooseBattle', territory: fixture.territory, target });
|
||||
const own = player(paid, fixture.owner), token = own.noField!.deployed!.tokenId;
|
||||
reject(battle, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
|
||||
paid = applyAction(paid, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
|
||||
assert.equal(player(paid, fixture.owner).forces[fixture.key], 6);
|
||||
assert.equal(player(paid, fixture.owner).reserves, 14);
|
||||
assert.equal(player(paid, fixture.owner).noField!.deployed, null);
|
||||
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
|
||||
const opened = openRicheseSkillsTechBattle({ ...fixture, game: ready });
|
||||
assert.equal(viewGame(opened, fixture.owner).battle!.ownForces!.normal, 6);
|
||||
assertAdvancedNativeCustody(opened);
|
||||
});
|
||||
|
||||
for (const mode of ['kill', 'ignore'] as const) void test(`cache-acquired Stone ${mode} uses real undialed forces and cleanup before awarding the actual winner an original loser-owned Tech Token`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'stone' });
|
||||
assert.ok(fixture.acquisition.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
|
||||
assert.equal(fixture.acquisition.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
|
||||
for (const p of fixture.acquisition.before.players) assert.equal(player(fixture.acquisition.after, p.id).spice, p.spice);
|
||||
let game = reveal(fixture);
|
||||
assert.ok(game.decision?.kind === 'stoneBurner');
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision.event, mode });
|
||||
const paidBefore = player(fixture.game, fixture.owner).spice;
|
||||
game = finishRicheseSkillsTechBattle(calls(game), 'skill');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.owner, 'Eight minus two undialed counters beats four minus one, independent of leaders.');
|
||||
for (const [actor, plan] of [[fixture.owner, fixture.ownerPlan], [fixture.opponent, fixture.opponentPlan]] as const) {
|
||||
assert.equal(player(game, actor).leaders.find(l => l.id === plan.leader)!.dead, mode === 'kill');
|
||||
}
|
||||
const bounty = mode === 'kill' ? [fixture.ownerPlan, fixture.opponentPlan].reduce((sum, plan, i) =>
|
||||
sum + player(fixture.game, i === 0 ? fixture.owner : fixture.opponent).leaders.find(l => l.id === plan.leader)!.strength, 0) : 0;
|
||||
assert.equal(player(game, fixture.owner).spice, paidBefore - 2 + bounty);
|
||||
if (mode === 'ignore') {
|
||||
const d = game.decision;
|
||||
assert.ok(d?.kind === 'sukRescue', 'Surviving hidden Suk trainer rescues actual Stone dialed casualties.');
|
||||
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.key === fixture.key);
|
||||
assert.ok(choice >= 0);
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: d.event, choice });
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 7);
|
||||
assert.equal(player(game, fixture.owner).tanks, 0);
|
||||
assert.equal(player(game, fixture.owner).reserves, 13);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
} else {
|
||||
assert.equal(game.lastBattleContext!.sukRescue, undefined, 'Killed skilled trainer cannot rescue.');
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 6);
|
||||
assert.equal(player(game, fixture.owner).tanks, 2);
|
||||
}
|
||||
assert.equal(player(game, fixture.opponent).tanks, 4);
|
||||
const collection = quoteSpiceCollection(game).receipts.find(r => r.player === fixture.owner)!;
|
||||
const settled = reward(fixture, game);
|
||||
assert.ok(player(settled, fixture.owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
assert.equal(player(settled, fixture.owner).spice, paidBefore - 2 + bounty + collection.collected + collection.strongholds,
|
||||
'Only real Stone support/bounty and independently quoted native Collection affect the wallet.');
|
||||
});
|
||||
|
||||
for (const precedence of ['double-traitor', 'explosion'] as const) void test(`${precedence} takes precedence over actual cached Stone and gives no winner, no rescue and no Tech reward`, () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ kind: 'stone', precedence });
|
||||
let game = reveal(fixture);
|
||||
assert.ok(game.decision?.kind === 'stoneBurner');
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision.event, mode: 'kill' });
|
||||
game = finishRicheseSkillsTechBattle(calls(game, precedence === 'double-traitor' ? [fixture.owner, fixture.opponent] : []));
|
||||
assert.equal(game.lastBattleContext!.winner, null);
|
||||
assert.deepEqual(changedTokens(fixture.game, game), []);
|
||||
assert.equal(player(game, fixture.owner).tanks, 8);
|
||||
assert.equal(player(game, fixture.opponent).tanks, 4);
|
||||
assert.equal(game.discard.filter(c => c.id === 'richese-stone-burner').length, 1);
|
||||
assert.deepEqual(cards(game), cards(fixture.game));
|
||||
assertAdvancedNativeCustody(game);
|
||||
});
|
||||
|
||||
void test('all four own-seat policies legally commit real capped marker plans, resolve skilled Suk and choose both-mode Stone controls without inventing token custody', () => {
|
||||
const marker = createRicheseSkillsTechFixture({ marker: 5, reserves: 3 });
|
||||
const stone = createRicheseSkillsTechFixture({ kind: 'stone' });
|
||||
const pending = finishRicheseSkillsTechBattle(calls(reveal(marker)), 'skill');
|
||||
assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
const choice = reveal(stone);
|
||||
assert.ok(choice.decision?.kind === 'stoneBurner');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const plans = openRicheseSkillsTechBattle(marker);
|
||||
const view = viewGame(plans, marker.owner); view.players.find(p => p.id === marker.owner)!.bot = difficulty;
|
||||
const plan = botActions(view).find(a => a.type === 'battlePlan'); assert.ok(plan);
|
||||
assert.ok(Number(plan.dial) <= 3 && Number(plan.support ?? 0) <= player(plans, marker.owner).spice);
|
||||
const sealed = applyAction(plans, marker.owner, plan);
|
||||
assert.ok(player(sealed, marker.owner).noField!.deployed);
|
||||
assert.deepEqual(changedTokens(marker.game, sealed), []);
|
||||
const rescueView = viewGame(pending, marker.owner); rescueView.players.find(p => p.id === marker.owner)!.bot = difficulty;
|
||||
const rescue = botActions(rescueView)[0]; assert.ok(rescue?.type === 'decision');
|
||||
const rescued = applyAction(pending, marker.owner, rescue);
|
||||
assert.equal(player(rescued, marker.owner).forces[marker.key], 1);
|
||||
reward(marker, rescued);
|
||||
const stoneView = viewGame(choice, stone.owner); stoneView.players.find(p => p.id === stone.owner)!.bot = difficulty;
|
||||
const mode = botActions(stoneView)[0]; assert.ok(mode.mode === 'kill' || mode.mode === 'ignore');
|
||||
const chosen = applyAction(choice, stone.owner, mode);
|
||||
assert.equal(chosen.battle!.stoneBurner![stone.owner], mode.mode);
|
||||
const settled = finishRicheseSkillsTechBattle(calls(chosen));
|
||||
assert.equal(settled.lastBattleContext!.winner, stone.owner);
|
||||
assert.equal(ownedTech(settled.techTokens, stone.owner).length, 2);
|
||||
assertAdvancedNativeCustody(settled);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh authenticated original ready lobby retains IDs and seat order through actual marker payment, battle and original reward', () => {
|
||||
const initial = createGame('AUTHRICH', newPlayer('human-enemy', 'Human Emperor', 'emperor'), true, ['choam']);
|
||||
joinGame(initial, newPlayer('human-richese', 'Human Richese', 'richese'));
|
||||
joinGame(initial, newPlayer('human-banker', 'Human Banker', 'atreides'));
|
||||
const original = structuredClone(initial), identities = initial.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }));
|
||||
const fixture = createRicheseSkillsTechFixture({ initial, marker: 3 });
|
||||
assert.deepEqual(initial, original);
|
||||
assert.equal(fixture.owner, 'human-richese');
|
||||
const completed = finishRicheseSkillsTechBattle(calls(reveal(fixture)));
|
||||
assert.equal(completed.lastBattleContext!.winner, fixture.owner);
|
||||
assert.deepEqual(completed.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), identities);
|
||||
assert.equal(ownedTech(completed.techTokens, fixture.owner).length, 2);
|
||||
assertAdvancedNativeCustody(completed);
|
||||
});
|
||||
|
||||
void test('native Atreides dial and whole-plan prescience cannot disclose an own concealed Richese marker merely because Skills and Tech coexist', () => {
|
||||
const fixture = createRicheseSkillsTechFixture({ marker: 5, opponent: 'atreides' });
|
||||
let game = structuredClone(fixture.game);
|
||||
// Explicit conserved physical Karama custody; no invented offer or preparation.
|
||||
const at = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(at >= 0);
|
||||
const karama = game.deck.splice(at, 1)[0]; player(game, fixture.opponent).hand.push(karama);
|
||||
const actor = game.active!;
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: actor === fixture.owner ? fixture.opponent : fixture.owner });
|
||||
for (let n = 0; game.battle?.preparation?.kind !== 'prescience' && n < 100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
reject(game, fixture.opponent, { type: 'prescience', field: 'dial' });
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let n = 0; game.decision?.kind !== 'fullPlanOffer' && n < 100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'fullPlanOffer');
|
||||
reject(game, fixture.opponent, { type: 'card', mode: 'special', card: karama.id, target: fixture.owner });
|
||||
assert.ok(player(game, fixture.owner).noField!.deployed);
|
||||
assert.ok(player(game, fixture.opponent).hand.some(c => c.id === karama.id));
|
||||
assert.deepEqual(changedTokens(fixture.game, game), []);
|
||||
assertAdvancedNativeCustody(game);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { quoteSukRescue } from '../game/suk-graduate';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
|
||||
import {
|
||||
createTleilaxuSkillsTechFixture, createTleilaxuSkillsTechRevivalFixture,
|
||||
advanceTleilaxuSkillsTechToPhase, settleTleilaxuSkillsTechRevival,
|
||||
tleilaxuSkillsTechPlayer as player, tleilaxuSkillsTechForces as at,
|
||||
type TleilaxuSkillsTechFixture,
|
||||
} from './fixture-tleilaxu-skills-tech';
|
||||
|
||||
function custody(game: Game) {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : 5);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function retainedReward(f: TleilaxuSkillsTechFixture, after: Game) {
|
||||
const before = player(f.faceDance, f.winner), winner = player(after, f.winner);
|
||||
assert.equal(after.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(after.lastBattleContext!.event, f.faceDance.lastBattleContext!.event);
|
||||
assert.equal(after.lastBattleContext!.result, f.faceDance.lastBattleContext!.result);
|
||||
assert.deepEqual(after.techTokens, f.faceDance.techTokens, 'Replacement is not another win, token claim or Axlotl restoration.');
|
||||
assert.equal(after.techTokens!.axlotl.owner, f.winner);
|
||||
assert.deepEqual(winner.hand, before.hand, 'The original winner keeps its completed physical card outcome.');
|
||||
assert.equal(winner.spice, before.spice, 'Face Dance adds no bounty and claws back no earned spice.');
|
||||
assert.equal(winner.tanks, before.tanks, 'Replacement returns survivors rather than killing a second army.');
|
||||
assert.equal(winner.reserves, before.reserves + at(f.faceDance, f.winner, f.territory));
|
||||
assert.equal(at(after, f.winner, f.territory), 0);
|
||||
const leader = winner.leaders.find(l => l.id === f.winnerLeader)!;
|
||||
const original = before.leaders.find(l => l.id === f.winnerLeader)!;
|
||||
assert.equal(leader.dead, true);
|
||||
assert.equal(leader.deaths, original.deaths + (original.dead ? 0 : 1));
|
||||
assert.ok(!after.leaderSkills!.assignments.some(a => a.leader === f.winnerLeader));
|
||||
assert.equal(after.leaderSkills!.deck.filter(skill => skill === f.skill).length, 1, 'Native trainer death returns the physical skill once.');
|
||||
assert.equal(player(after, f.tleilaxu).faceDancers!.find(c => c.leader === f.winnerLeader)!.revealed, true);
|
||||
custody(after);
|
||||
}
|
||||
|
||||
void test('native Guild/Ix winner earns actual losing Tleilaxu Axlotl after skill and card cleanup, before optional Face Dance', () => {
|
||||
for (const advanced of [false, true]) for (const winner of ['guild', 'ixians'] as const) {
|
||||
const f = createTleilaxuSkillsTechFixture({ advanced, winner });
|
||||
const quote = quoteSpiceBankerIncomeBattleEconomics(f.revealed, f.faceDance).resolution;
|
||||
assert.equal(quote.winner, f.winner);
|
||||
assert.deepEqual(quote.bounty, { player: f.winner, amount: player(f.revealed, f.tleilaxu).leaders.find(l => l.id === f.loserLeader)!.strength });
|
||||
assert.equal(f.beforePlans.techTokens!.axlotl.owner, f.tleilaxu);
|
||||
assert.equal(f.winnerCards.techTokens!.axlotl.owner, f.tleilaxu, 'Reward is still owed while the actual winner chooses cards.');
|
||||
assert.equal(f.skillChoice!.decision!.kind, 'sukRescue');
|
||||
assert.equal(f.skillChoice!.techTokens!.axlotl.owner, f.tleilaxu);
|
||||
assert.equal(f.winnerCards.lastBattleContext!.sukRescue!.completed, true);
|
||||
const ownPayment = quote.payments.find(p => p.player === f.winner)?.ownPayment ?? 0;
|
||||
assert.equal(player(f.faceDance, f.winner).spice, player(f.revealed, f.winner).spice - ownPayment + quote.bounty!.amount);
|
||||
assert.ok(player(f.faceDance, f.winner).hand.some(c => c.id === f.weapon));
|
||||
assert.ok(!player(f.faceDance, f.winner).hand.some(c => c.id === f.defense));
|
||||
assert.ok(f.faceDance.discard.some(c => c.id === f.defense));
|
||||
assert.ok(f.faceDance.discard.some(c => c.id === f.loserCard));
|
||||
assert.equal(player(f.faceDance, f.tleilaxu).leaders.find(l => l.id === f.loserLeader)!.dead, true);
|
||||
assert.equal(player(f.faceDance, f.tleilaxu).tanks, player(f.revealed, f.tleilaxu).tanks + 8);
|
||||
assert.equal(f.faceDance.decision!.kind, 'faceDance');
|
||||
assert.equal(f.faceDance.decision!.player, f.tleilaxu);
|
||||
assert.equal(f.faceDance.techTokens!.axlotl.owner, f.winner);
|
||||
const pending = f.skillChoice!.pendingSukRescue!, decision = f.skillChoice!.decision!;
|
||||
assert.equal(decision.kind, 'sukRescue');
|
||||
if (decision.kind !== 'sukRescue') throw new Error('Missing original Suk choice.');
|
||||
const selected = decision.options.reduce((best, option) => option.normal + option.elite > best.normal + best.elite ? option : best);
|
||||
const rescued = quoteSukRescue(pending.skill, pending.pool, pending.losses!, selected, !!pending.eliteOrigins);
|
||||
assert.equal(player(f.faceDance, f.winner).tanks, player(f.revealed, f.winner).tanks + rescued.tanks.normal + rescued.tanks.elite);
|
||||
assert.equal(at(f.faceDance, f.winner, f.territory), 8 - rescued.removed.reduce((sum, group) => sum + group.normal + group.elite, 0));
|
||||
retainedReward(f, applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction));
|
||||
}
|
||||
});
|
||||
|
||||
void test('original replacement controls permit zero, partial, board-only and mixed real-source forces without another reward', () => {
|
||||
const f = createTleilaxuSkillsTechFixture();
|
||||
const maximum = at(f.faceDance, f.winner, f.territory);
|
||||
const choices: Record<string, number>[] = [{}, { reserves: 1 }, { [f.boardSource]: 2 }, { reserves: maximum - 2, [f.boardSource]: 2 }];
|
||||
for (const sources of choices) {
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, { type: 'decision', reveal: true, sources, sector: f.sector });
|
||||
retainedReward(f, after);
|
||||
const total = Object.values(sources).reduce((sum, count) => sum + count, 0);
|
||||
assert.equal(at(after, f.tleilaxu, f.territory), total);
|
||||
assert.equal(player(after, f.tleilaxu).reserves, player(f.faceDance, f.tleilaxu).reserves - (sources.reserves ?? 0));
|
||||
assert.equal(player(after, f.tleilaxu).forces[f.boardSource] ?? 0, 3 - (sources[f.boardSource] ?? 0));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Face Dance cap and physical source boundaries reject without consuming the matching card or genuine award', () => {
|
||||
const f = createTleilaxuSkillsTechFixture(), before = structuredClone(f.faceDance);
|
||||
const maximum = at(f.faceDance, f.winner, f.territory);
|
||||
for (const sources of [{ reserves: maximum + 1 }, { [f.boardSource]: 4 }, { reserves: -1 }, { reserves: 0.5 }]) {
|
||||
assert.throws(() => applyAction(f.faceDance, f.tleilaxu, { type: 'decision', reveal: true, sources, sector: f.sector }));
|
||||
assert.deepEqual(f.faceDance, before);
|
||||
}
|
||||
assert.throws(() => applyAction(f.faceDance, f.winner, f.faceDanceAction));
|
||||
assert.deepEqual(f.faceDance, before);
|
||||
});
|
||||
|
||||
void test('declining replacement retains real Tech, native trainer and original completed card outcome', () => {
|
||||
const f = createTleilaxuSkillsTechFixture();
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, { type: 'decision', reveal: false });
|
||||
assert.deepEqual(after.techTokens, f.faceDance.techTokens);
|
||||
assert.equal(after.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(at(after, f.winner, f.territory), at(f.faceDance, f.winner, f.territory));
|
||||
assert.equal(player(after, f.winner).leaders.find(l => l.id === f.winnerLeader)!.dead, false);
|
||||
assert.ok(after.leaderSkills!.assignments.some(a => a.leader === f.winnerLeader && a.skill === f.skill));
|
||||
assert.equal(player(after, f.tleilaxu).faceDancers!.find(c => c.leader === f.winnerLeader)!.revealed, false);
|
||||
assert.deepEqual(player(after, f.winner).hand, player(f.faceDance, f.winner).hand);
|
||||
custody(after);
|
||||
});
|
||||
|
||||
void test('already-killed native trainer returns its skill in the original battle; matching Face Dance gives no second death or bounty', () => {
|
||||
const f = createTleilaxuSkillsTechFixture({ winnerDies: true });
|
||||
const quote = quoteSpiceBankerIncomeBattleEconomics(f.revealed, f.faceDance).resolution;
|
||||
assert.equal(quote.winner, f.winner);
|
||||
assert.equal(f.skillChoice, null, 'A dead selected Suk trainer has no rescue entitlement.');
|
||||
const before = player(f.faceDance, f.winner).leaders.find(l => l.id === f.winnerLeader)!;
|
||||
assert.equal(before.dead, true); assert.equal(before.deaths, 1);
|
||||
assert.ok(!f.faceDance.leaderSkills!.assignments.some(a => a.leader === f.winnerLeader));
|
||||
assert.equal(f.faceDance.leaderSkills!.deck.filter(skill => skill === f.skill).length, 1);
|
||||
retainedReward(f, applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction));
|
||||
});
|
||||
|
||||
void test('native winner Rihani completes before card cleanup and Tech; replacement kills its trainer without rewriting exchange history', () => {
|
||||
const f = createTleilaxuSkillsTechFixture({ skill: 'rihani-decipherer' });
|
||||
assert.equal(f.skillChoice!.decision!.kind, 'rihani');
|
||||
assert.equal(f.skillChoice!.techTokens!.axlotl.owner, f.tleilaxu);
|
||||
assert.equal(f.winnerCards.lastBattleContext!.rihani!.completed, true);
|
||||
assert.equal(f.faceDance.lastBattleContext!.rihani!.faceDanceStarted, true);
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction);
|
||||
retainedReward(f, after);
|
||||
assert.deepEqual(after.rihaniHistory, f.faceDance.rihaniHistory);
|
||||
});
|
||||
|
||||
void test('four original native policies replace the actual winner using legal sources after Axlotl was awarded', () => {
|
||||
const f = createTleilaxuSkillsTechFixture();
|
||||
for (const level of DIFFICULTIES) {
|
||||
const view = viewGame(f.faceDance, f.tleilaxu);
|
||||
view.players.find(p => p.id === f.tleilaxu)!.bot = level;
|
||||
const action = botActions(view).find(a => a.type === 'decision');
|
||||
assert.ok(action); assert.equal(action.reveal, true);
|
||||
const count = Object.values(action.sources as Record<string, number>).reduce((sum, n) => sum + n, 0);
|
||||
assert.equal(count, at(f.faceDance, f.winner, f.territory));
|
||||
const after = applyAction(f.faceDance, f.tleilaxu, action);
|
||||
retainedReward(f, after);
|
||||
assert.equal(at(after, f.tleilaxu, f.territory), count);
|
||||
}
|
||||
});
|
||||
|
||||
void test('only-Tleilaxu real free revival excludes Axlotl; another faction real free revival accrues and pays exactly its owned-token count', () => {
|
||||
for (const anotherFaction of [false, true]) {
|
||||
const f = createTleilaxuSkillsTechRevivalFixture();
|
||||
let game = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.ownFreeAction));
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 0);
|
||||
assert.equal(player(game, f.tleilaxu).freeForcesRevived, 1);
|
||||
assert.deepEqual(game.techTokens, f.game.techTokens, 'The native Tleilaxu-only free revival is not an Axlotl trigger.');
|
||||
if (anotherFaction) {
|
||||
game = settleTleilaxuSkillsTechRevival(applyAction(game, f.other, f.otherFreeAction));
|
||||
assert.equal(player(game, f.other).tanks, 0);
|
||||
assert.equal(player(game, f.other).freeForcesRevived, 1);
|
||||
assert.equal(game.techTokens!.axlotl.triggeredTurn, game.turn);
|
||||
assert.equal(game.techTokens!.axlotl.spice, ownedTech(game.techTokens, f.tleilaxu).length);
|
||||
}
|
||||
const before = player(game, f.tleilaxu).spice;
|
||||
const paid = advanceTleilaxuSkillsTechToPhase(game, 5);
|
||||
assert.equal(player(paid, f.tleilaxu).spice - before, anotherFaction ? ownedTech(game.techTokens, f.tleilaxu).length : 0);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, 0);
|
||||
custody(paid);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real native foreign Ghola revival grants no new Tleilaxu skill and preserves its living native trainer', () => {
|
||||
const f = createTleilaxuSkillsTechRevivalFixture();
|
||||
const assignments = structuredClone(f.game.leaderSkills!.assignments);
|
||||
const after = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.foreignGholaAction));
|
||||
const foreign = after.players.flatMap(p => p.leaders).find(l => l.id === f.foreignLeader)!;
|
||||
assert.equal(foreign.dead, false); assert.equal(foreign.gholaBy, f.tleilaxu);
|
||||
assert.deepEqual(after.leaderSkills!.assignments, assignments);
|
||||
assert.equal(after.leaderSkills!.offers[f.tleilaxu], undefined);
|
||||
assert.equal(player(after, f.tleilaxu).leaders.find(l => l.id === f.nativeTrainer)!.dead, false);
|
||||
assert.ok(!after.leaderSkills!.assignments.some(a => a.leader === f.foreignLeader));
|
||||
assert.deepEqual(after.techTokens, f.game.techTokens, 'Foreign leader revival is not force free-revival Tech income.');
|
||||
custody(after);
|
||||
});
|
||||
|
||||
void test('authenticated undealt lobby continuation retains exact original IDs/order without mutating or replacing seats', () => {
|
||||
const original = createTleilaxuSkillsTechFixture().initial;
|
||||
const before = structuredClone(original);
|
||||
const f = createTleilaxuSkillsTechFixture({ initial: original });
|
||||
assert.deepEqual(original, before);
|
||||
assert.deepEqual(f.initial, before);
|
||||
assert.deepEqual(f.afterSetup.players.map(p => ({ id: p.id, faction: p.faction })),
|
||||
original.players.map(p => ({ id: p.id, faction: p.faction })));
|
||||
assert.equal(f.winner, original.players.find(p => p.faction === 'guild')!.id);
|
||||
assert.equal(f.tleilaxu, original.players.find(p => p.faction === 'tleilaxu')!.id);
|
||||
assert.equal(f.faceDance.decision!.player, f.tleilaxu);
|
||||
retainedReward(f, applyAction(f.faceDance, f.tleilaxu, f.faceDanceAction));
|
||||
});
|
||||
Reference in new issue
Block a user