Files
Dune/tests/skills-tech-battle-runtime.test.ts
T

170 lines
11 KiB
TypeScript

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), []);
}
});