Files
Dune/tests/ornithopter-bots.test.ts
T
2026-09-08 07:37:52 +10:00

304 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
type GameView,
} from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { gameDistance, splitLocation } from '../game/board';
import { validateCohortSelection } from '../game/ornithopter';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
const cardId = 'richese-ornithopter',
source = 'red_chasm:7';
function fixture(faction: FactionId = 'emperor', amount = 1) {
const g = createGame('ORNIBOT', newPlayer('p', 'Pilot', faction), true);
g.players.push(
newPlayer('o', 'Observer', faction === 'atreides' ? 'emperor' : 'atreides'),
);
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.active = 'p';
g.order = ['p', 'o'];
g.movementRemaining = [...g.order];
g.storm = 18;
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.spice = 10;
}
g.players[0].hand = [richeseCards().find((c) => c.effect === 'ornithopter')!];
g.players[0].forces = { [source]: amount };
g.players[0].reserves = 20 - amount;
g.players[0].shipped = true;
return g;
}
function projection(g: Game, difficulty: Difficulty) {
const v = viewGame(g, 'p');
v.players[0].bot = difficulty;
return v;
}
const cardMoves = (v: GameView) =>
botActions(v).filter(
(a) => a.type === 'move' && (a.movementCard || a.ornithopterEvent),
);
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
void test('every profile actually uses card range beyond normal access without a stronghold and discards once', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture(),
v = projection(g, difficulty),
before = structuredClone(v);
const action = botActions(v)[0];
assert.equal(action.movementCard, cardId);
assert.equal(action.ornithopter, 'range3');
const destination = `${action.territory as string}:${action.sector as number}`;
const distance = gameDistance(
g,
source,
destination,
(k) => splitLocation(k).sector === g.storm,
);
assert.ok(distance > 1 && distance <= 3);
assert.deepEqual(v, before);
const done = applyAction(g, 'p', action);
assert.equal(done.players[0].forces[destination], 1);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].spice, 10);
assert.equal(done.players[0].reserves, 19);
assert.equal(done.discard.filter((c) => c.id === cardId).length, 1);
assert.equal(done.ornithopter, null);
assert.equal(cardMoves(projection(reload(done), difficulty)).length, 0);
assert.throws(() => applyAction(done, 'p', action));
}
});
void test('all profiles can split one origin into two different typed groups and finish with the exact restored event', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture('emperor', 5);
g.players[0].elites = {
forces: { [source]: 3 },
reserves: 2,
tanks: 0,
revived: 0,
};
const first = cardMoves(projection(g, difficulty)).find(
(a) => a.ornithopter === 'twoGroups',
);
assert.ok(first, difficulty);
assert.ok(Number(first.amount) < 5);
const moved = applyAction(g, 'p', first),
v = projection(reload(moved), difficulty);
assert.equal(moved.ornithopter!.completed, 1);
assert.equal(moved.discard.length, 0);
const candidates = cardMoves(v);
assert.ok(candidates.length);
const cohort = v.ornithopter!.active!.cohort!;
for (const action of candidates) {
assert.equal(action.ornithopterEvent, moved.ornithopter!.event);
assert.equal(action.movementCard, undefined);
const from = String(action.from);
validateCohortSelection(
cohort,
v.players[0].forces,
v.players[0].elites!.forces,
{ [from]: Number(action.amount) },
{ [from]: Number(action.elite) },
);
assert.notEqual(
from,
`${first.territory as string}:${first.sector as number}`,
);
}
const done = applyAction(moved, 'p', candidates[0]);
assert.equal(done.players[0].moved, 2);
assert.equal(done.ornithopter, null);
assert.equal(
Object.values(done.players[0].forces).reduce((a, b) => a + b, 0),
5,
);
assert.equal(
Object.values(done.players[0].elites!.forces).reduce((a, b) => a + b, 0),
3,
);
assert.equal(done.discard.filter((c) => c.id === cardId).length, 1);
}
});
void test('merged first-group arrivals never expand the original normal or elite quota offered to the second group', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture('emperor', 5);
const first = cardMoves(projection(g, difficulty)).find(
(a) => a.ornithopter === 'twoGroups',
)!;
const destination = `${first.territory as string}:${first.sector as number}`;
g.players[0].forces[destination] = 2;
g.players[0].reserves -= 2;
const moved = applyAction(g, 'p', first),
v = projection(moved, difficulty);
const cohort = v.ornithopter!.active!.cohort!;
assert.equal(cohort.forces[destination], 2);
for (const action of cardMoves(v)) {
const from = String(action.from);
assert.ok(Number(action.amount) <= (cohort.forces[from] ?? 0));
assert.doesNotThrow(() => applyAction(moved, 'p', action));
}
}
});
void test('concealed marker moves use separate exact events, never hidden value, and cannot be moved by both card groups', () => {
for (const difficulty of DIFFICULTIES) {
let firstShape: unknown;
for (const value of [0, 3, 5] as const) {
const g = fixture('richese', 2),
p = g.players[0];
p.noField = createRicheseNoField(['zero', 'three', 'five']);
const selected = p.noField.tokens.find((t) => t.value === value)!;
p.noField = deployRicheseNoField(p.noField, {
tokenId: selected.id,
controller: 'p',
location: { territory: 'red_chasm', sector: 7 },
});
p.noFieldEvent = 'marker-event';
const action = cardMoves(projection(g, difficulty)).find(
(a) => a.noField && a.ornithopter === 'twoGroups',
);
assert.ok(action);
assert.equal(action.event, 'marker-event');
assert.equal(action.ornithopterEvent, undefined);
assert.deepEqual(action.forces, {});
const shape = { ...action, noField: 'opaque' };
if (firstShape) assert.deepEqual(shape, firstShape);
else firstShape = shape;
const moved = applyAction(g, 'p', action);
assert.equal(moved.ornithopter!.cohort!.noField, undefined);
const next = cardMoves(projection(reload(moved), difficulty));
assert.ok(next.length);
assert.ok(
next.every(
(a) =>
a.noField === undefined &&
a.ornithopterEvent === moved.ornithopter!.event,
),
);
const done = applyAction(moved, 'p', next[0]);
assert.equal(done.players[0].reserves, 18);
assert.ok(done.players[0].noField!.deployed);
}
}
});
void test('all profiles honor mode/ownership guards, existing interaction priority and finite no-group closure', () => {
for (const difficulty of DIFFICULTIES) {
for (const change of [
(g: Game) => {
g.players[0].moved = 1;
},
(g: Game) => {
g.hajr = ['p'];
},
(g: Game) => {
g.active = 'o';
},
(g: Game) => {
g.players[0].hand = [];
},
]) {
const g = fixture();
change(g);
assert.equal(cardMoves(projection(g, difficulty)).length, 0);
}
const v = projection(fixture(), difficulty);
v.ornithopter!.blocked = 'Card committed.';
assert.equal(cardMoves(v).length, 0);
const truth = projection(fixture(), difficulty);
truth.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
} as unknown as NonNullable<GameView['truthtrance']>;
assert.equal(botActions(truth)[0].type, 'truthPass');
const response = projection(fixture(), difficulty);
response.response = { kind: 'emperorIncome', owner: 'o', passed: [] };
assert.equal(cardMoves(response).length, 0);
const g = fixture('emperor', 2),
first = cardMoves(projection(g, difficulty)).find(
(a) => a.ornithopter === 'twoGroups',
)!;
const moved = applyAction(g, 'p', first),
blocked = projection(moved, difficulty);
blocked.ornithopter!.active!.cohort = { forces: {}, elites: {} };
assert.equal(botActions(blocked)[0].type, 'endMovement');
const ended = applyAction(moved, 'p', botActions(blocked)[0]);
assert.equal(ended.ornithopter, null);
assert.equal(ended.discard.filter((c) => c.id === cardId).length, 1);
}
});
void test('Fremen and Ixian normal two-group ranges remain distinct from fixed card range without an Ix speed response', () => {
for (const difficulty of DIFFICULTIES)
for (const faction of ['fremen', 'ixians'] as const) {
const g = fixture(faction, 5);
if (faction === 'ixians')
g.players[0].elites = {
forces: { [source]: 3 },
reserves: 4,
tanks: 0,
revived: 0,
};
const first = cardMoves(projection(g, difficulty)).find(
(a) => a.ornithopter === 'twoGroups',
);
assert.ok(first);
const distance = gameDistance(
g,
source,
`${first.territory as string}:${first.sector as number}`,
(key) => splitLocation(key).sector === g.storm,
);
assert.ok(
distance <= (faction === 'fremen' || Number(first.elite) > 0 ? 2 : 1),
);
const moved = applyAction(g, 'p', first);
assert.equal(moved.players[0].moved, 1);
const continued = cardMoves(projection(moved, difficulty));
assert.ok(continued.length);
assert.doesNotThrow(() => applyAction(moved, 'p', continued[0]));
if (faction === 'ixians') {
const fixed = fixture('ixians', 1);
fixed.players[0].elites = {
forces: { [source]: 1 },
reserves: 6,
tanks: 0,
revived: 0,
};
const action = cardMoves(projection(fixed, difficulty))[0];
assert.equal(action.ornithopter, 'range3');
assert.equal(
gameDistance(
fixed,
source,
`${action.territory as string}:${action.sector as number}`,
(key) => splitLocation(key).sector === fixed.storm,
),
3,
);
const done = applyAction(fixed, 'p', action);
assert.equal(done.pendingIxMove, undefined);
assert.equal(done.response, null);
assert.equal(done.players[0].moved, 1);
}
}
});