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.
183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
import type { Card } from './cards';
|
|
import {
|
|
defenseTypes,
|
|
isDefenseCard,
|
|
validBattleCardPair,
|
|
weaponTypes,
|
|
} from './battle-cards';
|
|
import type { LeaderSkillId } from './leader-skill-cards';
|
|
|
|
export type DirectLeaderSkillId =
|
|
| 'warmaster'
|
|
| 'master-of-assassins'
|
|
| 'swordmaster-of-ginaz'
|
|
| 'killer-medic'
|
|
| 'prana-bindu-adept';
|
|
|
|
export type BattleLeaderSkill = {
|
|
skill: LeaderSkillId;
|
|
leader: string;
|
|
/** Ordinary assignments are publicly face up until deliberately concealed. */
|
|
faceUp: boolean;
|
|
/** A captured skill grants only its attached leader's lower battle effect. */
|
|
captured: boolean;
|
|
};
|
|
|
|
export type AppliedLeaderSkill = {
|
|
skill: DirectLeaderSkillId | 'planetologist';
|
|
amount: 1 | 2 | 3;
|
|
mode: 'normal' | 'skilled';
|
|
};
|
|
|
|
export type LeaderSkillBattleBonus = {
|
|
bonus: number;
|
|
applied: AppliedLeaderSkill[];
|
|
};
|
|
|
|
const directSkills = new Set<LeaderSkillId>([
|
|
'warmaster',
|
|
'master-of-assassins',
|
|
'swordmaster-of-ginaz',
|
|
'killer-medic',
|
|
'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);
|
|
}
|
|
|
|
function qualifies(
|
|
skill: DirectLeaderSkillId,
|
|
weapon: Card | undefined,
|
|
defense: Card | undefined,
|
|
) {
|
|
if (skill === 'warmaster')
|
|
return [weapon, defense].some((card) => card?.kind === 'worthless');
|
|
if (skill === 'master-of-assassins')
|
|
return weaponTypes(weapon).includes('poison');
|
|
if (skill === 'swordmaster-of-ginaz')
|
|
return weaponTypes(weapon).includes('projectile');
|
|
if (skill === 'killer-medic')
|
|
return defenseTypes(defense).includes('snooper');
|
|
return defenseTypes(defense).includes('shield');
|
|
}
|
|
|
|
/**
|
|
* Quote the five card-role strength families after weapon survival is known.
|
|
* Printed disc strength and bounty remain outside this additive score receipt.
|
|
*/
|
|
export function leaderSkillBattleBonus(input: {
|
|
assignments: readonly BattleLeaderSkill[];
|
|
/** Cheap Hero substitutes for another leader for normal +1 training only. */
|
|
selectedLeader: { id: string; kind: 'disc' | 'hero' } | undefined;
|
|
weapon: Card | undefined;
|
|
defense: Card | undefined;
|
|
skilledLeaderSurvives: boolean;
|
|
}): 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;
|
|
if (
|
|
input.selectedLeader.kind === 'disc' &&
|
|
assignment.leader === input.selectedLeader.id &&
|
|
(!assignment.faceUp || assignment.captured) &&
|
|
input.skilledLeaderSurvives
|
|
)
|
|
applied.push({ skill: assignment.skill, amount: 3, mode: 'skilled' });
|
|
else if (
|
|
assignment.leader !== input.selectedLeader.id &&
|
|
assignment.faceUp &&
|
|
!assignment.captured
|
|
)
|
|
applied.push({ skill: assignment.skill, amount: 1, mode: 'normal' });
|
|
}
|
|
return {
|
|
bonus: applied.reduce((sum, receipt) => sum + receipt.amount, 0),
|
|
applied,
|
|
};
|
|
}
|