Files
Dune/game/leader-skill-combat.ts
T

250 lines
9.3 KiB
TypeScript

import type { Card } from './cards';
import {
defenseTypes,
isDefenseCard,
validBattleCardPair,
weaponTypes,
} from './battle-cards';
import type { LeaderSkillId } from './leader-skill-cards';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
import { ECAZ_TREACHERY_DEFINITIONS } from './ecaz-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' | 'mentat' | 'spice-banker';
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;
export const PLANETOLOGIST_IX_SPECIALS = [
{ id: 'ix-thumper', name: 'Thumper', effect: 'thumper' },
{ id: 'ix-harvester', name: 'Harvester', effect: 'harvester' },
{ id: 'ix-amal', name: 'Amal', effect: 'amal' },
] as const;
// Build once from physical faces. Richese's cache also transports its red/blue
// components as `special`, so only printed green types join the role lookup.
const planetologistSpecialById: Partial<Record<string, Pick<Card, 'name' | 'effect'>>> =
Object.create(null);
for (const card of [...PLANETOLOGIST_BASE_SPECIALS, ...PLANETOLOGIST_IX_SPECIALS])
planetologistSpecialById[card.id] = card;
for (const definition of RICHESE_CARD_DEFINITIONS) {
if (
definition.printedType === 'Special' ||
definition.printedType === 'Special - Movement'
)
planetologistSpecialById[definition.card.id] = definition.card;
}
for (const definition of ECAZ_TREACHERY_DEFINITIONS)
planetologistSpecialById[definition.card.id] = definition.card;
/** Shared engine/UI/bot eligibility: exact printed green 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 = planetologistSpecialById[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),
)
);
}
/** These green Specials also have a native battle-slot use, so the role is explicit. */
export function planetologistSpecialNeedsChoice(card: Card | undefined): boolean {
return isPlanetologistBattleSpecialCard(card) &&
(card?.effect === 'reinforcements' || card?.effect === 'harassWithdraw');
}
/** Base/ordinary green Specials imply substitution; dual-use utility cards do not. */
export function usesPlanetologistBattleSpecial(input: Parameters<typeof canUsePlanetologistBattleSpecial>[0] & {
planetologistSpecial?: true;
}): boolean {
return canUsePlanetologistBattleSpecial(input) &&
(!planetologistSpecialNeedsChoice(input.card) || input.planetologistSpecial === true);
}
/** 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);
}
/** A captive grants only this actual disc's lower effect, never a normal effect. */
export function usesSurvivingSkilledLeader(
assignments: readonly BattleLeaderSkill[],
skill: LeaderSkillId,
leader: string | undefined | null,
survives: boolean,
): boolean {
return survives && !!leader && assignments.some((a) =>
a.skill === skill && a.leader === leader && (!a.faceUp || a.captured));
}
/** Bureaucrat changes the opponent's total, not either leader disc or bounty. */
export function bureaucratBattlePenalty(
assignments: readonly BattleLeaderSkill[],
leader: string | undefined | null,
survives: boolean,
opponentStrongholds: number | undefined,
): number {
if (!usesSurvivingSkilledLeader(assignments, 'bureaucrat', leader, survives)) return 0;
if (opponentStrongholds === undefined || !Number.isSafeInteger(opponentStrongholds) || opponentStrongholds < 0)
throw new Error('Bureaucrat needs a valid occupied stronghold count.');
return opponentStrongholds;
}
function qualifies(
skill: DirectLeaderSkillId,
weapon: Card | undefined,
defense: Card | undefined,
effectiveDefense: 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(effectiveDefense ?? defense).includes('snooper');
return defenseTypes(effectiveDefense ?? 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;
/** A copied defense role leaves the played Worthless card available to Warmaster. */
effectiveDefense?: Card;
skilledLeaderSurvives: boolean;
bankerSpice?: number;
planetologistSpecial?: true;
}): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = [];
if (input.bankerSpice && Number.isSafeInteger(input.bankerSpice) && input.bankerSpice >= 1 && input.bankerSpice <= 3 &&
input.selectedLeader.kind === 'disc' && usesSurvivingSkilledLeader(input.assignments, 'spice-banker', input.selectedLeader.id, input.skilledLeaderSurvives))
applied.push({ skill: 'spice-banker', amount: input.bankerSpice as 1 | 2 | 3, mode: 'skilled' });
if (input.selectedLeader.kind === 'disc' && usesSurvivingSkilledLeader(
input.assignments, 'mentat', input.selectedLeader.id, input.skilledLeaderSurvives,
)) applied.push({ skill: 'mentat', amount: 2, mode: 'skilled' });
if (
input.selectedLeader.kind === 'disc' &&
input.skilledLeaderSurvives &&
usesPlanetologistBattleSpecial({
assignments: input.assignments,
selectedLeader: input.selectedLeader.id,
card: input.weapon,
planetologistSpecial: input.planetologistSpecial,
})
)
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, input.effectiveDefense)) 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,
};
}