Connect capped extra range, two-origin gathering, Fremen cancellation and saved replacement choices. Add human controls and actual first-choice AI paths for all four profiles. Admit the exact nine base green Specials in the actual skilled leader's weapon slot for +2 on survival, with mandatory physical discard and no native utility effect. Reject stale dead-disc plans. Independent movement/battle review closed after the saved-dead-disc fix. 84 focused tests passed. Two four-profile Basic/Advanced samples completed (194/186 accepted actions, six JSON restorations each, zero rejected actions). Basic used both movement branches; Advanced used added range. Browser genuine setup/gather/refresh and a separate targeted Special/traitor-loss fixture passed. Required verification: types, lint, 4435 offline tests, build and 43 HTTP tests passed. First attempt stopped at missing explicit test promise markers; corrected before the successful broad run. Final check/build/HTTP durations: 144157/6638/25048 ms. Source fingerprint (1260 files): bb7be69cf3e925bf7338ea444f77279ed7b37faadc3c44ed7db01b941f27b861 Private report: checkpoints/20260913-planetologist/checks-final/report.json. Preserved all 325 opening games and all original application/seat/recovery records; 359 rooms after 34 new QA/HTTP rooms. SQLite quick_check ok and zero foreign-key violations. Healthy existing server reused; no reset/restart. Boundaries: eight other Leader Skills, enemy-only mixed BG gather response, expansion roster/green-card/optional-module combinations remain unfinished. Winning traitor caller discard remains a documented card-specific inference. No mode or publication gates opened. Efficiency measure: aggregate goal counter 67321978 -> 68710948 (1388970), including context and coordination; not billing tokens or a savings claim. One skill's three connected effects, selective implementation/research/review, one interrupted lint attempt followed by the required complete verification.
240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type GameView,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
|
import { gameDistance, location, splitLocation } from '../game/board';
|
|
|
|
const destination = location('polar_sink', 0);
|
|
const rangeSource = location('false_wall_west', 16);
|
|
const gatherSources = [
|
|
location('wind_pass', 14),
|
|
location('wind_pass_north', 17),
|
|
] as const;
|
|
|
|
function fixture(code = 'PLANETBOT'): Game {
|
|
const g = createGame(code, newPlayer('p', 'Pilot', 'emperor'), true);
|
|
g.players.push(newPlayer('o', 'Observer', 'atreides'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
order: ['p', 'o'],
|
|
active: 'p',
|
|
movementRemaining: ['p', 'o'],
|
|
});
|
|
for (const player of g.players) {
|
|
player.hand = [];
|
|
player.forces = {};
|
|
player.reserves = 20;
|
|
player.spice = 10;
|
|
}
|
|
const pilot = g.players[0];
|
|
pilot.forces = {
|
|
[rangeSource]: 2,
|
|
[gatherSources[0]]: 2,
|
|
[gatherSources[1]]: 3,
|
|
};
|
|
pilot.reserves = 13;
|
|
pilot.shipped = true;
|
|
pilot.elites = {
|
|
forces: {
|
|
[rangeSource]: 1,
|
|
[gatherSources[0]]: 1,
|
|
[gatherSources[1]]: 2,
|
|
},
|
|
reserves: 1,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
g.spice[destination] = 100;
|
|
g.leaderSkills = {
|
|
deck: LEADER_SKILL_CARDS.map((card) => card.id).filter(
|
|
(id) => id !== 'planetologist',
|
|
),
|
|
offers: {},
|
|
assignments: [
|
|
{
|
|
skill: 'planetologist',
|
|
leader: 'emperor-0',
|
|
owner: 'p',
|
|
},
|
|
],
|
|
};
|
|
return g;
|
|
}
|
|
|
|
function rangeFixture(code: string) {
|
|
const g = fixture(code);
|
|
g.players[0].forces = { [rangeSource]: 2 };
|
|
g.players[0].reserves = 18;
|
|
g.players[0].elites!.forces = { [rangeSource]: 1 };
|
|
g.players[0].elites!.reserves = 4;
|
|
return g;
|
|
}
|
|
|
|
function gatherFixture(code: string) {
|
|
const g = fixture(code);
|
|
delete g.players[0].forces[rangeSource];
|
|
g.players[0].reserves = 15;
|
|
delete g.players[0].elites!.forces[rangeSource];
|
|
g.players[0].elites!.reserves = 2;
|
|
return g;
|
|
}
|
|
|
|
function projection(g: Game, difficulty: Difficulty): GameView {
|
|
const view = viewGame(g, 'p');
|
|
view.players[0].bot = difficulty;
|
|
return view;
|
|
}
|
|
|
|
void test('all profiles propose and can execute both Planetologist movement alternatives', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
const view = projection(g, difficulty);
|
|
const before = structuredClone(view);
|
|
const moves = botActions(view).filter(
|
|
(action) =>
|
|
action.type === 'move' &&
|
|
(action.planetologist === 'range' || action.planetologist === 'gather'),
|
|
);
|
|
assert.deepEqual(view, before, `${difficulty} mutated its private view`);
|
|
|
|
const range = moves.find(
|
|
(action) =>
|
|
action.planetologist === 'range' &&
|
|
action.from === rangeSource &&
|
|
gameDistance(
|
|
g,
|
|
rangeSource,
|
|
location(String(action.territory), Number(action.sector)),
|
|
(key) => splitLocation(key).sector === g.storm,
|
|
) === 2,
|
|
);
|
|
assert.ok(range, `${difficulty} omitted the boosted-range branch`);
|
|
const rangeDestination = location(
|
|
String(range.territory),
|
|
Number(range.sector),
|
|
);
|
|
const ranged = applyAction(structuredClone(g), 'p', range);
|
|
assert.equal(ranged.players[0].forces[rangeDestination], 2);
|
|
assert.equal(ranged.players[0].elites!.forces[rangeDestination], 1);
|
|
|
|
const gather = moves.find(
|
|
(action) =>
|
|
action.planetologist === 'gather' &&
|
|
location(String(action.territory), Number(action.sector)) ===
|
|
destination &&
|
|
gatherSources.every(
|
|
(source) =>
|
|
Number((action.forces as Record<string, number>)[source]) > 0,
|
|
),
|
|
);
|
|
assert.ok(gather, `${difficulty} omitted the two-origin branch`);
|
|
assert.deepEqual(
|
|
new Set(
|
|
Object.keys(gather.forces as Record<string, number>).map(
|
|
(source) => splitLocation(source).territory,
|
|
),
|
|
),
|
|
new Set(['wind_pass', 'wind_pass_north']),
|
|
);
|
|
const gathered = applyAction(structuredClone(g), 'p', gather);
|
|
assert.equal(gathered.players[0].forces[destination], 5);
|
|
assert.equal(gathered.players[0].elites!.forces[destination], 3);
|
|
assert.equal(
|
|
Object.values(gathered.players[0].forces).reduce(
|
|
(total, count) => total + count,
|
|
0,
|
|
),
|
|
7,
|
|
);
|
|
assert.equal(
|
|
Object.values(gathered.players[0].elites!.forces).reduce(
|
|
(total, count) => total + count,
|
|
0,
|
|
),
|
|
4,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('each profile actually chooses a useful Planetologist move before ordinary ground moves', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const rangeGame = rangeFixture(difficulty === 'Easy' ? 'PR3' : 'PR0');
|
|
const range = botActions(projection(rangeGame, difficulty))[0];
|
|
assert.equal(range.type, 'move', difficulty);
|
|
assert.equal(range.planetologist, 'range', difficulty);
|
|
assert.equal(range.from, rangeSource, difficulty);
|
|
assert.equal(
|
|
location(String(range.territory), Number(range.sector)),
|
|
destination,
|
|
difficulty,
|
|
);
|
|
assert.doesNotThrow(() => applyAction(rangeGame, 'p', range));
|
|
|
|
const gatherGame = gatherFixture(difficulty === 'Easy' ? 'PG16' : 'PG0');
|
|
const gather = botActions(projection(gatherGame, difficulty))[0];
|
|
assert.equal(gather.type, 'move', difficulty);
|
|
assert.equal(gather.planetologist, 'gather', difficulty);
|
|
assert.equal(
|
|
location(String(gather.territory), Number(gather.sector)),
|
|
destination,
|
|
difficulty,
|
|
);
|
|
assert.deepEqual(
|
|
new Set(Object.keys(gather.forces as Record<string, number>)),
|
|
new Set(gatherSources),
|
|
difficulty,
|
|
);
|
|
assert.doesNotThrow(() => applyAction(gatherGame, 'p', gather));
|
|
}
|
|
});
|
|
|
|
void test('ordinary callers keep ordinary range and an unavailable skill creates no special bot actions', () => {
|
|
const g = fixture();
|
|
g.leaderSkills!.assignments = [];
|
|
g.leaderSkills!.deck.push('planetologist');
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const actions = botActions(projection(g, difficulty));
|
|
assert.equal(
|
|
actions.some((action) => action.planetologist !== undefined),
|
|
false,
|
|
difficulty,
|
|
);
|
|
assert.equal(
|
|
actions.some(
|
|
(action) =>
|
|
action.type === 'move' &&
|
|
action.from === rangeSource &&
|
|
location(String(action.territory), Number(action.sector)) ===
|
|
destination,
|
|
),
|
|
false,
|
|
`${difficulty} received the unavailable extra range`,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('bots do not propose the movement profile with an expansion faction present', () => {
|
|
const g = fixture();
|
|
g.players[1].faction = 'ixians';
|
|
for (const difficulty of DIFFICULTIES)
|
|
assert.equal(
|
|
botActions(projection(g, difficulty)).some(
|
|
(action) => action.planetologist !== undefined,
|
|
),
|
|
false,
|
|
difficulty,
|
|
);
|
|
});
|