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

+58 -9
View File
@@ -34,6 +34,9 @@ import {
type HomeworldExplosionLosses,
} from './homeworld-battle-rules';
import {
PLANETOLOGIST_BASE_SPECIALS,
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
leaderSkillBattleBonus,
type BattleLeaderSkill,
type LeaderSkillBattleBonus,
@@ -213,6 +216,35 @@ function validateCombatant(
(side.leader.id === p.leader && Number.isFinite(side.leader.strength)),
'The controlled leader must match the revealed plan.',
);
const selectedPlanetologistCards = [
p.weapon,
p.defense,
p.leader,
side.lateDefense,
].filter(
(selectedId): selectedId is string =>
!!selectedId &&
PLANETOLOGIST_BASE_SPECIALS.some(
(card) => card.id === selectedId,
),
);
requireQuote(
selectedPlanetologistCards.every((selectedId) => {
const selectedCard = side.hand.find((card) => card.id === selectedId);
return (
selectedId === p.weapon &&
side.leader?.id === p.leader &&
!side.leader.dead &&
isPlanetologistBattleSpecialCard(selectedCard) &&
canUsePlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [],
selectedLeader: p.leader,
card: selectedCard,
})
);
}),
'A green Special may replace only the assigned Planetologist leader’s weapon.',
);
return played;
}
/**
@@ -614,16 +646,30 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
auditCount(other.hand, auditor.usedCards, auditor.survived);
}
let retention: MoritaniRetention | null = null;
const mandatoryPlanetologistDiscard = (
side: ResolutionCombatant,
selected: string,
) =>
selected === side.plan.weapon &&
canUsePlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [],
selectedLeader: side.plan.leader,
card: card(side, selected),
});
const moritani = input.participants.find((p) => p.faction === 'moritani');
if (winner && moritani) {
const loser = winner === a ? d : a,
loserPlayed = loser === a ? played.attacker : played.defender;
const eligible = loserPlayed.filter((selected) =>
canRetainBattleCard(
card(loser, selected)!,
ac || dc,
!!loser.poisonTooth,
),
const retainablePlayed = loserPlayed.filter(
(selected) => !mandatoryPlanetologistDiscard(loser, selected),
);
const eligible = retainablePlayed.filter(
(selected) =>
canRetainBattleCard(
card(loser, selected)!,
ac || dc,
!!loser.poisonTooth,
),
);
if (
loser.id !== moritani.id &&
@@ -640,7 +686,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
player: loser.id,
territory: input.territory,
turn: input.turn,
played: [...loserPlayed],
played: [...retainablePlayed],
eligible,
stage: 'choose',
};
@@ -651,9 +697,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
.filter(
(selected) =>
!(
retention?.player === s.id && retention.played.includes(selected)
retention?.player === s.id &&
retention.played.includes(selected) &&
!mandatoryPlanetologistDiscard(s, selected)
) &&
(winner !== s ||
(mandatoryPlanetologistDiscard(s, selected) ||
winner !== s ||
!canRetainBattleCard(
card(s, selected)!,
ac || dc,