101 lines
3.6 KiB
TypeScript
101 lines
3.6 KiB
TypeScript
import type { Card } from './cards';
|
|
import { isDefenseCard, isWeaponCard } from './battle-cards';
|
|
import { ecazTreacheryDefinition } from './ecaz-cards';
|
|
import {
|
|
isPlanetologistBattleSpecialCard,
|
|
validLeaderSkillBattleCardPair,
|
|
} from './leader-skill-combat';
|
|
|
|
export type BattleCardSlot = 'weapon' | 'defense';
|
|
export type BattlePlanInspectionField = BattleCardSlot | 'leader' | 'dial';
|
|
export type BattleCategoryInspectionValue = string | number | null;
|
|
|
|
function canonicalEcazBattleSpecial(card: Card | undefined) {
|
|
if (
|
|
!card ||
|
|
Object.keys(card).sort().join(',') !== 'effect,id,kind,name'
|
|
)
|
|
return false;
|
|
const definition = ecazTreacheryDefinition(card);
|
|
return (
|
|
definition?.card.effect === 'reinforcements' ||
|
|
definition?.card.effect === 'harassWithdraw'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Single-slot role eligibility only. The caller still applies paired-card rules
|
|
* such as Chemistry/Weirding Way dependencies and validates the selected
|
|
* Planetologist leader before setting planetologistWeapon.
|
|
*/
|
|
export function battleCardSlotEligible(
|
|
slot: BattleCardSlot,
|
|
card: Card | undefined,
|
|
options: Readonly<{ planetologistWeapon?: boolean }> = {},
|
|
): boolean {
|
|
if (!card) return true;
|
|
if (canonicalEcazBattleSpecial(card)) return true;
|
|
if (slot === 'defense') return isDefenseCard(card);
|
|
return (
|
|
isWeaponCard(card) ||
|
|
(!!options.planetologistWeapon && isPlanetologistBattleSpecialCard(card))
|
|
);
|
|
}
|
|
|
|
/** Slot-only specials do not supply the opposite category for alternate roles. */
|
|
export function validBattleSlotPair(
|
|
weapon?: Card,
|
|
defense?: Card,
|
|
planetologistWeapon = false,
|
|
): boolean {
|
|
if (!canonicalEcazBattleSpecial(weapon) && !canonicalEcazBattleSpecial(defense))
|
|
return validLeaderSkillBattleCardPair(weapon, defense, planetologistWeapon);
|
|
return (
|
|
battleCardSlotEligible('weapon', weapon, { planetologistWeapon }) &&
|
|
battleCardSlotEligible('defense', defense) &&
|
|
(!weapon || weapon.id !== defense?.id) &&
|
|
(weapon?.kind !== 'chemistry' || (!!defense && isDefenseCard(defense))) &&
|
|
(defense?.kind !== 'weirdingWay' || (!!weapon && isWeaponCard(weapon)))
|
|
);
|
|
}
|
|
|
|
/** A Planetologist substitution suppresses only the selected weapon's native effect. */
|
|
export function usesNativeBattleSpecial(
|
|
plan: Readonly<{ weapon?: unknown; defense?: unknown; planetologistSpecial?: unknown }>,
|
|
card: 'ecaz-reinforcements' | 'ecaz-harass-withdraw',
|
|
): boolean {
|
|
return plan.defense === card || (plan.weapon === card && plan.planetologistSpecial !== true);
|
|
}
|
|
|
|
/**
|
|
* Prescience asks for the selected category rather than physical slot
|
|
* occupancy. Exact Ecaz battle specials answer null without revealing their
|
|
* identity. Existing Planetologist inspection behavior is a separate contract.
|
|
*/
|
|
export function battleCategoryInspectionValue(
|
|
field: BattlePlanInspectionField,
|
|
value: BattleCategoryInspectionValue | undefined,
|
|
card?: Card,
|
|
): BattleCategoryInspectionValue | undefined {
|
|
if (value === undefined || field === 'leader' || field === 'dial' || value === null)
|
|
return value;
|
|
if (card && card.id !== value)
|
|
throw new Error('The inspected card does not match the selected battle slot.');
|
|
if (canonicalEcazBattleSpecial(card))
|
|
return null;
|
|
return value;
|
|
}
|
|
|
|
/** Partial plan search leaves an unspecified candidate unconstrained. */
|
|
export function fixedBattleInspectionMatches(
|
|
field: BattlePlanInspectionField,
|
|
fixed: BattleCategoryInspectionValue,
|
|
candidate: BattleCategoryInspectionValue | undefined,
|
|
card?: Card,
|
|
): boolean {
|
|
return (
|
|
candidate === undefined ||
|
|
battleCategoryInspectionValue(field, candidate, card) === fixed
|
|
);
|
|
}
|