Files
Dune/tests/sandmaster-ornithopter-engine.test.ts
T

203 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
quoteSandmasterMovement, sandmasterActionOrder, sandmasterCollectionPiles,
sandmasterLeader, sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import {
actSandmasterOrnithopter as act, advanceSandmasterOrnithopter as advance,
createSandmasterOrnithopterFixture as fixture, finishSandmasterOrnithopter as finish,
moveSandmasterOrnithopter as move, sandmasterOrnithopterClean as clean,
sandmasterOrnithopterDestination as destination, sandmasterOrnithopterInventory as inventory,
sandmasterOrnithopterPlayer as player, sandmasterOrnithopterSecondAction as secondAction,
sandmasterOrnithopterStocks as stocks, type SandmasterOrnithopterFixture,
} from './fixture-sandmaster-ornithopter';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
const ownView = (game: Game, actor: string) => viewGame(game, actor).players.find(seat => seat.id === actor)!;
const bonusLogs = (game: Game) => game.log.filter(entry => entry.text.includes('Sandmaster collected 1 spice while moving')).length;
function withBonus(f: SandmasterOrnithopterFixture, game: Game, bonus: number) {
const expected = structuredClone(f.originalStocks);
expected.find(seat => seat.id === f.actor)!.spice += bonus;
assert.deepEqual(stocks(game), expected);
assert.equal(bonusLogs(game) - f.originalSandmasterLogCount, bonus);
assert.equal(game.spice[f.pile] ?? 0, f.pileBefore - bonus);
for (const [key, amount] of Object.entries(f.originalPiles))
if (key !== f.pile) assert.equal(game.spice[key], amount, 'Unselected entered piles are not additional compulsory income');
assert.deepEqual(inventory(game), f.originalInventory);
}
function source(f: SandmasterOrnithopterFixture) {
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.actor);
assert.deepEqual(f.game.players.map(seat => seat.faction), ['emperor', 'richese', 'atreides']);
assert.deepEqual(f.game.expansions, ['choam']); assert.ok(f.game.leaderSkills);
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(clean(f.game)); assert.equal(sandmasterLeader(f.game, f.actor), f.trainer);
assert.ok(f.actions.some(step => step.action.type === 'start'));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
step.action.skill === 'sandmaster' && step.action.leader === f.trainer));
assert.ok(f.acquisition.steps.some(step => step.actor === f.actor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.actor).spice - player(f.acquisition.after, f.actor).spice, 1);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
assert.equal(f.shipment.before.turn, 1); assert.equal(f.shipment.step.action.type, 'ship');
assert.equal(f.shipment.step.action.amount, 4); assert.equal(f.shipment.step.action.elite, 0);
assert.equal(player(f.shipment.before, f.actor).spice - player(f.shipment.after, f.actor).spice, 8);
assert.equal(player(f.shipment.before, f.actor).reserves - player(f.shipment.after, f.actor).reserves, 4);
assert.deepEqual(player(f.game, f.actor).forces, { [f.source]: 4 });
assert.equal(player(f.game, f.actor).reserves, 16); assert.equal(player(f.game, f.actor).tanks, 0);
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(ownView(f.game, f.actor).movesAllowed, 1);
assert.equal(f.game.ornithopter ?? null, null);
assert.equal(f.beforeBlow.turn, 2); assert.equal(f.beforeBlow.phase, 0);
assert.deepEqual(f.spiceOrder.after.map(card => JSON.stringify(card)).sort(), f.spiceOrder.original.map(card => JSON.stringify(card)).sort());
assert.ok(f.blow.steps.some(step => step.action.type === 'ready' || step.action.type === 'stormDial'));
assert.ok(f.blow.after.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(f.blow.card)));
assert.equal(f.blow.after.spice[f.pile], (f.beforeBlow.spice[f.pile] ?? 0) + f.blow.card.amount);
assert.equal(f.game.spice[f.pile], f.pileBefore); assert.ok(f.pileBefore >= 2);
assert.equal(f.choice.routes[f.source][0], f.source);
assert.equal(f.choice.routes[f.source].at(-1), f.targetLocation);
assert.deepEqual(f.choice.collect, [f.pile]);
assert.equal(sandmasterRouteDistance(f.game, f.route), f.mode === 'range3' ? 3 : 1);
const view = viewGame(f.game, f.actor), order = sandmasterActionOrder(view, f.actor, f.action);
assert.ok(order, 'The real held-card action has an owned Sandmaster order');
assert.equal(order.range, f.mode === 'range3' ? 3 : 1);
const quote = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
assert.deepEqual(quote.routes, f.choice.routes);
assert.deepEqual(quote.piles, [{ key: f.pile, before: f.pileBefore }]);
if (f.mode === 'range3') {
const shortest = sandmasterShortestRoute(f.game, f.actor, f.source, f.targetLocation, 3); assert.ok(shortest);
assert.ok(sandmasterRouteDistance(f.game, shortest) < 3,
'The owned original choice deliberately retains a longer legal collecting route');
}
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
}
function retired(f: SandmasterOrnithopterFixture, game: Game, movements: 1 | 2, bonus: number) {
withBonus(f, game, bonus);
assert.equal(player(game, f.actor).moved, movements);
assert.equal(ownView(game, f.actor).movesAllowed, movements);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, movements - 1);
assert.equal(game.ornithopter ?? null, null);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
}
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
void test(`public ${advanced ? 'Advanced' : 'Basic'} Sandmaster ${mode} collects from actual Spice blows once per accepted physical movement`, () => {
const f = fixture({ advanced, mode }); source(f);
const before = structuredClone(f.game);
let game = move(f); assert.deepEqual(f.game, before);
withBonus(f, game, 1);
assert.equal(player(game, f.actor).forces[f.source], 2);
assert.equal(player(game, f.actor).forces[f.targetLocation], 2);
assert.equal(player(game, f.actor).moved, 1);
if (mode === 'twoGroups') {
assert.equal(game.ornithopter?.completed, 1);
assert.deepEqual(game.ornithopter.cohort!.forces, { [f.source]: 2 });
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
rejected(game, f.actor, { type: 'move', forces: { [f.targetLocation]: 1 },
ornithopterEvent: game.ornithopter.event, ...f.sourceLocation });
const action = secondAction(f, game), order = sandmasterActionOrder(viewGame(game, f.actor), f.actor, action);
assert.ok(order);
const quote = quoteSandmasterMovement(game, f.actor, order, action.sandmaster);
assert.deepEqual(quote.piles, [{ key: f.pile, before: f.pileBefore - 1 }], 'The second original cohort requotes the actual remaining spice');
game = act(game, f.actor, action); game = advance(game, clean);
assert.deepEqual(player(game, f.actor).forces, { [f.targetLocation]: 4 });
retired(f, game, 2, 2);
} else retired(f, game, 1, 1);
rejected(game, f.actor, f.action);
const bonus = bonusLogs(game), forces = structuredClone(player(game, f.actor).forces);
const steps: { actor: string; action: Action }[] = [];
game = finish(f, game, steps);
assert.equal(game.phase, 7); assert.equal(game.turn, 2);
assert.ok(steps.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.equal(bonusLogs(game), bonus, 'Ordinary Collection does not repeat any movement bonus');
assert.deepEqual(player(game, f.actor).forces, forces);
assert.equal(player(game, f.actor).reserves, 16); assert.equal(player(game, f.actor).tanks, 0);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
void test('each actual card movement can independently decline Sandmaster without altering its original force route or disposal', () => {
for (const advanced of [false, true]) for (const firstCollect of [false, true]) {
const f = fixture({ advanced, mode: 'twoGroups' }); source(f);
let game = move(f, structuredClone(f.game), [], firstCollect);
withBonus(f, game, Number(firstCollect));
game = act(game, f.actor, secondAction(f, game, !firstCollect)); game = advance(game, clean);
retired(f, game, 2, 1);
assert.deepEqual(player(game, f.actor).forces, { [f.pile]: 4 });
}
const f = fixture({ mode: 'range3' }); source(f);
const game = move(f, structuredClone(f.game), [], false);
retired(f, game, 1, 0);
assert.equal(player(game, f.actor).forces[f.targetLocation], 2);
});
void test('invalid physical-flight Sandmaster routes and pile choices reject before card, board spice or original forces move', () => {
const f = fixture({ mode: 'range3' }); source(f);
rejected(f.game, f.observer, f.action);
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: {}, collect: [f.pile] } });
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: f.routes, collect: [f.pile, f.pile] } });
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: f.routes, collect: [f.source] } });
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: { [f.source]: [f.source, f.targetLocation] }, collect: [f.pile] } });
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'stale-card-flight' });
const changed = structuredClone(f.game); changed.spice[f.pile] = 0;
rejected(changed, f.actor, f.action); // Labelled changed-pile supplement; not a played source.
assert.deepEqual(stocks(f.game), f.originalStocks);
assert.deepEqual(f.game.spice, f.originalPiles);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(f.game), f.originalInventory);
});
void test('early ending retains one committed Sandmaster payment and discards the abandoned second flight once', () => {
const f = fixture({ mode: 'twoGroups' }); source(f);
let game = move(f);
game = act(game, f.actor, f.endMovement); game = advance(game, state => clean(state) && state.active !== f.actor);
assert.equal(bonusLogs(game) - f.originalSandmasterLogCount, 1);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(player(game, f.actor).moved, 1);
assert.equal(player(game, f.actor).forces[f.source], 2);
assert.equal(player(game, f.actor).forces[f.pile], 2);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
const bonus = bonusLogs(game); game = finish(f, game);
assert.equal(game.phase, 7); assert.equal(bonusLogs(game), bonus);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('all four minimal profiles offer a collected original physical flight and its remaining original group', () => {
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
const f = fixture({ advanced, mode }); source(f);
const sources = [f.game, ...(mode === 'twoGroups' ? [move(f)] : [])];
for (const game of sources) for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.actor); view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
const candidates = botActions(view).filter(action => {
if (action.type !== 'move' || !action.sandmaster || typeof action.sandmaster !== 'object' || Array.isArray(action.sandmaster)) return false;
const choice = action.sandmaster as Partial<SandmasterChoice>;
return Array.isArray(choice.collect) && choice.collect.length > 0 &&
(game.ornithopter ? action.ornithopterEvent === game.ornithopter.event
: action.movementCard === f.card && action.ornithopter === mode);
});
assert.ok(candidates.length, `${difficulty} offers original ${mode}/${game.ornithopter ? 'second' : 'first'} Sandmaster collection`);
const action = candidates[0], choice = action.sandmaster as SandmasterChoice;
const offered = sandmasterCollectionPiles(game, choice.routes);
assert.ok(choice.collect.every(key => offered[splitLocation(key).territory]?.includes(key)));
const before = player(game, f.actor), moved = before.moved;
let accepted = act(game, f.actor, action); accepted = advance(accepted, clean);
assert.equal(player(accepted, f.actor).moved, moved + 1);
assert.equal(player(accepted, f.actor).spice, before.spice + choice.collect.length);
assert.equal(player(accepted, f.actor).reserves, before.reserves); assert.equal(player(accepted, f.actor).tanks, before.tanks);
for (const key of choice.collect) assert.equal(accepted.spice[key] ?? 0, game.spice[key] - 1);
assert.equal(typeof destination(action), 'string');
assert.deepEqual(inventory(accepted), f.originalInventory);
}
}
});