216 lines
14 KiB
TypeScript
216 lines
14 KiB
TypeScript
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 { awaitSpiceBankerIncomeRevivalActor, 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 = awaitSpiceBankerIncomeRevivalActor(f.game, f.tleilaxu);
|
|
assert.equal(game.revivalOrder!.remaining[0], f.tleilaxu);
|
|
assert.ok(game.revivalOrder!.remaining.includes(f.other));
|
|
game = settleTleilaxuSkillsTechRevival(applyAction(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 = awaitSpiceBankerIncomeRevivalActor(game, f.other);
|
|
assert.equal(game.revivalOrder!.remaining[0], f.other);
|
|
assert.ok(game.revivalOrder!.completed.includes(f.tleilaxu));
|
|
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 current = awaitSpiceBankerIncomeRevivalActor(f.game, f.tleilaxu);
|
|
assert.equal(current.revivalOrder!.remaining[0], f.tleilaxu);
|
|
const after = settleTleilaxuSkillsTechRevival(applyAction(current, 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));
|
|
});
|