458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import {
|
|
botEntryAllowed,
|
|
botGroundMoveAllowed,
|
|
botMovementRange,
|
|
fremenReserveEntry,
|
|
} from '../game/bot-mobility';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import {
|
|
gameDistance,
|
|
location,
|
|
MOBILE_LOCATION,
|
|
MOBILE_STRONGHOLD,
|
|
splitLocation,
|
|
territory,
|
|
} from '../game/board';
|
|
import type { FactionId } from '../game/catalog';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
import { baseDeck } from '../game/cards';
|
|
|
|
function fixture(faction: FactionId = 'emperor', advanced = false): Game {
|
|
const g = createGame(
|
|
'REACHREVIEW',
|
|
newPlayer('p', 'Mover', faction),
|
|
advanced,
|
|
);
|
|
g.players.push(
|
|
newPlayer('o', 'Opponent', 'atreides'),
|
|
newPlayer('x', 'Third', 'harkonnen'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'p',
|
|
order: ['p', 'o', 'x'],
|
|
movementRemaining: ['p', 'o', 'x'],
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, { hand: [], forces: {}, spice: 50, reserves: 20 });
|
|
return g;
|
|
}
|
|
function army(g: Game, seat: number, forces: Record<string, number>) {
|
|
g.players[seat].forces = forces;
|
|
g.players[seat].reserves =
|
|
20 - Object.values(forces).reduce((sum, n) => sum + n, 0);
|
|
}
|
|
function projection(g: Game, difficulty: Difficulty) {
|
|
const view = viewGame(g, 'p');
|
|
view.players[0].bot = difficulty;
|
|
return view;
|
|
}
|
|
function candidates(g: Game, difficulty: Difficulty, type: string) {
|
|
const view = projection(g, difficulty),
|
|
before = structuredClone(view);
|
|
const actions = botActions(view).filter((a) => a.type === type);
|
|
assert.deepEqual(
|
|
view,
|
|
before,
|
|
'Generating candidates must not mutate the player projection.',
|
|
);
|
|
for (const action of actions)
|
|
assert.doesNotThrow(
|
|
() => applyAction(g, 'p', action),
|
|
`${difficulty}: ${JSON.stringify(action)}`,
|
|
);
|
|
return actions;
|
|
}
|
|
const destination = (a: Action) =>
|
|
location(String(a.territory), Number(a.sector));
|
|
|
|
void test('independent review: every offered ordinary move respects actual faction range, typed Ix selection and cancellation', () => {
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const mode of [
|
|
'ordinary',
|
|
'fremen',
|
|
'ix',
|
|
'ixCanceled',
|
|
'city',
|
|
'choam',
|
|
] as const) {
|
|
const faction =
|
|
mode === 'fremen'
|
|
? 'fremen'
|
|
: mode.startsWith('ix')
|
|
? 'ixians'
|
|
: mode === 'choam'
|
|
? 'choam'
|
|
: 'emperor';
|
|
const g = fixture(faction);
|
|
army(g, 0, {
|
|
'red_chasm:7': 5,
|
|
...(mode === 'city' ? { 'arrakeen:10': 1 } : {}),
|
|
});
|
|
g.players[0].shipped = true;
|
|
if (mode.startsWith('ix'))
|
|
g.players[0].elites = {
|
|
forces: { 'red_chasm:7': 3 },
|
|
reserves: 4,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
if (mode === 'ixCanceled')
|
|
g.players[0].ixMovementBlocked = { turn: 2, move: 0 };
|
|
if (mode === 'choam') g.choamMovement = { turn: 2, bonus: 1 };
|
|
const range =
|
|
mode === 'city' ? 3 : ['fremen', 'ix', 'choam'].includes(mode) ? 2 : 1;
|
|
const actions = candidates(g, difficulty, 'move').filter(
|
|
(a) => a.from === 'red_chasm:7',
|
|
);
|
|
assert.ok(
|
|
actions.length > 0,
|
|
`${difficulty} ${mode}: keep reachable moves after filtering ranked targets`,
|
|
);
|
|
assert.ok(
|
|
actions.every(
|
|
(a) => gameDistance(g, String(a.from), destination(a)) <= range,
|
|
),
|
|
);
|
|
if (range > 1)
|
|
assert.ok(
|
|
actions.some(
|
|
(a) => gameDistance(g, String(a.from), destination(a)) === range,
|
|
),
|
|
`${difficulty} ${mode}: retain longer legal routes`,
|
|
);
|
|
if (mode.startsWith('ix'))
|
|
assert.ok(
|
|
actions.every((a) => a.elite === 3),
|
|
'Easy must include unavoidable cyborgs when moving the whole group.',
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent review: Fremen geometric reserve radius stays free and preserves Advanced storm arrivals', () => {
|
|
assert.equal(fremenReserveEntry('sietch_tabr'), true);
|
|
assert.equal(fremenReserveEntry('cielago_west'), true);
|
|
assert.equal(fremenReserveEntry('polar_sink'), true);
|
|
assert.equal(fremenReserveEntry('carthag'), false);
|
|
assert.equal(fremenReserveEntry('arrakeen'), false);
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const advanced of [false, true]) {
|
|
const g = fixture('fremen', advanced);
|
|
g.storm = territory('sietch_tabr').sectors[0];
|
|
g.players[0].spice = 0;
|
|
const actions = candidates(g, difficulty, 'ship');
|
|
assert.ok(actions.length > 0);
|
|
assert.ok(
|
|
actions.every(
|
|
(a) => !['carthag', 'arrakeen'].includes(String(a.territory)),
|
|
),
|
|
);
|
|
if (!advanced) assert.ok(actions.every((a) => a.sector !== g.storm));
|
|
else if (difficulty !== 'Easy')
|
|
assert.ok(
|
|
actions.some(
|
|
(a) => a.territory === 'sietch_tabr' && a.sector === g.storm,
|
|
),
|
|
'A high-ranked legal storm reinforcement must survive target filtering.',
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent review: two-fighter strongholds reject new fighters while advisor entry and departure remain available', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture('beneGesserit', true);
|
|
army(g, 0, { 'arrakeen:10': 4 });
|
|
army(g, 1, { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
|
|
army(g, 2, { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
|
|
g.players[0].advisors = { arrakeen: {} };
|
|
g.players[0].shipped = true;
|
|
const actions = candidates(g, difficulty, 'move');
|
|
assert.ok(
|
|
actions.some((a) => a.territory === 'imperial_basin'),
|
|
'Advisors can leave a stronghold containing two other fighter factions.',
|
|
);
|
|
const view = projection(g, difficulty);
|
|
assert.equal(
|
|
botMovementRange(view, view.players[0], 0),
|
|
1,
|
|
'Advisors in Arrakeen do not grant city movement.',
|
|
);
|
|
g.players[0].shipped = false;
|
|
const shipView = projection(g, difficulty);
|
|
assert.equal(
|
|
botEntryAllowed(shipView, shipView.players[0], 'arrakeen', 10, 'ship'),
|
|
true,
|
|
);
|
|
const joining = candidates(g, difficulty, 'ship');
|
|
if (difficulty !== 'Easy')
|
|
assert.ok(
|
|
joining.some((a) => a.territory === 'arrakeen'),
|
|
'Reserves can join existing advisors.',
|
|
);
|
|
g.players[0].advisors = {};
|
|
army(g, 0, {});
|
|
const blocked = candidates(g, difficulty, 'ship');
|
|
assert.ok(
|
|
!blocked.some((a) => a.territory === 'arrakeen'),
|
|
'Direct reserve arrivals cannot invent advisor stance.',
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent review: advisor lock and ally destination rules do not block legal advisor or polar entry', () => {
|
|
const g = fixture('beneGesserit', true);
|
|
army(g, 0, { 'arrakeen:10': 2, 'imperial_basin:10': 1 });
|
|
army(g, 1, { 'arrakeen:10': 1, 'imperial_basin:10': 1, 'polar_sink:0': 1 });
|
|
g.players[0].advisors = { arrakeen: { lockedTurn: 2 } };
|
|
g.players[0].shipped = true;
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let view = projection(g, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'arrakeen:10',
|
|
'imperial_basin:10',
|
|
0,
|
|
),
|
|
false,
|
|
);
|
|
assert.ok(
|
|
!candidates(g, difficulty, 'move').some(
|
|
(a) => a.from === 'arrakeen:10' && a.territory === 'imperial_basin',
|
|
),
|
|
);
|
|
const advisors = structuredClone(g);
|
|
advisors.players[0].advisors!.imperial_basin = {};
|
|
advisors.players[0].ally = 'o';
|
|
advisors.players[1].ally = 'p';
|
|
view = projection(advisors, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'arrakeen:10',
|
|
'imperial_basin:10',
|
|
0,
|
|
),
|
|
true,
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
applyAction(advisors, 'p', {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
amount: 1,
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
}),
|
|
);
|
|
assert.equal(
|
|
botEntryAllowed(view, view.players[0], 'polar_sink', 0, 'ship'),
|
|
true,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent review: hidden No-Field denomination counts as one public faction without changing movement candidates', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
g.players[2].faction = 'richese';
|
|
army(g, 0, { 'imperial_basin:10': 4 });
|
|
army(g, 1, { 'arrakeen:10': 1 });
|
|
const nf = createRicheseNoField(['opaque-a', 'opaque-b', 'opaque-c']);
|
|
g.players[2].noField = deployRicheseNoField(nf, {
|
|
tokenId: 'opaque-a',
|
|
controller: 'x',
|
|
location: { territory: 'arrakeen', sector: 10 },
|
|
});
|
|
g.players[2].noFieldEvent = 'concealed';
|
|
const before = botActions(projection(g, difficulty));
|
|
assert.ok(
|
|
!before.some(
|
|
(a) =>
|
|
['move', 'ship', 'guildShip'].includes(a.type) &&
|
|
a.territory === 'arrakeen',
|
|
),
|
|
);
|
|
const changed = structuredClone(g);
|
|
changed.players[2].noField!.tokens[0].value = 5;
|
|
changed.players[2].noField!.tokens[2].value = 0;
|
|
changed.players[1].spice = 123456;
|
|
changed.players[1].hand = baseDeck().slice(-4);
|
|
assert.deepEqual(
|
|
botActions(projection(changed, difficulty)),
|
|
before,
|
|
'Private enemy balances, cards and marker values are not candidate-generation inputs.',
|
|
);
|
|
candidates(g, difficulty, 'move');
|
|
candidates(g, difficulty, 'ship');
|
|
}
|
|
});
|
|
|
|
void test('independent review: mobile stronghold permits ground entry but reserves remain Ixian-only, including sector-zero storm immunity', () => {
|
|
for (const faction of ['emperor', 'ixians'] as const)
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture(faction);
|
|
g.mobileStronghold = { location: 'red_chasm:7' };
|
|
army(g, 0, { 'red_chasm:7': 4 });
|
|
const actions = candidates(g, difficulty, 'move');
|
|
assert.ok(actions.some((a) => destination(a) === MOBILE_LOCATION));
|
|
const ships = candidates(g, difficulty, 'ship');
|
|
if (faction === 'emperor')
|
|
assert.ok(!ships.some((a) => a.territory === MOBILE_STRONGHOLD));
|
|
else if (difficulty !== 'Easy')
|
|
assert.ok(ships.some((a) => a.territory === MOBILE_STRONGHOLD));
|
|
g.storm = 0;
|
|
const view = projection(g, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'red_chasm:7',
|
|
MOBILE_LOCATION,
|
|
0,
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
botEntryAllowed(view, view.players[0], 'polar_sink', 0, 'ship'),
|
|
true,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent review: Guild transports use paid affordability and may carry allied Fremen beyond their free-entry radius', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture('fremen');
|
|
g.players[1].faction = 'guild';
|
|
g.players[0].ally = 'o';
|
|
g.players[1].ally = 'p';
|
|
g.players[0].spice = 0;
|
|
assert.deepEqual(candidates(g, difficulty, 'guildShip'), []);
|
|
g.aid.o = { recipient: 'p', amount: 10 };
|
|
const funded = candidates(g, difficulty, 'guildShip');
|
|
assert.ok(funded.length > 0);
|
|
if (difficulty !== 'Easy')
|
|
assert.ok(
|
|
funded.some((a) => a.territory === 'arrakeen'),
|
|
'Guild transport does not inherit the Fremen free reinforcement radius.',
|
|
);
|
|
assert.ok(funded.every((a) => a.from === 'reserves'));
|
|
assert.ok(
|
|
funded.every((a) => splitLocation(destination(a)).sector !== g.storm),
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent review: allied transit, Baliset same-territory movement and real storm barriers keep their distinct rules', () => {
|
|
const g = fixture('fremen');
|
|
army(g, 0, { 'red_chasm:7': 4 });
|
|
army(g, 1, { 'pasty_mesa:7': 1 });
|
|
g.players[0].ally = 'o';
|
|
g.players[1].ally = 'p';
|
|
g.players[0].shipped = true;
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let view = projection(g, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'red_chasm:7',
|
|
'pasty_mesa:7',
|
|
0,
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'red_chasm:7',
|
|
'the_minor_erg:7',
|
|
0,
|
|
),
|
|
true,
|
|
'Allied presence cannot become a ground transit barrier.',
|
|
);
|
|
candidates(g, difficulty, 'move');
|
|
const baliset = structuredClone(g);
|
|
baliset.players[0].ally = null;
|
|
baliset.players[1].ally = null;
|
|
baliset.players[1].faction = 'choam';
|
|
baliset.choamBaliset = [{ player: 'p', territory: 'pasty_mesa', turn: 2 }];
|
|
view = projection(baliset, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'red_chasm:7',
|
|
'pasty_mesa:7',
|
|
0,
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
botEntryAllowed(view, view.players[0], 'pasty_mesa', 7, 'ship'),
|
|
true,
|
|
);
|
|
army(baliset, 0, { 'pasty_mesa:6': 4 });
|
|
view = projection(baliset, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'pasty_mesa:6',
|
|
'pasty_mesa:7',
|
|
0,
|
|
),
|
|
true,
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
applyAction(baliset, 'p', {
|
|
type: 'move',
|
|
from: 'pasty_mesa:6',
|
|
territory: 'pasty_mesa',
|
|
sector: 7,
|
|
amount: 1,
|
|
}),
|
|
);
|
|
army(baliset, 1, {});
|
|
view = projection(baliset, difficulty);
|
|
assert.equal(
|
|
botGroundMoveAllowed(
|
|
view,
|
|
view.players[0],
|
|
'red_chasm:7',
|
|
'pasty_mesa:7',
|
|
0,
|
|
),
|
|
true,
|
|
'A stale restriction without CHOAM presence is ineffective.',
|
|
);
|
|
const storm = structuredClone(g);
|
|
storm.storm = 7;
|
|
assert.deepEqual(
|
|
candidates(storm, difficulty, 'move'),
|
|
[],
|
|
'Forces in the actual storm sector cannot initiate a ground move.',
|
|
);
|
|
}
|
|
});
|