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

+146 -11
View File
@@ -1,4 +1,6 @@
import { discoveryBotActions } from './discovery-options';
import type { Card } from './cards';
import { canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
import { discoveryFlightBotActions } from './discovery-flight-options';
import { discoveryEntryBotActions } from './discovery-entry-options';
import { discoveryStormActions } from './discovery-storm-options';
@@ -48,7 +50,6 @@ import {
isWeaponCard,
isStoneBurner,
isDefenseCard,
validBattleCardPair,
defaultVoiceMatch,
playedVoiceMatch,
weaponKills,
@@ -72,6 +73,8 @@ import {
fremenReserveEntry,
guildTransportCost,
} from './bot-mobility';
import { planetologistLeader } from './planetologist-movement';
import { faction } from './catalog';
import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
@@ -670,9 +673,13 @@ function plans(g: GameView): Action[] {
commitments.every(
(element) => element.field !== field || element.value === value,
);
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
const specialForLeader = (card: Card | undefined, leader: string | null) =>
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
const weapons = [
null,
...(me.hand ?? []).filter(isWeaponCard).map((c) => c.id),
...(me.hand ?? []).filter(c => isWeaponCard(c) ||
me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id))).map(c => c.id),
].filter((id) => fixed('weapon', id));
const defenses = [
null,
@@ -752,9 +759,10 @@ function plans(g: GameView): Action[] {
) + (kwisatz ? 2 : 0);
const w = me.hand?.find((c) => c.id === weapon);
const d = me.hand?.find((c) => c.id === defense);
if (w && !isWeaponCard(w) && !specialForLeader(w,leader)) continue;
const stoneBattle =
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
if ((!leader && (weapon || defense)) || !validBattleCardPair(w, d))
if ((!leader && (weapon || defense)) || !validLeaderSkillBattleCardPair(w, d, specialForLeader(w,leader)))
continue;
if (voice) {
const used =
@@ -800,7 +808,11 @@ function plans(g: GameView): Action[] {
b.strongholdEffects[other.id],
);
const ownSurvivingStrength =
effects.stunned || effects.attackerDead ? 0 : strength;
effects.stunned || effects.attackerDead ? 0 : strength + leaderSkillBattleBonus({
assignments:battleSkills,
selectedLeader:leader ? {id:leader,kind:myLeader?'disc':'hero'} : undefined,
weapon:w,defense:d,skilledLeaderSurvives:true,
}).bonus;
const enemySurvivingStrength =
effects.stunned || (inspected && effects.defenderDead)
? 0
@@ -869,7 +881,7 @@ function plans(g: GameView): Action[] {
))
)
continue;
const effectiveWeapon = w && w.kind !== 'worthless' ? 6 : 0;
const effectiveWeapon = w && w.kind !== 'worthless' && !specialForLeader(w,leader) ? 6 : 0;
const effectiveDefense = d && d.kind !== 'worthless' ? 4 : 0;
const selfExplosion = battleWeaponsExplode(w, d);
const correctDefense = inspected
@@ -3088,6 +3100,13 @@ function policyActions(g: GameView): Action[] {
return { from, amount, elite };
})
.filter((source) => source.amount > 0);
const planetologist = planetologistLeader(g, me.id);
const groundMoves: {
action: Action;
destinationScore: number;
amount: number;
gather: boolean;
}[] = [];
if (movementAvailable)
for (const to of targets
.filter(
@@ -3100,15 +3119,131 @@ function policyActions(g: GameView): Action[] {
.slice(0, 24))
for (const { from, amount, elite } of movingSources) {
if (botGroundMoveAllowed(g, me, from, to.key, elite))
actions.push({
type: 'move',
from,
territory: to.t,
sector: to.s,
groundMoves.push({
action: {
type: 'move',
from,
territory: to.t,
sector: to.s,
amount,
elite,
},
destinationScore: to.score,
amount,
elite,
gather: false,
});
}
if (
movementAvailable &&
planetologist &&
!g.ornithopter?.active &&
g.players.every((player) => faction(player.faction).expansion === 'base')
) {
const usefulTargets = targets
.filter(
(to) =>
marker?.location.territory !== to.t && to.score > -1000,
);
for (const to of usefulTargets
.filter((target) =>
movingSources.some(
({ from, elite }) =>
botGroundMoveAllowed(
g,
me,
from,
target.key,
elite,
'range',
) && !botGroundMoveAllowed(g, me, from, target.key, elite),
),
)
.slice(0, 24))
for (const { from, amount, elite } of movingSources) {
if (
botGroundMoveAllowed(g, me, from, to.key, elite, 'range') &&
!botGroundMoveAllowed(g, me, from, to.key, elite)
)
groundMoves.push({
action: {
type: 'move',
planetologist: 'range',
from,
territory: to.t,
sector: to.s,
amount,
elite,
},
destinationScore: to.score,
amount,
gather: false,
});
}
const gatherSources = movingSources.slice(0, 12);
for (let first = 0; first < gatherSources.length; first += 1)
for (let second = first + 1; second < gatherSources.length; second += 1) {
const a = gatherSources[first],
b = gatherSources[second],
aTerritory = splitLocation(a.from).territory,
bTerritory = splitLocation(b.from).territory;
if (
aTerritory === bTerritory ||
(g.advanced &&
me.faction === 'beneGesserit' &&
isAdvisor(me, aTerritory) !== isAdvisor(me, bTerritory))
)
continue;
for (const to of usefulTargets
.filter(
(target) =>
botGroundMoveAllowed(
g,
me,
a.from,
target.key,
a.elite,
'gather',
) &&
botGroundMoveAllowed(
g,
me,
b.from,
target.key,
b.elite,
'gather',
),
)
.slice(0, 24)) {
groundMoves.push({
action: {
type: 'move',
planetologist: 'gather',
forces: { [a.from]: a.amount, [b.from]: b.amount },
...(me.elites
? {
eliteForces: {
[a.from]: a.elite,
[b.from]: b.elite,
},
}
: {}),
territory: to.t,
sector: to.s,
},
destinationScore: to.score,
amount: a.amount + b.amount,
gather: true,
});
}
}
}
groundMoves.sort(
(a, b) =>
b.destinationScore - a.destinationScore ||
b.amount - a.amount ||
Number(b.gather) - Number(a.gather),
);
actions.push(...groundMoves.map((candidate) => candidate.action));
if (reveal && !actions.length) actions.push(reveal);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g));