Prototype Planetologist movement and Special-card battles

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.
This commit is contained in:
Quintine committed 2026-09-13 18:16:36 +10:00
1 parent 6e6fecc9d4
commit 5082c81353
22 files changed
+2473 -93

No files matched your search

+514
View File
@@ -0,0 +1,514 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
import {
PLANETOLOGIST_BASE_SPECIALS,
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
type BattleLeaderSkill,
} from '../game/leader-skill-combat';
import {
BattleResolutionQuoteError,
quoteBattleResolution,
type BattleResolutionInput,
type ResolutionCombatant,
} from '../game/battle-resolution-quote';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function special(effect = 'atomics') {
const card = baseDeck().find((candidate) => candidate.effect === effect);
assert.ok(card);
return card;
}
const assignment = (
leader: string,
faceUp = false,
captured = false,
): BattleLeaderSkill => ({
skill: 'planetologist',
leader,
faceUp,
captured,
});
void test('the alternate battle role is bound to the nine exact base green Specials', () => {
const eligible = baseDeck().filter(isPlanetologistBattleSpecialCard);
assert.deepEqual(
eligible.map((card) => card.id),
PLANETOLOGIST_BASE_SPECIALS.map((card) => card.id),
);
assert.deepEqual(
eligible.map((card) => card.name),
[
'Family Atomics',
'Weather Control',
'Hajr',
'Tleilaxu Ghola',
'Harvester',
'Karama',
'Karama',
'Truthtrance',
'Truthtrance',
],
);
assert.equal(
baseDeck()
.filter((card) => card.kind === 'hero')
.some(isPlanetologistBattleSpecialCard),
false,
);
assert.equal(ixBattleCards().some(isPlanetologistBattleSpecialCard), false);
assert.equal(
isPlanetologistBattleSpecialCard({
...special(),
id: 'forged-green-special',
}),
false,
);
});
void test('only the actual concealed or captured assigned leader may use the physical substitute', () => {
const card = special();
assert.equal(
canUsePlanetologistBattleSpecial({
assignments: [assignment('leader')],
selectedLeader: 'leader',
card,
}),
true,
);
assert.equal(
canUsePlanetologistBattleSpecial({
assignments: [assignment('leader', false, true)],
selectedLeader: 'leader',
card,
}),
true,
);
for (const input of [
{
assignments: [assignment('leader')],
selectedLeader: 'other',
card,
},
{
assignments: [assignment('leader', true)],
selectedLeader: 'leader',
card,
},
{
assignments: [
{
...assignment('leader'),
skill: 'warmaster' as const,
},
],
selectedLeader: 'leader',
card,
},
])
assert.equal(canUsePlanetologistBattleSpecial(input), false);
});
function battle(): BattleResolutionInput {
const side = (
id: string,
faction: 'emperor' | 'guild',
): ResolutionCombatant => {
const leader = { ...leaders(faction)[0] };
return {
id,
faction,
spice: 10,
hand: [],
leader,
plan: {
dial: id === 'a' ? 1 : 0,
support: 0,
leader: leader.id,
weapon: null,
defense: null,
},
forces: {
normal: 5,
elite: 0,
eliteStrength: 2,
freeSupport: false,
},
};
};
return {
advanced: false,
turn: 2,
territory: 'arrakeen',
attacker: side('a', 'emperor'),
defender: side('d', 'guild'),
voters: [
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
{ id: 'd', beneficiary: 'd', called: false, traitors: [] },
],
participants: [
{ id: 'a', faction: 'emperor' },
{ id: 'd', faction: 'guild' },
],
physicalCards: baseDeck(),
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
function playPlanetologist(input: BattleResolutionInput, card = special()) {
input.attacker.hand = [card];
input.attacker.plan.weapon = card.id;
input.attacker.leaderSkills = [assignment(input.attacker.plan.leader!)];
return card;
}
void test('a surviving skilled disc gains two without activating the Special and discards it after winning', () => {
const input = battle();
const card = playPlanetologist(input);
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.deepEqual(quote.leaderSkillBonuses.attacker, {
bonus: 2,
applied: [{ skill: 'planetologist', amount: 2, mode: 'skilled' }],
});
assert.equal(quote.effects?.defenderDead, false);
assert.equal(quote.winner, 'a');
assert.deepEqual(quote.discarded, [{ player: 'a', card: card.id }]);
assert.deepEqual(quote.winnerCards, []);
assert.deepEqual(input, before);
});
void test('leader death suppresses the two-point band but never releases the revealed Special', () => {
const input = battle();
const card = playPlanetologist(input, special('weather'));
const poison = baseDeck().find((candidate) => candidate.kind === 'poison')!;
input.defender.hand = [poison];
input.defender.plan.weapon = poison.id;
const quote = quoteBattleResolution(input);
assert.equal(quote.leaderDeaths.attacker, true);
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
assert.ok(
quote.discarded.some(
(entry) => entry.player === 'a' && entry.card === card.id,
),
);
});
void test('the later discard instruction also binds a successful traitor caller', () => {
const input = battle();
const card = playPlanetologist(input, special('hajr'));
input.voters = [
{
id: 'a',
beneficiary: 'a',
called: true,
traitors: [input.defender.plan.leader!],
},
input.voters[1],
];
const quote = quoteBattleResolution(input);
assert.equal(quote.result, 'traitor');
assert.equal(quote.winner, 'a');
assert.deepEqual(quote.discarded, [{ player: 'a', card: card.id }]);
assert.deepEqual(quote.winnerCards, []);
});
void test('Moritani cleanup cannot retain or reserve the mandatory Planetologist discard', () => {
const input = battle();
const card = playPlanetologist(input, special('truthtrance'));
const worthless = baseDeck().find(
(candidate) => candidate.kind === 'worthless',
)!;
const poison = baseDeck().find(
(candidate) => candidate.kind === 'poison',
)!;
input.attacker.ally = 'm';
input.attacker.hand = [card, worthless];
input.attacker.plan.defense = worthless.id;
input.defender.hand = [poison];
input.defender.plan.weapon = poison.id;
input.participants = [
...input.participants,
{ id: 'm', faction: 'moritani', ally: 'a' },
];
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, 'd');
assert.deepEqual(quote.retention?.played, [worthless.id]);
assert.deepEqual(quote.retention?.eligible, [worthless.id]);
assert.ok(
quote.discarded.some(
(entry) => entry.player === 'a' && entry.card === card.id,
),
);
});
void test('quote validation rejects the Special in another role or with another leader without mutation', () => {
for (const mutate of [
(input: BattleResolutionInput, card: Card) => {
input.attacker.plan.weapon = null;
input.attacker.plan.defense = card.id;
},
(input: BattleResolutionInput) => {
input.attacker.leaderSkills = [assignment('some-other-leader')];
},
(input: BattleResolutionInput) => {
input.attacker.leaderSkills = [assignment(input.attacker.plan.leader!, true)];
},
(input: BattleResolutionInput, card: Card) => {
input.attacker.hand = [{ ...card, name: 'Forged Special' }];
},
(input: BattleResolutionInput) => {
input.attacker.leader!.dead = true;
},
(input: BattleResolutionInput) => {
delete input.attacker.leader;
},
]) {
const input = battle();
const card = playPlanetologist(input);
mutate(input, card);
const before = structuredClone(input);
assert.throws(
() => quoteBattleResolution(input),
BattleResolutionQuoteError,
);
assert.deepEqual(input, before);
}
});
function finishSetup(state: Game) {
let game = state;
for (let step = 0; game.status === 'setup' && step < 10; step += 1) {
const owner = game.players.find((player) => player.traitorChoices.length);
assert.ok(owner, `setup stalled at ${game.setupStage}`);
game = applyAction(game, owner.id, {
type: 'traitor',
leader: owner.traitorChoices[0],
});
}
assert.equal(game.status, 'playing');
return game;
}
function engineBattle(hide: boolean) {
let game = createGame('PLANETBATTLE', newPlayer('a', 'A', 'emperor'));
joinGame(game, newPlayer('d', 'D', 'guild'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === 'planetologist',
);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
const attackerOffer = game.leaderSkills!.offers.a;
assert.equal(attackerOffer.cards[0], 'planetologist');
game = applyAction(game, 'a', {
type: 'leaderSkill',
event: attackerOffer.event,
skill: 'planetologist',
leader: 'emperor-0',
});
const defenderOffer = game.leaderSkills!.offers.d;
game = applyAction(game, 'd', {
type: 'leaderSkill',
event: defenderOffer.event,
skill: defenderOffer.cards[0],
leader: 'guild-0',
});
game = finishSetup(game);
for (const player of game.players) {
game.deck.push(...player.hand);
player.hand = [];
player.forces = { 'arrakeen:10': 5 };
player.reserves = 15;
}
const index = game.deck.findIndex((card) => card.effect === 'atomics');
assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1);
game.players[0].hand.push(card);
Object.assign(game, {
phase: 6,
storm: 18,
order: ['a', 'd'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
game = applyAction(game, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'd',
});
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, {
type: 'leaderSkillVisibility',
event: decision.event,
hide: decision.player === 'a' ? hide : false,
});
}
while (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, {
type: 'declineBattlePower',
});
return { game, card };
}
function unchanged(game: Game, player: string, action: Action, error: RegExp) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, player, action), error);
assert.deepEqual(game, before);
}
void test('the real engine accepts only a concealed Planetologist plan and preserves actual-Weapon Voice', () => {
const publicSkill = engineBattle(false);
unchanged(
publicSkill.game,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: publicSkill.card.id,
},
/behind the shield/,
);
const { game, card } = engineBattle(true);
unchanged(
game,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: card.id,
},
/eligible Planetologist Special/,
);
const poisonIndex = game.deck.findIndex(
(candidate) => candidate.kind === 'poison',
);
assert.ok(poisonIndex >= 0);
const [poison] = game.deck.splice(poisonIndex, 1);
game.players[0].hand.push(poison);
game.battle!.voice = { target: 'a', kind: 'poison', must: true };
unchanged(
game,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
},
/comply with the Voice/,
);
delete game.battle!.voice;
const planned = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
});
assert.equal(planned.battle!.plans.a.weapon, card.id);
assert.equal(planned.storm, game.storm, 'Family Atomics utility did not fire');
});
void test('all four bot profiles offer a legal physical Planetologist battle plan', () => {
const { game, card } = engineBattle(true);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const plans = botActions(view).filter(
(action) =>
action.type === 'battlePlan' &&
action.leader === 'emperor-0' &&
action.weapon === card.id,
);
assert.ok(plans.length, `${difficulty} omitted the physical Special`);
assert.doesNotThrow(
() => applyAction(game, 'a', plans[0]),
`${difficulty} emitted an invalid Planetologist plan`,
);
}
});
void test('the real engine resolves and discards a winning Planetologist Special exactly once', () => {
const { game: battleState, card } = engineBattle(true);
let game = applyAction(battleState, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
assert.equal(
game.discard.filter((candidate) => candidate.id === card.id).length,
1,
);
assert.equal(
game.players[0].hand.some((candidate) => candidate.id === card.id),
false,
);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.lastBattleContext?.winnerDiscards?.completed, true);
});
void test('a skilled disc that dies between traitor votes cannot supply a stale Planetologist plan', () => {
const { game: battleState, card } = engineBattle(true);
let game = applyAction(battleState, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
const skilled = game.players[0].leaders.find(
(leader) => leader.id === 'emperor-0',
)!;
skilled.dead = true;
skilled.deaths += 1;
unchanged(
game,
'd',
{ type: 'traitorCall', call: false },
/assigned Planetologist leader’s weapon/,
);
});
+239
View File
@@ -0,0 +1,239 @@
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,
);
});
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import {
PlanetologistMovement,
planetologistMoveDraft,
} from '../components/planetologist-movement';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { location } from '../game/board';
const destination = 'polar_sink';
const rangeSource = location('false_wall_west', 16);
const gatherSources = [
location('wind_pass', 14),
location('wind_pass_north', 17),
] as const;
function fixture(): Game {
const g = createGame('PLANETUI', 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;
}
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.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 markup(g: Game, viewer = 'p', busy = false) {
return renderToStaticMarkup(
createElement(PlanetologistMovement, {
game: viewGame(g, viewer),
act() {},
busy,
destination,
sector: 0,
}),
);
}
void test('the owner sees both modes, physical source controls and the shared board destination', () => {
const g = fixture();
const html = markup(g);
assert.match(html, /Planetologist movement/);
assert.match(html, /One territory farther, maximum three/);
assert.match(html, /Gather from two territories/);
assert.match(html, /Destination: Polar Sink, sector 0/);
assert.match(html, /False Wall West, sector 16 · 2 forces/);
assert.match(html, /Elite from this group · 1 available/);
assert.match(html, /Select forces from one territory/);
assert.match(html, /disabled=""/);
assert.equal(markup(g, 'o'), '');
assert.ok((markup(g, 'p', true).match(/disabled=""/g) ?? []).length >= 2);
});
void test('drafts send only the public mode and exact physical counts, and both settle through the engine', () => {
const g = fixture();
const view = viewGame(g, 'p');
const range = planetologistMoveDraft(
view,
'range',
destination,
0,
{ [rangeSource]: 2 },
{ [rangeSource]: 1 },
);
assert.equal(range.blocked, null);
assert.deepEqual(range.action, {
type: 'move',
planetologist: 'range',
forces: { [rangeSource]: 2 },
eliteForces: { [rangeSource]: 1 },
territory: destination,
sector: 0,
});
assert.equal(Object.hasOwn(range.action!, 'leader'), false);
const ranged = applyAction(structuredClone(g), 'p', range.action!);
assert.equal(ranged.players[0].forces[`${destination}:0`], 2);
assert.equal(ranged.players[0].elites!.forces[`${destination}:0`], 1);
const gather = planetologistMoveDraft(
view,
'gather',
destination,
0,
{ [gatherSources[0]]: 2, [gatherSources[1]]: 3 },
{ [gatherSources[0]]: 1, [gatherSources[1]]: 2 },
);
assert.equal(gather.blocked, null);
assert.deepEqual(gather.action, {
type: 'move',
planetologist: 'gather',
forces: { [gatherSources[0]]: 2, [gatherSources[1]]: 3 },
eliteForces: { [gatherSources[0]]: 1, [gatherSources[1]]: 2 },
territory: destination,
sector: 0,
});
assert.equal(Object.hasOwn(gather.action!, 'leader'), false);
const gathered = applyAction(structuredClone(g), 'p', gather.action!);
assert.equal(gathered.players[0].forces[`${destination}:0`], 5);
assert.equal(gathered.players[0].elites!.forces[`${destination}:0`], 3);
});
void test('the client rejects wrong origin counts and unavailable elite custody before sending', () => {
const view = viewGame(fixture(), 'p');
assert.match(
planetologistMoveDraft(
view,
'gather',
destination,
0,
{ [gatherSources[0]]: 1 },
{ [gatherSources[0]]: 0 },
).blocked!,
/exactly two different territories/,
);
assert.match(
planetologistMoveDraft(
view,
'range',
destination,
0,
{ [rangeSource]: 2 },
{ [rangeSource]: 0 },
).blocked!,
/normal and elite mix/,
);
const expansion = fixture();
expansion.players[1].faction = 'ixians';
assert.match(
planetologistMoveDraft(
viewGame(expansion, 'p'),
'range',
destination,
0,
{ [rangeSource]: 2 },
{ [rangeSource]: 1 },
).blocked!,
/expansion factions awaits/,
);
});
+410
View File
@@ -0,0 +1,410 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import {
applyAction,
createGame,
newPlayer,
joinGame,
initializeLeaderSkillsGameForAudit,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
import { TERRITORIES, distance, splitLocation, location } from '../game/board';
import { baseDeck } from '../game/cards';
import { placeFixtureHand } from './fixture-hand';
import { planetologistLeader } from '../game/planetologist-movement';
const source = 'red_chasm:7';
const restore = (g: Game): Game => JSON.parse(JSON.stringify(g));
const route = (from: string, to: string) =>
distance(from, to, (key) => splitLocation(key).sector === 18);
const sand = TERRITORIES.filter((t) => t.type === 'sand').flatMap((t) =>
t.sectors.filter((s) => s !== 18).map((s) => location(t.id, s)),
);
function target(range: number, from = source) {
const to = sand.find((key) => route(from, key) === range);
assert.ok(to, `route ${range} from ${from}`);
return to;
}
function move(range: number, mode?: 'range' | 'gather'): Action {
const to = splitLocation(target(range));
return {
type: 'move',
from: source,
amount: 3,
territory: to.territory,
sector: to.sector,
...(mode ? { planetologist: mode } : {}),
};
}
function fixture(faction: FactionId = 'emperor', advanced = false): Game {
let g = createGame(
'PLANETMOVE',
newPlayer('p', 'Planetologist', faction),
advanced,
);
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'planetologist');
let cursor = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = cursor-- === index ? 0 : 0xffffffff;
return array;
});
try {
g = initializeLeaderSkillsGameForAudit(g);
} finally {
mock.restoreAll();
}
for (let i = 0; g.status === 'setup' && i < 60; i++) {
let accepted = false;
for (const p of g.players) {
const view = viewGame(g, p.id);
const skills = view.leaderSkills!;
const options =
g.setupStage === 'leaderSkills' && skills.offer
? [
{
type: 'leaderSkill',
event: skills.offer.event,
skill: p.id === 'p' ? 'planetologist' : skills.offer.cards[0],
leader: skills.eligibleLeaders[0].id,
},
]
: g.setupStage === 'traitors' && p.traitorChoices.length
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
: g.setupStage === 'prediction' && p.faction === 'beneGesserit'
? [{ type: 'predict', faction: 'harkonnen', turn: 3 }]
: g.setupStage === 'forces' &&
p.faction === 'fremen' &&
p.reserves === 20
? [
{
type: 'fremenSetup',
placements: {
sietch_tabr: 10,
false_wall_south: 0,
false_wall_west: 0,
},
},
]
: g.setupStage === 'forces' &&
p.faction === 'beneGesserit' &&
!p.advisorSetup
? [
{
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
},
]
: botActions(view);
for (const a of options) {
try {
g = applyAction(g, p.id, a);
accepted = true;
break;
} catch {
/* Other seats can be waiting. */
}
}
if (accepted) break;
}
assert.ok(accepted, `setup ${g.setupStage}`);
}
assert.equal(g.status, 'playing');
assert.ok(planetologistLeader(g, 'p'));
Object.assign(g, {
phase: 5,
turn: 2,
storm: 18,
active: 'p',
order: ['p', 'h'],
movementRemaining: ['p', 'h'],
ready: [],
response: null,
decision: null,
phaseOpening: null,
});
for (const p of g.players) {
g.deck.push(...p.hand);
p.hand = [];
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.shipped = true;
p.moved = 0;
p.advisors = {};
if (p.elites) p.elites = { ...p.elites, forces: {}, reserves: 3, tanks: 0 };
}
g.players[0].forces = { [source]: 3 };
g.players[0].reserves = 17;
return g;
}
function unchanged(g: Game, a: Action, error: RegExp) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'p', a), error);
assert.deepEqual(g, before);
}
function custody(g: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const ids = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
].map((c) => c.id);
assert.equal(ids.length, 33);
assert.equal(new Set(ids).size, 33);
}
function gather(g: Game, destinationKey?: string, range = 1) {
const to =
destinationKey ??
sand.find(
(dest) =>
new Set(
sand
.filter(
(key) =>
route(key, dest) === range &&
splitLocation(key).territory !== splitLocation(dest).territory,
)
.map((key) => splitLocation(key).territory),
).size >= 2,
)!;
const origins = sand.filter(
(key) =>
route(key, to) === range &&
splitLocation(key).territory !== splitLocation(to).territory,
);
const first = origins[0];
const second = origins.find(
(key) => splitLocation(key).territory !== splitLocation(first).territory,
)!;
assert.ok(first && second);
g.players[0].forces = { [first]: 3, [second]: 2 };
g.players[0].reserves = 15;
const destination = splitLocation(to);
return {
a: {
type: 'move',
planetologist: 'gather',
forces: { [first]: 2, [second]: 1 },
territory: destination.territory,
sector: destination.sector,
} as Action,
first,
second,
to,
};
}
void test('Planetologist range is explicit, capped at three, and moves physical forces once', () => {
for (const advanced of [false, true]) {
const g = fixture('emperor', advanced);
unchanged(g, move(2), /more than 1/);
unchanged(g, move(3, 'range'), /more than 2/);
const next = applyAction(restore(g), 'p', move(2, 'range'));
assert.equal(next.players[0].forces[target(2)], 3);
assert.equal(next.players[0].moved, 1);
custody(next);
assert.deepEqual(normalizeAutomaticGame(restore(next)), next);
const city = fixture();
city.players[0].forces['arrakeen:10'] = 1;
city.players[0].reserves--;
unchanged(city, move(4, 'range'), /more than 3/);
assert.equal(applyAction(city, 'p', move(3, 'range')).players[0].moved, 1);
}
});
void test('two-origin movement keeps separate sources and elites, one shared arrival and one movement use', () => {
const g = fixture('emperor', true);
const { a, first, second, to } = gather(g);
g.players[0].elites!.forces = { [first]: 1, [second]: 1 };
g.players[0].elites!.reserves = 1;
a.eliteForces = { [first]: 1, [second]: 1 };
const next = applyAction(restore(g), 'p', a);
assert.equal(next.players[0].forces[to], 3);
assert.equal(next.players[0].forces[first], 1);
assert.equal(next.players[0].forces[second], 1);
assert.equal(next.players[0].elites!.forces[to], 2);
assert.equal(next.players[0].elites!.reserves, 1);
assert.equal(next.players[0].moved, 1);
custody(next);
assert.match(next.log.map((e) => e.text).join('\n'), / and /);
const without = { ...a };
delete without.planetologist;
unchanged(g, without, /one territory/);
unchanged(g, { ...a, planetologist: 'range' }, /one territory/);
unchanged(g, { ...a, forces: { [first]: 2 } }, /exactly two/);
const far = fixture();
const selected = gather(far, undefined, 2);
unchanged(far, selected.a, /more than 1/);
});
void test('Fremen extra range preserves a real Karama response and replacement keeps Planetologist', () => {
for (const advanced of [false, true]) {
const g = fixture('fremen', advanced);
const karama = baseDeck().find((c) => c.effect === 'karama')!;
placeFixtureHand(g, 1, [karama]);
const ordinaryBonus = applyAction(g, 'p', move(2, 'range'));
assert.equal(ordinaryBonus.response, null);
assert.equal(ordinaryBonus.players[0].moved, 1);
const pending = applyAction(g, 'p', move(3, 'range'));
assert.equal(pending.response?.kind, 'fremenMovement');
assert.equal(pending.players[0].moved, 0);
assert.deepEqual(pending.pendingFremenMove?.order.planetologist, {
leader: planetologistLeader(g, 'p'),
mode: 'range',
});
const allowed = applyAction(restore(pending), 'h', {
type: 'passResponse',
});
assert.equal(allowed.players[0].forces[target(3)], 3);
assert.equal(allowed.players[0].moved, 1);
custody(allowed);
const canceled = applyAction(restore(pending), 'h', {
type: 'card',
card: karama.id,
mode: 'cancel',
});
assert.deepEqual(canceled.players[0].forces, g.players[0].forces);
assert.equal(canceled.players[0].moved, 0);
unchanged(canceled, move(3, 'range'), /more than 2/);
const replacement = applyAction(restore(canceled), 'p', move(2, 'range'));
assert.equal(replacement.players[0].forces[target(2)], 3);
assert.equal(replacement.players[0].moved, 1);
custody(replacement);
assert.equal(
replacement.discard.filter((c) => c.id === karama.id).length,
1,
);
}
});
void test('Fremen gather response restores both origins and rejects stale or forged skill receipts before payment', () => {
const g = fixture('fremen', true);
const { a, to } = gather(g, undefined, 2);
const karama = baseDeck().find((c) => c.effect === 'karama')!;
placeFixtureHand(g, 1, [karama]);
const pending = applyAction(g, 'p', a);
assert.equal(pending.response?.kind, 'fremenMovement');
assert.equal(pending.pendingFremenMove?.order.origins?.length, 2);
const saved = restore(pending);
const allowed = applyAction(saved, 'h', { type: 'passResponse' });
assert.equal(allowed.players[0].forces[to], 3);
assert.equal(allowed.players[0].moved, 1);
custody(allowed);
for (const corrupt of [
(s: Game) => {
s.pendingFremenMove!.order.planetologist!.leader = 'harkonnen-0';
},
(s: Game) => {
s.pendingFremenMove!.order.origins!.pop();
},
(s: Game) => {
s.pendingFremenMove!.order.planetologist!.mode = 'range';
},
]) {
const stale = restore(pending);
corrupt(stale);
const before = structuredClone(stale);
assert.throws(() =>
applyAction(stale, 'h', {
type: 'card',
card: karama.id,
mode: 'cancel',
}),
);
assert.deepEqual(stale, before);
}
});
void test('captured or dead Planetologist gives neither side a native movement benefit; invalid modes are immutable', () => {
const g = fixture();
const leader = g.players[0].leaders.find(
(l) => l.id === planetologistLeader(g, 'p'),
)!;
unchanged(
g,
{
...move(2, 'range'),
planetologist: { leader: leader.id, mode: 'range' },
},
/available Planetologist/,
);
unchanged(
g,
{ ...move(2, 'range'), origins: ['red_chasm'] },
/available Planetologist/,
);
leader.capturedBy = 'h';
assert.equal(planetologistLeader(g, 'p'), null);
assert.equal(planetologistLeader(g, 'h'), null);
unchanged(g, move(2, 'range'), /available Planetologist/);
delete leader.capturedBy;
leader.dead = true;
assert.equal(planetologistLeader(g, 'p'), null);
unchanged(g, move(2, 'range'), /available Planetologist/);
});
void test('Bene Gesserit gather resolves determined mixed-source stances and preserves advisor locks', () => {
for (const existingAdvisors of [false, true]) {
const g = fixture('beneGesserit', true);
const { a, first, second, to } = gather(g);
const origin = splitLocation(first).territory,
destination = splitLocation(to).territory;
g.players[0].advisors = { [origin]: {} };
// Keep the source advisors beside another faction so normalization cannot retire their stance.
g.players[1].forces = { [first]: 1 };
g.players[1].reserves = 19;
if (existingAdvisors) {
g.players[0].forces[to] = 1;
g.players[0].reserves--;
g.players[0].advisors[destination] = {};
g.players[1].forces[to] = 1;
g.players[1].reserves--;
}
const next = applyAction(restore(g), 'p', a);
assert.equal(!!next.players[0].advisors?.[destination], existingAdvisors);
assert.equal(next.players[0].forces[to], existingAdvisors ? 4 : 3);
assert.equal(next.players[0].forces[first], 1);
assert.equal(next.players[0].forces[second], 1);
custody(next);
}
const mixed = fixture('beneGesserit', true);
const { a, first, to } = gather(mixed);
mixed.players[0].advisors = { [splitLocation(first).territory]: {} };
mixed.players[1].forces = { [first]: 1, [to]: 1 };
mixed.players[1].reserves = 18;
unchanged(mixed, a, /combined flip response/);
const uniform = fixture('beneGesserit', true);
const draft = gather(uniform);
uniform.players[0].advisors = {
[splitLocation(draft.first).territory]: { lockedTurn: 2 },
[splitLocation(draft.second).territory]: {},
};
uniform.players[1].forces = {
[draft.first]: 1,
[draft.second]: 1,
[draft.to]: 1,
};
uniform.players[1].reserves = 17;
unchanged(uniform, { ...draft.a, fighters: true }, /cannot flip/);
const advisors = applyAction(restore(uniform), 'p', draft.a);
assert.equal(
advisors.players[0].advisors?.[splitLocation(draft.to).territory]
?.lockedTurn,
2,
);
assert.equal(advisors.players[0].forces[draft.to], 3);
custody(advisors);
});