Compose classic Leader Skills with original Tech Tokens

This commit is contained in:
Quintine committed 2026-10-04 06:01:53 +11:00
1 parent 21d71e8282
commit b71f953635
16 files changed
+1059 -22

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { TERRITORIES } from '../game/board';
import type { Card } from '../game/cards';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type SkillsTechBattleOptions = {
/** A fresh authenticated, undealt classic lobby; its seat IDs are retained. */
initial?: Game;
advanced?: boolean;
skill?: 'suk-graduate' | 'sandmaster';
band?: 'normal' | 'skilled';
/** Selection/absence are earned by a real preceding battle, never staged ownership. */
reward?: 'automatic' | 'selection' | 'absent';
};
export type SkillsTechBattleFixture = {
game: Game;
owner: string;
opponent: string;
trainer: string;
territory: string;
key: string;
weapon: string;
defense: string;
losingCard: string;
losingLeader: string;
ownerPlan: Action;
opponentPlan: Action;
band: 'normal' | 'skilled';
skill: 'suk-graduate' | 'sandmaster';
reward: 'automatic' | 'selection' | 'absent';
};
export function skillsTechBattlePlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
}
function withSkillOffer<T>(skill: SkillsTechBattleFixture['skill'], slot: number, initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === skill);
let index = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index <= 0) return Reflect.apply(original, crypto, [array]) as V;
assert.ok(array instanceof Uint32Array && array.length === 1);
const swap = index === Math.max(target, slot) ? Math.min(target, slot) : index;
array[0] = swap === index ? 0xffffffff : Math.ceil(swap * 0x100000000 / (index + 1));
index--;
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Native policy, with deliberate no-call/no-spend decisions at original windows. */
export function nextSkillsTechBattleStep(game: Game): { actor: string; action: Action } {
if (game.decision?.kind === 'battleCards')
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'techToken')
return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } };
if (!game.decision && !game.response && game.battle?.preLeader?.closed === false) {
const battle = game.battle;
const actor = battle.preLeader!.ready.includes(battle.attacker) ? battle.defender : battle.attacker;
return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader!.event } };
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return next;
}
function step(game: Game): Game {
const next = nextSkillsTechBattleStep(game);
return applyAction(game, next.actor, next.action);
}
function reachPhase(state: Game, target: number): Game {
let game = state;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision) && i < 1000; i++) {
assert.equal(game.turn, 1);
game = step(game);
}
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
function untrained(game: Game, player: Player, strongest: boolean) {
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const leader = player.leaders.filter(l => !trained.has(l.id) && !l.dead && !l.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
assert.ok(leader);
return leader;
}
function held(game: Game, player: Player, predicate: (card: Card) => boolean): string {
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0, 'Conserved physical card must remain in the actual deck.');
const card = game.deck.splice(index, 1)[0];
player.hand.push(card);
return card.id;
}
/** Fresh original setup/first Storm/Tech assignment. Only the initial skill offer,
* unplayed Spice Card order and conserved battle board/cards are controlled.
* Wallets, phases, turn, plans, receipts and Tech owners are never fabricated.
* The optional preliminary battle earns a second token or leaves no token.
* Its second, already-present conflict keeps the genuine Battle phase open. */
export function createSkillsTechBattleFixture(options: SkillsTechBattleOptions = {}): SkillsTechBattleFixture {
const skill = options.skill ?? 'suk-graduate';
const band = options.band ?? 'skilled';
const reward = options.reward ?? 'automatic';
assert.ok(skill !== 'sandmaster' || band === 'skilled', 'Sandmaster victory uses its lower band.');
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
game = createGame('SKILLSTECHBATTLE', newPlayer('guild', 'Guild', 'guild'), options.advanced ?? false);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
}
assert.equal(game.status, 'lobby');
assert.ok(game.players.length >= 3 && game.players.length <= 6);
assert.equal(game.expansions.length, 0);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const owner = game.players.find(p => p.faction === 'guild');
const opponent = game.players.find(p => p.faction === 'emperor');
assert.ok(owner && opponent, 'Authenticated lobby needs Guild and Emperor seats.');
if (!game.techTokens) game.techTokens = createTechTokens();
assert.deepEqual(game.techTokens, createTechTokens());
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
game = withSkillOffer(skill, game.players.findIndex(p => p.id === owner.id) * 2,
() => initializeLeaderSkillsGameForAudit(game));
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 selected = actor === owner.id ? skill : offer.cards.find(c => !view.unavailableSkills?.[c]);
assert.ok(selected && offer.cards.includes(selected) && !view.unavailableSkills?.[selected]);
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = skillsTechBattlePlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: selected, leader: leader.id });
} else game = step(game);
}
assert.equal(game.status, 'playing');
// Arrange two real, unplayed non-worm blows; the first supplies the battle pile.
for (const [position, territory] of [[0, 'hagga_basin'], [1, 'funeral_plain']] as const) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && card.territory === territory);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = reachPhase(game, 5);
const winner = skillsTechBattlePlayer(game, owner.id);
const enemy = skillsTechBattlePlayer(game, opponent.id);
const territory = 'hagga_basin';
const key = `${territory}:12`;
assert.ok(game.spice[key] > 0 && !TERRITORIES.find(t => t.id === territory)!.sectors.includes(game.storm));
// Return every board/card counter to its original physical reserve/deck.
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 p of [winner, enemy]) {
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= 10);
p.reserves -= 8; p.forces[key] = 8;
}
let supportTerritory: string | undefined;
if (reward !== 'automatic') {
const desert = TERRITORIES.find(t => t.type === 'sand' && t.id !== territory && !t.sectors.includes(game.storm));
assert.ok(desert);
supportTerritory = desert.id;
for (const p of [winner, enemy]) { p.reserves -= 2; p.forces[`${desert.id}:${desert.sectors[0]}`] = 2; }
}
game = reachPhase(game, 6);
if (supportTerritory) {
game = openSkillsTechBattle(game, winner.id, enemy.id, supportTerritory, true);
const firstWinner = skillsTechBattlePlayer(game, reward === 'selection' ? enemy.id : winner.id);
const firstLoser = skillsTechBattlePlayer(game, reward === 'selection' ? winner.id : enemy.id);
const winLeader = untrained(game, firstWinner, true);
const loseLeader = untrained(game, firstLoser, false);
assert.ok(winLeader.strength > loseLeader.strength);
game = applyAction(game, firstWinner.id, { type: 'battlePlan', leader: winLeader.id, dial: 0, support: 0 });
game = applyAction(game, firstLoser.id, { type: 'battlePlan', leader: loseLeader.id, dial: 0, support: 0 });
game = finishSkillsTechBattle(game, 'complete');
assert.equal(game.phase, 6);
assert.equal(game.lastBattleContext!.winner, firstWinner.id);
assert.equal(ownedTech(game.techTokens, enemy.id).length, reward === 'selection' ? 2 : 0);
}
const trainer = game.leaderSkills!.assignments.find(a => a.owner === winner.id && a.skill === skill)!.leader;
const selectedLeader = band === 'skilled' ? trainer : untrained(game, skillsTechBattlePlayer(game, winner.id), true).id;
const losingLeader = untrained(game, skillsTechBattlePlayer(game, enemy.id), false).id;
const weapon = held(game, skillsTechBattlePlayer(game, winner.id), card => card.kind === 'projectile');
const defense = held(game, skillsTechBattlePlayer(game, winner.id), card => card.kind === 'shield');
const losingCard = held(game, skillsTechBattlePlayer(game, enemy.id), card => card.kind === 'worthless');
return {
game, owner: winner.id, opponent: enemy.id, trainer, territory, key, weapon, defense, losingCard, losingLeader,
ownerPlan: { type: 'battlePlan', leader: selectedLeader, dial: 4, support: game.advanced ? 4 : 0, weapon, defense },
opponentPlan: { type: 'battlePlan', leader: losingLeader, dial: 0, support: 0, weapon: losingCard },
skill, band, reward,
};
}
/** Stop at actual private plan controls after original visibility/preparation. */
export function openSkillsTechBattle(state: Game, owner: string, opponent: string, territory: string, hide = true): Game {
assert.ok(state.active === owner || state.active === opponent);
let game = applyAction(state, state.active!, { type: 'chooseBattle', territory, target: state.active === owner ? opponent : owner });
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
if (game.decision?.kind === 'leaderSkillVisibility')
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: game.decision.player === owner ? hide : true });
else game = step(game);
}
assert.ok(game.battle && !game.response && !game.decision);
return game;
}
/** Resolve calls/mandatory cleanup, stopping BEFORE the requested native choice.
* complete means this battle's reward is complete, not that the game is over. */
export function finishSkillsTechBattle(state: Game, stop: 'skill' | 'cleanup' | 'tech' | 'complete' = 'complete'): Game {
let game = state;
for (let i = 0; i < 200; i++) {
if (stop === 'skill' && game.decision?.kind === 'sukRescue') return game;
if ((stop === 'skill' || stop === 'cleanup') && game.decision?.kind === 'battleCards') return game;
if (stop === 'tech' && game.decision?.kind === 'techToken') return game;
if (!game.battle && !game.response && !game.decision && !game.pendingTreacheryDiscard) return game;
if (game.decision?.kind === 'sukRescue') {
const decision = game.decision;
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum);
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, choice });
} else game = step(game);
}
throw new Error('Native skills/Tech battle aftermath did not settle.');
}
+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { forceRevivalQuote } from '../game/revival';
import type { TechId } from '../game/tech-tokens';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type SkillsTechPaymentsOptions = {
/** Fresh authenticated undealt classic lobby; original seat IDs/order survive. */
initial?: Game;
advanced?: boolean;
kind?: 'force-revival' | 'shipment' | 'guild-shipment';
withGuild?: boolean;
/** Real counters revived or shipped, including the free Smuggler counter. */
amount?: number;
/** Ordinary full price when false; the default shipment explicitly opts in. */
smuggler?: boolean;
};
export type SkillsTechPaymentsStep = { actor: string; action: Action };
export type SkillsTechPaymentsFixture = {
game: Game;
beforePayment: Game;
setup: Game;
actions: SkillsTechPaymentsStep[];
actor: string;
payer: string;
owner: string;
smugglerOwner: string;
paymentAction: Action;
amount: number;
cost: number;
token: TechId;
tokenOwner: string;
destination?: string;
};
/** Control only the initial all14 shuffle so native two-card offers contain
* Banker/Smuggler. All later skill, card, Traitor and token shuffles stay native. */
function withPaymentSkillOffers<T>(game: Game, initialize: () => T): T {
const cards: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
const selected: LeaderSkillId[] = game.players.map(player => player.faction === 'atreides' ? 'spice-banker'
: player.faction === 'harkonnen' ? 'smuggler' : player.faction === 'emperor' ? 'warmaster'
: player.faction === 'guild' ? 'swordmaster-of-ginaz' : player.faction === 'fremen' ? 'killer-medic'
: 'prana-bindu-adept');
// Fill each original two-card offer without introducing another payment or
// optional question ability into the continuation policy.
const desired: LeaderSkillId[] = [];
const remaining = cards.filter(card => !selected.includes(card));
for (const skill of selected) desired.push(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);
type RandomInput = Parameters<typeof original>[0];
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 original(array as RandomInput);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function nextSkillsTechPaymentsNativeStep(game: Game): SkillsTechPaymentsStep | null {
return nextSpiceBankerIncomeNativeStep(game);
}
function step(game: Game, actions?: SkillsTechPaymentsStep[]): Game {
const next = nextSkillsTechPaymentsNativeStep(game);
assert.ok(next);
actions?.push(next);
return applyAction(game, next.actor, next.action);
}
export function initializeSkillsTechPaymentsSetup(options: SkillsTechPaymentsOptions = {}): Game {
let game = options.initial ? structuredClone(options.initial)
: createGame('SKILLTECH', newPlayer('banker', 'Banker', 'atreides'), options.advanced ?? false);
assert.equal(game.status, 'lobby', 'Continue only a fresh undealt authenticated lobby.');
assert.equal(game.expansions.length, 0);
if (!options.initial) {
joinGame(game, newPlayer('smuggler', 'Smuggler', 'harkonnen'));
joinGame(game, newPlayer('payer', 'Payer', 'emperor'));
if (options.withGuild || options.kind === 'guild-shipment') joinGame(game, newPlayer('guild', 'Guild', 'guild'));
}
assert.ok(game.players.some(player => player.faction === 'atreides'));
assert.ok(game.players.some(player => player.faction === 'harkonnen'));
assert.ok(game.players.some(player => player.faction === 'emperor'));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
if (options.withGuild || options.kind === 'guild-shipment') assert.ok(game.players.some(player => player.faction === 'guild'));
// The normal host lobby control creates canonical UNUSED tokens. Original
// setup/first Storm alone assign ownership; never retrofit a played game.
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withPaymentSkillOffers(game, () => initializeSpiceBankerIncomeGameForAudit(game));
}
export function completeSkillsTechPaymentsSetup(state: Game, actions?: SkillsTechPaymentsStep[]): 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 player = game.players.find(seat => seat.id === actor)!;
const view = viewGame(game, actor).leaderSkills!;
const skill = player.faction === 'atreides' ? 'spice-banker'
: player.faction === 'harkonnen' ? 'smuggler' : offer.cards[0];
assert.ok(offer.cards.includes(skill));
assert.ok(view.eligibleLeaders[0]);
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id };
actions?.push({ actor, action });
game = applyAction(game, actor, action);
} else game = step(game, actions);
}
assert.equal(game.status, 'playing');
return game;
}
/** First clean native boundary at/after target; empty Battle may auto-skip. Never assign turn or phase. */
export function advanceSkillsTechPaymentsToPhase(state: Game, target: number, actions?: SkillsTechPaymentsStep[]): Game {
let game = state;
const turn = game.turn;
for (let i = 0; (game.phase < target || game.phaseOpening || game.response || game.decision)
&& game.status === 'playing' && game.turn === turn && i < 1000; i++) game = step(game, actions);
assert.equal(game.turn, turn);
assert.ok(game.phase >= target);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
/** Empty native stronghold avoids incidental sand spice collection and combat;
* no board occupancy or token ownership is staged for shipment. */
export function skillsTechPaymentsEmptyDestination(game: Game): { territory: string; sector: number } {
const destination = TERRITORIES.find(territory => territory.type === 'stronghold'
&& !territory.sectors.includes(game.storm)
&& game.players.every(player => Object.entries(player.forces)
.every(([key, amount]) => !amount || !key.startsWith(`${territory.id}:`))));
assert.ok(destination);
return { territory: destination.id, sector: destination.sectors[0] };
}
/** Stops BEFORE the native payment/companion action, preserving the original
* available wallets and IDs for an authenticated human payment smoke. The only
* controlled position is labelled conserved Emperor reserve->Tanks casualties. */
export function createSkillsTechPaymentsFixture(options: SkillsTechPaymentsOptions = {}): SkillsTechPaymentsFixture {
const actions: SkillsTechPaymentsStep[] = [];
const setup = initializeSkillsTechPaymentsSetup(options);
let game = completeSkillsTechPaymentsSetup(setup, actions);
const kind = options.kind ?? 'force-revival';
const owner = game.players.find(player => player.faction === 'atreides')!.id;
const smugglerOwner = game.players.find(player => player.faction === 'harkonnen')!.id;
const actor = kind === 'force-revival' ? game.players.find(player => player.faction === 'emperor')!.id
: kind === 'guild-shipment' ? game.players.find(player => player.faction === 'guild')!.id : smugglerOwner;
game = advanceSkillsTechPaymentsToPhase(game, kind === 'force-revival' ? 4 : 5, actions);
let amount: number;
let cost: number;
let paymentAction: Action;
let destination: string | undefined;
if (kind === 'force-revival') {
amount = options.amount ?? 3;
const payer = game.players.find(player => player.id === actor)!;
assert.ok(payer.reserves >= amount);
// Explicit conserved casualty unit position, not fabricated native money.
payer.reserves -= amount;
payer.tanks += amount;
cost = forceRevivalQuote(game, payer, amount).cost;
paymentAction = { type: 'revive', amount };
} else {
for (let i = 0; game.active !== actor && i < 20; i++) game = step(game, actions);
assert.equal(game.active, actor);
const target = skillsTechPaymentsEmptyDestination(game);
destination = `${target.territory}:${target.sector}`;
amount = options.amount ?? (kind === 'guild-shipment' ? 8 : 5);
const smuggler = kind === 'shipment' && options.smuggler !== false;
cost = kind === 'guild-shipment' ? Math.ceil(amount / 2) : amount - (smuggler ? 1 : 0);
paymentAction = { type: 'ship', ...target, amount, ...(smuggler ? { smuggler: true } : {}) };
}
const token = kind === 'force-revival' ? 'axlotl' : 'heighliners';
const tokenOwner = game.techTokens![token].owner;
assert.ok(tokenOwner, 'Ownership must have been assigned by the original first Storm.');
return { game, beforePayment: structuredClone(game), setup, actions, actor, payer: actor,
owner, smugglerOwner, paymentAction, amount, cost, token, tokenOwner, destination };
}
export function paySkillsTechPaymentsFixture(fixture: SkillsTechPaymentsFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; (game.response || game.decision || game.pendingRevival || game.pendingShipment) && i < 200; i++) game = step(game);
assert.ok(!game.response && !game.decision && !game.pendingRevival && !game.pendingShipment);
return game;
}
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import {
createSkillsTechBattleFixture, finishSkillsTechBattle, openSkillsTechBattle,
skillsTechBattlePlayer, type SkillsTechBattleFixture,
} from './fixture-skills-tech-battle';
function reveal(fixture: SkillsTechBattleFixture): Game {
let game = openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory, fixture.band === 'skilled');
game = applyAction(game, fixture.owner, fixture.ownerPlan);
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
return finishSkillsTechBattle(game, 'skill');
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function conserveForces(game: Game): void {
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 5);
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
}
}
}
function tokenChanges(before: Game, after: Game): string[] {
return TECH_TOKENS.filter(token => before.techTokens![token.id].owner !== after.techTokens![token.id].owner).map(token => token.id);
}
function assertLoserCleanup(game: Game, fixture: SkillsTechBattleFixture): void {
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.losingCard), false);
assert.equal(game.discard.filter(card => card.id === fixture.losingCard).length, 1);
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).leaders.find(l => l.id === fixture.losingLeader)!.dead, true);
}
for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${band} Suk physically rescues before cleanup and one original automatic Tech reward`, () => {
const fixture = createSkillsTechBattleFixture({ advanced, band });
const before = fixture.game;
const ownerBefore = skillsTechBattlePlayer(before, fixture.owner);
const enemyBefore = skillsTechBattlePlayer(before, fixture.opponent);
const bounty = enemyBefore.leaders.find(l => l.id === fixture.losingLeader)!.strength;
let game = reveal(fixture);
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assertLoserCleanup(game, fixture);
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).tanks, enemyBefore.tanks + 8);
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).forces[fixture.key] ?? 0, 0);
assert.deepEqual(tokenChanges(before, game), [], 'Rescue/played-card cleanup precede token reward.');
assert.equal(skillsTechBattlePlayer(game, fixture.owner).spice, ownerBefore.spice - (advanced ? 4 : 0) + bounty);
if (band === 'skilled') {
const decision = game.decision;
assert.ok(decision?.kind === 'sukRescue');
assert.equal(decision.mode, 'skilled');
assert.equal(skillsTechBattlePlayer(game, fixture.owner).forces[fixture.key], 8);
assert.equal(skillsTechBattlePlayer(game, fixture.owner).tanks, ownerBefore.tanks);
const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key);
assert.ok(choice >= 0);
reject(game, fixture.opponent, { type: 'decision', event: decision.event, choice });
reject(game, fixture.owner, { type: 'decision', event: `${decision.event}-stale`, choice });
const action: Action = { type: 'decision', event: decision.event, choice };
game = applyAction(game, fixture.owner, action);
reject(game, fixture.owner, action);
}
const rescued = skillsTechBattlePlayer(game, fixture.owner);
assert.equal(rescued.forces[fixture.key], band === 'skilled' ? 5 : 4);
assert.equal(rescued.reserves, ownerBefore.reserves + (band === 'skilled' ? 2 : 1));
assert.equal(rescued.tanks, ownerBefore.tanks + (band === 'skilled' ? 1 : 3));
assert.equal(rescued.battleLosses, ownerBefore.battleLosses + (band === 'skilled' ? 1 : 3));
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.decision?.kind, 'battleCards');
const walletAfterReward = rescued.spice;
const collected = quoteSpiceCollection(game).receipts.find(receipt => receipt.player === fixture.owner)!.collected;
const token = ownedTech(before.techTokens, fixture.opponent)[0];
assert.ok(token);
game = finishSkillsTechBattle(game);
assert.deepEqual(tokenChanges(before, game), [token]);
assert.equal(game.techTokens![token].owner, fixture.owner);
assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0);
assert.equal(game.pendingTech, null);
assert.equal(skillsTechBattlePlayer(game, fixture.owner).spice, walletAfterReward + collected,
'Ordinary leader bounty remains; subsequent Collection is independently quoted.');
assert.deepEqual(skillsTechBattlePlayer(game, fixture.owner).hand.map(card => card.id).sort(), [fixture.weapon, fixture.defense].sort());
assertLoserCleanup(game, fixture);
conserveForces(game);
});
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Sandmaster adds exactly three board spice before choosing one of two genuinely won Tech Tokens`, () => {
const fixture = createSkillsTechBattleFixture({ advanced, skill: 'sandmaster', reward: 'selection' });
const before = fixture.game;
const originalPile = before.spice[fixture.key];
const ownerBefore = skillsTechBattlePlayer(before, fixture.owner);
const enemyBefore = skillsTechBattlePlayer(before, fixture.opponent);
const bounty = enemyBefore.leaders.find(l => l.id === fixture.losingLeader)!.strength;
let game = reveal(fixture);
assertLoserCleanup(game, fixture);
assert.equal(game.spice[fixture.key], originalPile + 3);
assert.deepEqual(game.lastBattleContext!.sandmaster, { leader: fixture.trainer, key: fixture.key, before: originalPile, after: originalPile + 3 });
assert.equal(skillsTechBattlePlayer(game, fixture.owner).spice, ownerBefore.spice - (advanced ? 4 : 0) + bounty,
'Sandmaster deposits board spice, not immediate faction income.');
assert.equal(skillsTechBattlePlayer(game, fixture.owner).forces[fixture.key], 4);
assert.equal(skillsTechBattlePlayer(game, fixture.owner).tanks, ownerBefore.tanks + 4);
assert.equal(skillsTechBattlePlayer(game, fixture.opponent).tanks, enemyBefore.tanks + 8);
assert.deepEqual(tokenChanges(before, game), []);
game = finishSkillsTechBattle(game, 'tech');
const decision = game.decision;
assert.ok(decision?.kind === 'techToken');
assert.equal(decision.choices.length, 2);
assert.deepEqual(new Set(decision.choices), new Set(ownedTech(before.techTokens, fixture.opponent)));
const other = TECH_TOKENS.find(token => !decision.choices.includes(token.id))!;
reject(game, fixture.owner, { type: 'decision', token: other.id });
reject(game, fixture.opponent, { type: 'decision', token: decision.choices[0] });
// Each real legal policy chooses an owned token and transfers precisely one.
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, fixture.owner);
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'decision');
assert.ok(action && typeof action.token === 'string' && decision.choices.includes(action.token as typeof decision.choices[number]));
const wallet = skillsTechBattlePlayer(game, fixture.owner).spice;
const collected = quoteSpiceCollection(game).receipts.find(receipt => receipt.player === fixture.owner)!.collected;
const paid = finishSkillsTechBattle(applyAction(game, fixture.owner, action));
assert.deepEqual(tokenChanges(before, paid), [action.token]);
assert.equal(ownedTech(paid.techTokens, fixture.opponent).length, 1);
assert.equal(paid.pendingTech, null);
assert.equal(skillsTechBattlePlayer(paid, fixture.owner).spice, wallet + collected);
assert.equal(paid.lastBattleContext!.sandmaster!.after, originalPile + 3);
assertLoserCleanup(paid, fixture);
conserveForces(paid);
reject(paid, fixture.owner, action);
}
});
}
void test('a real preliminary loss leaves the later loser with no token, so native aftermath skips Tech and keeps the winner cards', () => {
const fixture = createSkillsTechBattleFixture({ advanced: true, reward: 'absent' });
assert.equal(ownedTech(fixture.game.techTokens, fixture.opponent).length, 0);
const game = finishSkillsTechBattle(reveal(fixture));
assert.deepEqual(tokenChanges(fixture.game, game), []);
assert.equal(game.pendingTech, null);
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assert.equal(skillsTechBattlePlayer(game, fixture.owner).forces[fixture.key], 5);
assertLoserCleanup(game, fixture);
assert.deepEqual(skillsTechBattlePlayer(game, fixture.owner).hand.map(card => card.id).sort(), [fixture.weapon, fixture.defense].sort());
conserveForces(game);
});
void test('Advanced own-seat legal policies cannot finance battle support with a future bounty, rescue or Tech reward', () => {
const fixture = createSkillsTechBattleFixture({ advanced: true });
const game = openSkillsTechBattle(fixture.game, fixture.owner, fixture.opponent, fixture.territory);
const wallet = skillsTechBattlePlayer(game, fixture.owner).spice;
reject(game, fixture.owner, { ...fixture.ownerPlan, support: wallet + 1 });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, fixture.owner);
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan');
assert.ok(plan);
assert.ok(Number(plan.support ?? 0) + Number(plan.bankerSpice ?? 0) <= wallet);
const committed = applyAction(game, fixture.owner, plan);
assert.equal(skillsTechBattlePlayer(committed, fixture.owner).spice, wallet);
assert.deepEqual(tokenChanges(game, committed), []);
}
});
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
advanceSkillsTechPaymentsToPhase, createSkillsTechPaymentsFixture,
nextSkillsTechPaymentsNativeStep, paySkillsTechPaymentsFixture,
skillsTechPaymentsEmptyDestination, type SkillsTechPaymentsFixture,
} from './fixture-skills-tech-payments';
function wallets(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
}
function assertPhysicalPayment(before: Game, after: Game): void {
const cards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort();
assert.equal(cards(before).length, 33);
assert.equal(new Set(cards(after)).size, 33);
assert.deepEqual(cards(after), cards(before), 'Payment must not create, lose or redeal a physical card.');
assert.deepEqual(after.leaderSkills, before.leaderSkills, 'Normal income does not move a skill card or training disc.');
for (const player of after.players) assert.equal(player.reserves + player.tanks
+ Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
}
function deferred(game: Game, owner: string): { owner: string; amount: number }[] {
return viewGame(game, owner).spiceBankerIncome!.deferred;
}
/** Native phase-end payout may also auto-skip empty Battle and collect the board.
* Account for that independent original quote; only Banker waits for Mentat. */
function assertSeparateCollections(fixture: SkillsTechPaymentsFixture, paid: Game, bankerAmount: number, techAmount: number): Game {
const before = wallets(paid);
const sources = structuredClone(paid.spiceBankerIncome!.sources);
const phaseEnd = advanceSkillsTechPaymentsToPhase(paid, paid.phase + 1);
const expected = phaseEnd.phase === 7
? Object.fromEntries(quoteSpiceCollection(paid).receipts.map(receipt => [receipt.player, receipt.balance]))
: { ...before };
expected[fixture.tokenOwner] += techAmount;
assert.deepEqual(wallets(phaseEnd), expected, 'Native Collection plus only the original token owner’s phase-end pile.');
assert.equal(phaseEnd.techTokens![fixture.token].spice, 0);
assert.equal(phaseEnd.techTokens![fixture.token].triggeredTurn, techAmount ? paid.turn : undefined);
assert.deepEqual(deferred(phaseEnd, fixture.owner), bankerAmount ? [{ owner: fixture.owner, amount: bankerAmount }] : []);
assert.deepEqual(phaseEnd.spiceBankerIncome!.sources, sources, 'A Tech credit is not a qualifying Banker payment.');
const collection = advanceSkillsTechPaymentsToPhase(phaseEnd, 7);
const collectionWallets = wallets(collection); // Includes original stronghold/desert Collection, not invented funding.
assert.deepEqual(deferred(collection, fixture.owner), bankerAmount ? [{ owner: fixture.owner, amount: bankerAmount }] : []);
const mentat = advanceSkillsTechPaymentsToPhase(collection, 8);
const expectedMentat = { ...collectionWallets };
expectedMentat[fixture.owner] += bankerAmount;
assert.deepEqual(wallets(mentat), expectedMentat);
assert.deepEqual(deferred(mentat, fixture.owner), []);
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources, 'Banker collection must not recursively earn Banker income.');
assert.equal(mentat.techTokens![fixture.token].spice, 0, 'Mentat income does not retrigger technology.');
const next = nextSkillsTechPaymentsNativeStep(mentat);
assert.ok(next);
const continued = applyAction(mentat, next.actor, next.action);
assert.equal(continued.phase, 8);
assert.deepEqual(wallets(continued), expectedMentat, 'Further real Mentat readiness cannot credit either pile twice.');
assert.deepEqual(continued.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections);
assertPhysicalPayment(fixture.beforePayment, continued);
return continued;
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`${band} Banker + Axlotl: actual four-spice revival delivers forces, keeps both earning stores unspendable, and collects each once at its own boundary`, () => {
const fixture = createSkillsTechPaymentsFixture({ advanced });
const before = fixture.beforePayment;
const payer = before.players.find(player => player.id === fixture.payer)!;
assert.equal(fixture.cost, 4);
assert.equal(before.techTokens!.axlotl.spice, 0);
const paid = paySkillsTechPaymentsFixture(fixture);
const revived = paid.players.find(player => player.id === payer.id)!;
assert.equal(revived.reserves, payer.reserves + 3);
assert.equal(revived.tanks, payer.tanks - 3);
assert.equal(revived.revived, payer.revived + 3);
assert.equal(revived.freeForcesRevived, 1);
const expected = wallets(before);
expected[payer.id] -= 4;
assert.deepEqual(wallets(paid), expected, 'Neither accrued pile is available money during Revival.');
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
assert.equal(paid.techTokens!.axlotl.spice, 1);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
assert.deepEqual(paid.spiceBankerIncome!.sources.filter(row => row.grant).map(row => row.source.bankLegs),
[[{ payer: fixture.payer, amount: 4 }]]);
// A second real free revival is another eligible Axlotl action, not another
// physical token award. Stage only one conserved Harkonnen reserve casualty.
const smuggler = paid.players.find(player => player.id === fixture.smugglerOwner)!;
smuggler.reserves -= 1;
smuggler.tanks += 1;
const repeat = applyAction(paid, smuggler.id, { type: 'revive', amount: 1 });
assert.equal(repeat.players.find(player => player.id === smuggler.id)!.reserves, smuggler.reserves + 1);
assert.equal(repeat.players.find(player => player.id === smuggler.id)!.tanks, smuggler.tanks - 1);
assert.deepEqual(wallets(repeat), expected);
assert.equal(repeat.techTokens!.axlotl.spice, 1);
assert.deepEqual(deferred(repeat, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
assertSeparateCollections(fixture, repeat, 1, 1);
});
for (const amount of [1, 2]) void test(`${band} Axlotl free-return trigger is independent of an actual ${amount === 1 ? 'zero' : 'two-spice nonqualifying'} Banker debit`, () => {
const fixture = createSkillsTechPaymentsFixture({ advanced, amount });
const payer = fixture.beforePayment.players.find(player => player.id === fixture.payer)!;
assert.equal(fixture.cost, amount === 1 ? 0 : 2);
const paid = paySkillsTechPaymentsFixture(fixture);
const revived = paid.players.find(player => player.id === payer.id)!;
assert.equal(revived.spice, payer.spice - fixture.cost);
assert.equal(revived.reserves, payer.reserves + amount);
assert.equal(revived.tanks, payer.tanks - amount);
assert.equal(paid.techTokens!.axlotl.spice, 1);
assert.deepEqual(deferred(paid, fixture.owner), []);
assert.equal(paid.spiceBankerIncome!.sources.some(row => row.grant), false);
assertSeparateCollections(fixture, paid, 0, 1);
});
void test(`${band} Smuggler + Banker + Heighliners: five physical counters cost four, two real bank invoices earn only one of each deferred award`, () => {
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment' });
const before = fixture.beforePayment;
const payer = before.players.find(player => player.id === fixture.payer)!;
assert.equal(fixture.cost, 4);
let paid = paySkillsTechPaymentsFixture(fixture);
const shipped = paid.players.find(player => player.id === payer.id)!;
assert.equal(shipped.reserves, payer.reserves - 5);
assert.equal(shipped.forces[fixture.destination!], 5);
assert.equal(shipped.spice, payer.spice - 4);
const expected = wallets(before);
expected[payer.id] -= 4;
assert.deepEqual(wallets(paid), expected);
assert.equal(paid.techTokens!.heighliners.spice, 1);
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
// Continue to Emperor using original endMovement; no phase or turn is staged.
const emperor = paid.players.find(player => player.faction === 'emperor')!;
for (let i = 0; paid.active !== emperor.id && i < 20; i++) {
const next = nextSkillsTechPaymentsNativeStep(paid);
assert.ok(next);
paid = applyAction(paid, next.actor, next.action);
}
assert.equal(paid.phase, 5);
assert.equal(paid.active, emperor.id);
const target = skillsTechPaymentsEmptyDestination(paid);
const secondBefore = paid.players.find(player => player.id === emperor.id)!;
paid = applyAction(paid, emperor.id, { type: 'ship', ...target, amount: 4 });
assert.equal(paid.players.find(player => player.id === emperor.id)!.spice, secondBefore.spice - 4);
assert.equal(paid.players.find(player => player.id === emperor.id)!.reserves, secondBefore.reserves - 4);
assert.equal(paid.players.find(player => player.id === emperor.id)!.forces[`${target.territory}:${target.sector}`], 4);
assert.equal(paid.techTokens!.heighliners.spice, 1);
assert.equal(paid.spiceBankerIncome!.sources.filter(row => row.grant).length, 1);
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
assertSeparateCollections(fixture, paid, 1, 1);
});
for (const smuggler of [true, false]) void test(`${band} Smuggler ${smuggler ? 'free companion drops' : 'decline retains'} the original four-counter invoice across Banker's four-spice threshold`, () => {
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment', amount: 4, smuggler });
const before = fixture.beforePayment.players.find(player => player.id === fixture.payer)!;
const paid = paySkillsTechPaymentsFixture(fixture);
const after = paid.players.find(player => player.id === fixture.payer)!;
assert.equal(after.reserves, before.reserves - 4);
assert.equal(after.forces[fixture.destination!], 4);
assert.equal(after.spice, before.spice - (smuggler ? 3 : 4));
assert.equal(paid.techTokens!.heighliners.spice, 1);
assert.deepEqual(deferred(paid, fixture.owner), smuggler ? [] : [{ owner: fixture.owner, amount: 1 }]);
assertSeparateCollections(fixture, paid, smuggler ? 0 : 1, 1);
});
void test(`${band} actual Smuggler fee goes to Guild, while Heighliners accrues separately and Banker gains nothing`, () => {
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment', withGuild: true });
const before = wallets(fixture.beforePayment);
const guild = fixture.beforePayment.players.find(player => player.faction === 'guild')!;
const paid = paySkillsTechPaymentsFixture(fixture);
const expected = { ...before };
expected[fixture.payer] -= 4;
expected[guild.id] += 4;
assert.deepEqual(wallets(paid), expected, 'The original Guild fee excludes both deferred earnings.');
assert.equal(paid.players.find(player => player.id === fixture.payer)!.forces[fixture.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 1);
assert.deepEqual(deferred(paid, fixture.owner), []);
assertSeparateCollections(fixture, paid, 0, 1);
});
void test(`${band} Guild's actual own-bank shipment earns Banker but is excluded from the original Heighliners trigger`, () => {
const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'guild-shipment' });
const before = fixture.beforePayment.players.find(player => player.id === fixture.payer)!;
const paid = paySkillsTechPaymentsFixture(fixture);
const after = paid.players.find(player => player.id === fixture.payer)!;
assert.equal(after.spice, before.spice - 4);
assert.equal(after.reserves, before.reserves - 8);
assert.equal(after.forces[fixture.destination!], 8);
assert.equal(paid.techTokens!.heighliners.spice, 0);
assert.equal(paid.techTokens!.heighliners.triggeredTurn, undefined);
assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]);
assertSeparateCollections(fixture, paid, 1, 0);
});
}
void test('fresh authenticated payment fixture retains original faction identities through setup, real Smuggler invoice and Mentat', () => {
const initial = createGame('AUTHTECH', newPlayer('human-owner', 'Human owner', 'emperor'), true);
joinGame(initial, newPlayer('human-companion', 'Human companion', 'harkonnen'));
joinGame(initial, newPlayer('human-banker', 'Human banker', 'atreides'));
const identities = initial.players.map(player => ({ id: player.id, name: player.name, faction: player.faction }));
const fixture = createSkillsTechPaymentsFixture({ initial, kind: 'shipment' });
assert.equal(fixture.payer, 'human-companion');
assert.equal(fixture.owner, 'human-banker');
const paid = paySkillsTechPaymentsFixture(fixture);
assert.equal(paid.players.find(player => player.id === fixture.payer)!.spice,
fixture.beforePayment.players.find(player => player.id === fixture.payer)!.spice - 4);
const mentat = assertSeparateCollections(fixture, paid, 1, 1);
assert.deepEqual(mentat.players.map(player => ({ id: player.id, name: player.name, faction: player.faction })), identities);
});