170 lines
9.6 KiB
TypeScript
170 lines
9.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { fighterCount } from '../game/advisors';
|
|
import { gameDistance, splitLocation, territory } from '../game/board';
|
|
import { quoteBattleBoard } from '../game/board-resolution-quote';
|
|
import { quoteAdvisorBattleOffer } from '../game/movement-phase-quote';
|
|
import { nexusAdvisorAction } from '../game/nexus-advisor-options';
|
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
import {
|
|
createNexusAdvisorStormFixture, nexusAdvisorStormPlayer,
|
|
type NexusAdvisorStormFixture,
|
|
} from './fixture-nexus-advisor-storm';
|
|
|
|
function declaration(f: NexusAdvisorStormFixture, game = f.game): Action {
|
|
const view = viewGame(game, f.owner);
|
|
const offer = view.nexusAdvisors!.offer!;
|
|
const action = nexusAdvisorAction(view, offer.event, [f.territory]);
|
|
assert.ok(action, 'The real storm-held advisor territory is a legal whole-territory Cunning selection');
|
|
return action;
|
|
}
|
|
function resources(game: Game) {
|
|
return game.players.map(player => ({ id: player.id, spice: player.spice,
|
|
reserves: player.reserves, tanks: player.tanks, forces: player.forces,
|
|
elites: player.elites, shipped: player.shipped, moved: player.moved,
|
|
revived: player.revived, ally: player.ally }));
|
|
}
|
|
function settle(game: Game): Game {
|
|
for (let count = 0; count < 80; count++) {
|
|
if (!game.response && !game.decision && !game.phaseOpening) return game;
|
|
const step = nextStrongholdFactionsNativeStep(game);
|
|
assert.ok(step, 'Only original responses and decisions may settle the declaration');
|
|
game = applyAction(game, step.actor, step.action);
|
|
}
|
|
throw Error('Original storm advisor response did not settle');
|
|
}
|
|
function rejectsImmutably(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
function assertStormGeometry(f: NexusAdvisorStormFixture, game: Game): void {
|
|
const from = `${f.territory}:${f.separated ? 9 : 10}`;
|
|
const to = `${f.territory}:${f.separated ? 5 : 10}`;
|
|
assert.equal(gameDistance(game, from, to), 0);
|
|
assert.ok(gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) > 0);
|
|
assert.equal(quoteBattleBoard(game).battles.some(battle => battle.territory === f.territory &&
|
|
[battle.attacker, battle.defender].includes(f.owner)), false);
|
|
}
|
|
function ordinaryStormMove(f: NexusAdvisorStormFixture): Action {
|
|
return { type: 'move', forces: { [`${f.territory}:${f.separated ? 9 : 10}`]: 1 },
|
|
eliteForces: { [`${f.territory}:${f.separated ? 9 : 10}`]: 0 },
|
|
territory: f.separated ? f.territory : 'polar_sink', sector: f.separated ? 5 : 0 };
|
|
}
|
|
|
|
for (const separated of [false, true]) {
|
|
const label = separated ? 'real rock groups separated by the storm' : 'real shielded Arrakeen groups under the storm';
|
|
void test(`Cunning changes only stance for ${label}, without granting movement or battle`, () => {
|
|
const f = createNexusAdvisorStormFixture({ separated });
|
|
const original = structuredClone(f.game);
|
|
assert.equal(original.advanced, true);
|
|
assert.deepEqual(nexusAdvisorStormPlayer(f.afterStorm, f.owner).forces,
|
|
nexusAdvisorStormPlayer(f.beforeStorm, f.owner).forces);
|
|
assert.deepEqual(nexusAdvisorStormPlayer(f.afterStorm, f.opponent).forces,
|
|
nexusAdvisorStormPlayer(f.beforeStorm, f.opponent).forces);
|
|
assert.equal(territory(f.territory).type, separated ? 'rock' : 'stronghold');
|
|
assert.deepEqual(nexusAdvisorStormPlayer(original, f.owner).advisors![f.territory], { lockedTurn: 1 });
|
|
assert.equal(nexusAdvisorStormPlayer(original, f.owner).forces[`${f.territory}:${separated ? 9 : 10}`], separated ? 1 : 2);
|
|
assert.equal(fighterCount(nexusAdvisorStormPlayer(original, f.opponent), f.territory), 1);
|
|
assert.equal(quoteAdvisorBattleOffer(original).territories.includes(f.territory), false);
|
|
assertStormGeometry(f, original);
|
|
rejectsImmutably(original, f.owner, ordinaryStormMove(f));
|
|
rejectsImmutably(original, f.owner, { ...ordinaryStormMove(f), territory: f.territory,
|
|
sector: separated ? 5 : 10, fighters: true });
|
|
rejectsImmutably(original, f.owner, { type: 'ship', territory: f.territory,
|
|
sector: original.storm, amount: 1, elite: 0, allyPayment: 0 });
|
|
|
|
const action = declaration(f);
|
|
rejectsImmutably(original, f.opponent, action);
|
|
rejectsImmutably(original, f.owner, { ...action, event: `${String(action.event)}-stale` });
|
|
let game = applyAction(original, f.owner, action);
|
|
assert.equal(game.response?.kind, 'nexusAdvisorFlip');
|
|
const receipt = structuredClone(game.nexusAdvisorHistory!.at(-1)!.receipt);
|
|
assert.equal(receipt.owner, f.owner);
|
|
assert.equal(receipt.turn, original.turn);
|
|
assert.deepEqual(receipt.selections, [{ territory: f.territory, count: 2,
|
|
locations: separated
|
|
? [{ location: `${f.territory}:5`, count: 1 }, { location: `${f.territory}:9`, count: 1 }]
|
|
: [{ location: `${f.territory}:10`, count: 2 }],
|
|
noField: null, stance: { lockedTurn: 1 } }]);
|
|
assert.deepEqual(resources(game), resources(original));
|
|
assert.deepEqual(nexusAdvisorStormPlayer(game, f.owner).advisors,
|
|
nexusAdvisorStormPlayer(original, f.owner).advisors);
|
|
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
|
|
rejectsImmutably(game, f.owner, action);
|
|
game = settle(game);
|
|
assert.equal(game.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
|
|
assert.equal(game.nexusAdvisorHistory!.length, 1);
|
|
assert.deepEqual(game.nexusAdvisorHistory!.at(-1)!.receipt, receipt);
|
|
const expected = structuredClone(nexusAdvisorStormPlayer(original, f.owner).advisors!);
|
|
delete expected[f.territory];
|
|
assert.deepEqual(nexusAdvisorStormPlayer(game, f.owner).advisors, expected);
|
|
assert.deepEqual(resources(game), resources(original));
|
|
assert.deepEqual(game.players.map(player => player.hand), original.players.map(player => player.hand));
|
|
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 2);
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
|
|
assert.equal(game.turn, original.turn);
|
|
assert.equal(game.phase, 5);
|
|
assert.equal(game.active, f.owner);
|
|
assert.equal(game.response, null);
|
|
assertStormGeometry(f, game);
|
|
rejectsImmutably(game, f.owner, ordinaryStormMove(f));
|
|
rejectsImmutably(game, f.owner, action);
|
|
|
|
const sinkBefore = nexusAdvisorStormPlayer(game, f.owner).forces['polar_sink:0'] ?? 0;
|
|
game = applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0,
|
|
amount: 1, elite: 0, allyPayment: 0 });
|
|
game = settle(game);
|
|
assert.equal(nexusAdvisorStormPlayer(game, f.owner).shipped, true);
|
|
assert.equal(nexusAdvisorStormPlayer(game, f.owner).moved, 0);
|
|
assert.equal(nexusAdvisorStormPlayer(game, f.owner).forces['polar_sink:0'], sinkBefore + 1);
|
|
game = applyAction(game, f.owner, { type: 'endMovement' });
|
|
assert.notEqual(game.active, f.owner);
|
|
});
|
|
|
|
void test(`the real auction Karama cancels the original batch for ${label}`, () => {
|
|
const f = createNexusAdvisorStormFixture({ separated });
|
|
const original = structuredClone(f.game), action = declaration(f);
|
|
let game = applyAction(original, f.owner, action);
|
|
game = applyAction(game, f.canceler, { type: 'card', card: f.cancelerCard, mode: 'cancel' });
|
|
game = settle(game);
|
|
assert.equal(game.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
|
|
assert.equal(game.nexusAdvisorHistory!.length, 1);
|
|
assert.deepEqual(resources(game), resources(original));
|
|
assert.deepEqual(game.players.map(player => player.advisors), original.players.map(player => player.advisors));
|
|
assert.equal(game.discard.filter(card => card.id === f.cancelerCard).length, 1);
|
|
assert.equal(nexusAdvisorStormPlayer(game, f.canceler).hand.some(card => card.id === f.cancelerCard), false);
|
|
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
|
|
assert.equal(game.active, f.owner);
|
|
assert.equal(game.phase, 5);
|
|
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 0);
|
|
assert.equal(quoteAdvisorBattleOffer(game).territories.includes(f.territory), false);
|
|
assertStormGeometry(f, game);
|
|
rejectsImmutably(game, f.owner, ordinaryStormMove(f));
|
|
rejectsImmutably(game, f.owner, action);
|
|
game = applyAction(game, f.owner, { type: 'endMovement' });
|
|
assert.notEqual(game.active, f.owner);
|
|
});
|
|
|
|
void test(`all four legal bot policies can use Cunning for ${label}`, () => {
|
|
const f = createNexusAdvisorStormFixture({ separated });
|
|
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
const view = viewGame(f.game, f.owner);
|
|
view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
|
const actions = botActions(view);
|
|
const action = actions.find(candidate => candidate.type === 'nexusAdvisors');
|
|
assert.ok(action, `${difficulty} selects the genuine two-advisor versus one-fighter offer`);
|
|
let game = applyAction(f.game, f.owner, action);
|
|
assert.equal(game.response?.kind, 'nexusAdvisorFlip');
|
|
game = settle(game);
|
|
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 2);
|
|
assert.deepEqual(resources(game), resources(f.game));
|
|
assertStormGeometry(f, game);
|
|
}
|
|
});
|
|
}
|