Files
Dune/tests/betrayal-sandmaster-route.test.ts
T

115 lines
7.1 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 { TERRITORIES, location, splitLocation } from '../game/board';
import { fighterCount } from '../game/advisors';
import {
quoteSandmasterMovement, sandmasterActionOrder, sandmasterDefaultChoice,
sandmasterEnteredTerritories, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import { createBetrayalSandmasterRoute, sandmasterTrainer } from './fixture-betrayal-sandmaster-route';
function unchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native Guild Betrayal Sandmaster debits its printed pile once and retains ordinary movement', () => {
const f = createBetrayalSandmasterRoute();
const before = sandmasterTrainer(f.game);
const assignment = f.game.leaderSkills!.assignments.find(skill => skill.owner === before.id)!;
assert.equal(assignment.skill, 'sandmaster');
assert.ok(before.leaders.some(leader => leader.id === assignment.leader && !leader.dead));
assert.ok(f.betrayalPasses > 0);
const quote = quoteSandmasterMovement(f.game, before.id, f.order, f.choice);
assert.equal(quote.leader, assignment.leader);
assert.deepEqual(quote.piles, [{ key: f.printedPile, before: f.printedAmount }]);
assert.deepEqual(sandmasterEnteredTerritories(quote.routes), [splitLocation(f.printedPile).territory]);
const ordinary = applyAction(f.game, before.id, f.ordinary);
const accepted = applyAction(JSON.parse(JSON.stringify(f.game)) as Game, before.id, f.action);
assert.equal(sandmasterTrainer(accepted).spice, before.spice + 1);
assert.deepEqual(accepted.spice, { ...f.game.spice, [f.printedPile]: f.printedAmount - 1 });
assert.deepEqual(sandmasterTrainer(accepted).forces, sandmasterTrainer(ordinary).forces);
assert.equal(sandmasterTrainer(accepted).forces[f.printedPile], 1);
assert.equal(sandmasterTrainer(accepted).reserves, before.reserves);
assert.equal(sandmasterTrainer(accepted).moved, before.moved + 1);
assert.equal(sandmasterTrainer(accepted).moved, sandmasterTrainer(ordinary).moved);
assert.equal(fighterCount(sandmasterTrainer(accepted), splitLocation(f.printedPile).territory), 1);
for (const player of f.game.players.filter(player => player.id !== before.id))
assert.equal(accepted.players.find(seat => seat.id === player.id)!.spice, player.spice);
unchanged(accepted, before.id, f.action);
const declined = applyAction(f.game, before.id, { ...f.ordinary, sandmaster: { ...f.choice, collect: [] } });
assert.deepEqual(declined.spice, ordinary.spice);
assert.equal(sandmasterTrainer(declined).spice, sandmasterTrainer(ordinary).spice);
assert.deepEqual(sandmasterTrainer(declined).forces, sandmasterTrainer(ordinary).forces);
assert.equal(sandmasterTrainer(declined).moved, sandmasterTrainer(ordinary).moved);
const continued = applyAction(accepted, before.id, { type: 'endMovement' });
const originalContinuation = applyAction(ordinary, before.id, { type: 'endMovement' });
assert.equal(continued.phase, originalContinuation.phase);
assert.equal(continued.active, originalContinuation.active);
// endMovement may advance straight through empty Battle to native Collection.
// Compare with the same original continuation, not with its pre-collection pile.
assert.equal(sandmasterTrainer(continued).spice, sandmasterTrainer(originalContinuation).spice + 1);
assert.equal(continued.spice[f.printedPile], originalContinuation.spice[f.printedPile] - 1);
});
void test('native Sandmaster rejects wrong actor, duplicate, invalid and abandoned requests immutably', () => {
const f = createBetrayalSandmasterRoute();
unchanged(f.game, 'holder', f.action);
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, collect: [f.printedPile, f.printedPile] } });
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, routes: {} } });
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, collect: ['polar_sink:0'] } });
const abandoned = applyAction(f.game, 'trainer', { type: 'endMovement' });
assert.equal(abandoned.spice[f.printedPile], f.printedAmount);
assert.equal(sandmasterTrainer(abandoned).spice, sandmasterTrainer(f.game).spice);
assert.deepEqual(sandmasterTrainer(abandoned).forces, sandmasterTrainer(f.game).forces);
unchanged(abandoned, 'trainer', f.action);
});
void test('a native empty destination offers no Sandmaster pile and keeps the free ground move unchanged', () => {
const f = createBetrayalSandmasterRoute();
const empty = TERRITORIES.flatMap(territory => territory.sectors.map(sector => location(territory.id, sector)))
.find(key => splitLocation(key).territory !== 'polar_sink' &&
!Object.entries(f.game.spice).some(([pile, amount]) => amount > 0 &&
splitLocation(pile).territory === splitLocation(key).territory) &&
sandmasterShortestRoute(f.game, 'trainer', 'polar_sink:0', key, 1));
assert.ok(empty);
const destination = splitLocation(empty);
const action: Action = { ...f.ordinary, territory: destination.territory, sector: destination.sector };
const choice = sandmasterDefaultChoice(viewGame(f.game, 'trainer'), 'trainer', action);
assert.ok(choice);
assert.deepEqual(choice.collect, []);
const order = sandmasterActionOrder(viewGame(f.game, 'trainer'), 'trainer', action);
assert.ok(order);
assert.deepEqual(quoteSandmasterMovement(f.game, 'trainer', order, choice).piles, []);
const next = applyAction(f.game, 'trainer', { ...action, sandmaster: choice });
assert.deepEqual(next.spice, f.game.spice);
assert.equal(sandmasterTrainer(next).spice, sandmasterTrainer(f.game).spice);
assert.equal(sandmasterTrainer(next).forces[empty], 1);
assert.equal(sandmasterTrainer(next).moved, sandmasterTrainer(f.game).moved + 1);
unchanged(f.game, 'trainer', { ...action, sandmaster: { ...choice, collect: [empty] } });
});
void test('all four minimal bot policies propose an actual native collecting Sandmaster move', () => {
const f = createBetrayalSandmasterRoute();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, 'trainer');
view.players.find(player => player.id === 'trainer')!.autopilot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'move' && candidate.sandmaster);
assert.ok(action, difficulty);
const choice = action.sandmaster as SandmasterChoice;
assert.ok(choice.collect.length > 0, difficulty);
const order = sandmasterActionOrder(view, 'trainer', action);
assert.ok(order, difficulty);
const quote = quoteSandmasterMovement(f.game, 'trainer', order, choice);
const next = applyAction(f.game, 'trainer', action);
assert.equal(sandmasterTrainer(next).spice, sandmasterTrainer(f.game).spice + quote.collect.length, difficulty);
for (const pile of quote.piles) assert.equal(next.spice[pile.key], pile.before - 1, difficulty);
assert.equal(sandmasterTrainer(next).moved, sandmasterTrainer(f.game).moved + 1, difficulty);
}
});