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

+90 -3
View File
@@ -1,5 +1,10 @@
import type { Card } from './cards';
import { defenseTypes, weaponTypes } from './battle-cards';
import {
defenseTypes,
isDefenseCard,
validBattleCardPair,
weaponTypes,
} from './battle-cards';
import type { LeaderSkillId } from './leader-skill-cards';
export type DirectLeaderSkillId =
@@ -19,8 +24,8 @@ export type BattleLeaderSkill = {
};
export type AppliedLeaderSkill = {
skill: DirectLeaderSkillId;
amount: 1 | 3;
skill: DirectLeaderSkillId | 'planetologist';
amount: 1 | 2 | 3;
mode: 'normal' | 'skilled';
};
@@ -37,6 +42,74 @@ const directSkills = new Set<LeaderSkillId>([
'prana-bindu-adept',
]);
/**
* Canonical GF9 base-deck green Special cards. Persisted IDs distinguish these
* physical components from expansion cards sharing the engine's broad
* `special` bucket. Cheap Hero and Cheap Heroine occupy the leader slot and
* are deliberately absent.
*/
export const PLANETOLOGIST_BASE_SPECIALS = [
{ id: 'treachery-24', name: 'Family Atomics', effect: 'atomics' },
{ id: 'treachery-25', name: 'Weather Control', effect: 'weather' },
{ id: 'treachery-26', name: 'Hajr', effect: 'hajr' },
{ id: 'treachery-27', name: 'Tleilaxu Ghola', effect: 'ghola' },
{ id: 'treachery-28', name: 'Harvester', effect: 'harvester' },
{ id: 'treachery-29', name: 'Karama', effect: 'karama' },
{ id: 'treachery-30', name: 'Karama', effect: 'karama' },
{ id: 'treachery-31', name: 'Truthtrance', effect: 'truthtrance' },
{ id: 'treachery-32', name: 'Truthtrance', effect: 'truthtrance' },
] as const;
const planetologistBaseSpecialById = new Map<
string,
(typeof PLANETOLOGIST_BASE_SPECIALS)[number]
>(
PLANETOLOGIST_BASE_SPECIALS.map((card) => [card.id, card]),
);
/** Shared engine/UI/bot eligibility: exact printed base component, no native use. */
export function isPlanetologistBattleSpecialCard(
card: Pick<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined,
): boolean {
if (!card || card.kind !== 'special') return false;
const canonical = planetologistBaseSpecialById.get(card.id);
return (
canonical?.name === card.name &&
canonical.effect === card.effect
);
}
/** The substitute role belongs only to the actual assigned leader in battle. */
export function canUsePlanetologistBattleSpecial(input: {
assignments: readonly BattleLeaderSkill[];
selectedLeader: string | null | undefined;
card: Pick<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined;
}): boolean {
return (
isPlanetologistBattleSpecialCard(input.card) &&
!!input.selectedLeader &&
input.assignments.some(
(assignment) =>
assignment.skill === 'planetologist' &&
assignment.leader === input.selectedLeader &&
(!assignment.faceUp || assignment.captured),
)
);
}
/** Planetologist fills the slot without becoming an actual Weapon card. */
export function validLeaderSkillBattleCardPair(
weapon: Card | undefined,
defense: Card | undefined,
planetologistWeapon: boolean,
): boolean {
if (!planetologistWeapon) return validBattleCardPair(weapon, defense);
return (
(!defense || isDefenseCard(defense)) &&
defense?.kind !== 'weirdingWay'
);
}
function isDirectSkill(skill: string): skill is DirectLeaderSkillId {
return directSkills.has(skill as LeaderSkillId);
}
@@ -71,6 +144,20 @@ export function leaderSkillBattleBonus(input: {
}): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = [];
if (
input.selectedLeader.kind === 'disc' &&
input.skilledLeaderSurvives &&
canUsePlanetologistBattleSpecial({
assignments: input.assignments,
selectedLeader: input.selectedLeader.id,
card: input.weapon,
})
)
applied.push({
skill: 'planetologist',
amount: 2,
mode: 'skilled',
});
for (const assignment of input.assignments) {
if (!isDirectSkill(assignment.skill)) continue;
if (!qualifies(assignment.skill, input.weapon, input.defense)) continue;