Compose independent utility cards with classic Leader Skills

This commit is contained in:
Quintine committed 2026-10-17 14:17:24 +11:00
1 parent 40a5e51229
commit 5a32c1ac85
23 files changed
+1845 -87

No files matched your search

+8
View File
@@ -59,6 +59,14 @@ export function validBattleSlotPair(
);
}
/** 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
+24 -12
View File
@@ -46,7 +46,7 @@ import {
} from './homeworld-battle-rules';
import {
PLANETOLOGIST_BASE_SPECIALS,
canUsePlanetologistBattleSpecial,
usesPlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
leaderSkillBattleBonus,
bureaucratBattlePenalty,
@@ -65,6 +65,7 @@ export class BattleResolutionQuoteError extends Error {
}
export type ResolutionPlan = {
bankerSpice?: number;
planetologistSpecial?: true;
dial: number;
support: number;
allyPayment?: number;
@@ -240,6 +241,10 @@ function validateCombatant(
} catch (error) {
throw new BattleResolutionQuoteError(error instanceof Error ? error.message : 'Invalid Spice Banker commitment.');
}
requireQuote(p.planetologistSpecial === undefined || (p.planetologistSpecial === true &&
usesPlanetologistBattleSpecial({ assignments: side.leaderSkills ?? [],
selectedLeader: p.leader, card: side.hand.find(card => card.id === p.weapon), planetologistSpecial: true })),
'The alternate Special role needs the actual Planetologist weapon substitution.');
const selected = [p.weapon, p.defense, p.leader, side.lateDefense].filter(
(value): value is string => value != null,
);
@@ -277,9 +282,8 @@ function validateCombatant(
].filter(
(selectedId): selectedId is string =>
!!selectedId &&
PLANETOLOGIST_BASE_SPECIALS.some(
(card) => card.id === selectedId,
),
(PLANETOLOGIST_BASE_SPECIALS.some((card) => card.id === selectedId) ||
selectedId === 'ecaz-recruits' || (p.planetologistSpecial === true && selectedId === p.weapon)),
);
requireQuote(
selectedPlanetologistCards.every((selectedId) => {
@@ -289,10 +293,11 @@ function validateCombatant(
side.leader?.id === p.leader &&
!side.leader.dead &&
isPlanetologistBattleSpecialCard(selectedCard) &&
canUsePlanetologistBattleSpecial({
usesPlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [],
selectedLeader: p.leader,
card: selectedCard,
planetologistSpecial: p.planetologistSpecial,
})
);
}),
@@ -405,29 +410,33 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
const reinforcements: NonNullable<BattleResolutionQuote['reinforcements']> = [];
for (const side of [a, d]) {
const slots = [card(side, side.plan.weapon), card(side, side.plan.defense)];
const planetologistWeapon = usesPlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [], selectedLeader: side.plan.leader,
card: slots[0], planetologistSpecial: side.plan.planetologistSpecial,
});
const selectedIds = [side.plan.weapon, side.plan.defense, side.plan.leader, side.lateDefense];
requireQuote(selectedIds.every((selectedId) =>
selectedId !== REINFORCEMENTS_CARD || slots.some(c => c?.id === selectedId && isReinforcements(c))),
'Reinforcements must be a canonical physical card in exactly one battle-card slot.');
const selected = slots.filter(isReinforcements);
const selected = slots.filter((selectedCard, slot) =>
isReinforcements(selectedCard) && !(slot === 0 && planetologistWeapon));
requireQuote(selected.length <= 1 && (selected.length === 0) === (side.reinforcementsReserves === undefined),
'Reinforcements requires its own reserve counts exactly when selected.');
if (selected.length) {
requireQuote(validBattleSlotPair(slots[0], slots[1]) &&
requireQuote(validBattleSlotPair(slots[0], slots[1], planetologistWeapon) &&
!(side === d && reinforcements.length),
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
requireQuote(!(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length),
'Reinforcements cannot combine with leader skills.');
const reserves = side.reinforcementsReserves!;
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
'Reinforcements needs own typed reserves.');
const cost = quoteReinforcements(reserves.normal, reserves.elite);
reinforcements.push({ player: side.id, card: REINFORCEMENTS_CARD, normal: cost.normal, elite: cost.elite });
}
const harass = slots.some(isHarassWithdraw);
const harass = slots.some((selectedCard, slot) =>
isHarassWithdraw(selectedCard) && !(slot === 0 && planetologistWeapon));
requireQuote((!side.harassWithdraw && side.harassSelection === undefined) || harass, 'The withdrawal context requires the physical Harass & Withdraw card.');
if (harass) {
requireQuote(validBattleSlotPair(slots[0], slots[1]),
requireQuote(validBattleSlotPair(slots[0], slots[1], planetologistWeapon),
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
requireQuote(side.harassWithdraw,
'Harass & Withdraw needs its supported physical context.');
@@ -672,6 +681,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
effectiveDefense: effectiveAd,
skilledLeaderSurvives: !deaths.attacker,
bankerSpice: a.plan.bankerSpice,
planetologistSpecial: a.plan.planetologistSpecial,
}),
defender: leaderSkillBattleBonus({
assignments: d.leaderSkills ?? [],
@@ -681,6 +691,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
effectiveDefense: effectiveDd,
skilledLeaderSurvives: !deaths.defender,
bankerSpice: d.plan.bankerSpice,
planetologistSpecial: d.plan.planetologistSpecial,
}),
};
const attackerPenalty = bureaucratBattlePenalty(d.leaderSkills ?? [], d.leader?.id,
@@ -860,10 +871,11 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
selected: string,
) =>
selected === side.plan.weapon &&
canUsePlanetologistBattleSpecial({
usesPlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [],
selectedLeader: side.plan.leader,
card: card(side, selected),
planetologistSpecial: side.plan.planetologistSpecial,
});
// The explicit after-use instruction also applies to a successful own traitor
// call, using the same specificity inference as Planetologist's discard.
+21 -11
View File
@@ -3,7 +3,7 @@ import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions }
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus } from './leader-skill-combat';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice, leaderSkillBattleBonus } from './leader-skill-combat';
import { defaultSmugglerBattleAllocation, smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
@@ -732,19 +732,24 @@ function plans(g: GameView): Action[] {
const bankerPrototypeAvailable = spiceBankerModeSupported(g);
const specialForLeader = (card: Card | undefined, leader: string | null) =>
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
const prototypeCardAllowed = (card: Card) =>
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) &&
(card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
const prototypeCardAllowed = (card: Card, slot: 'weapon' | 'defense') => {
const native = (!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) &&
(card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
return native || (slot === 'weapon' && planetologistSpecialNeedsChoice(card) &&
me.leaders.some(l => !l.dead && controlsLeader(me, l) &&
(!l.usedAt || l.usedAt === b.territory) && specialForLeader(card, l.id)));
};
const weapons = [
null,
...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('weapon', c, {
planetologistWeapon: me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id)),
})).map(c => c.id),
...(me.hand ?? []).filter(c => prototypeCardAllowed(c, 'weapon') &&
(battleCardSlotEligible('weapon', c) ||
me.leaders.some(l => !l.dead && controlsLeader(me, l) &&
(!l.usedAt || l.usedAt === b.territory) && specialForLeader(c, l.id)))).map(c => c.id),
].filter((id) => fixed('weapon', id));
const defenses = [
null,
...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('defense', c)).map((c) => c.id),
...(me.hand ?? []).filter(c => prototypeCardAllowed(c, 'defense') && battleCardSlotEligible('defense', c)).map((c) => c.id),
].filter((id) => fixed('defense', id));
const availableLeaders = me.leaders.filter(
(l) =>
@@ -829,7 +834,11 @@ function plans(g: GameView): Action[] {
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader)))
continue;
const reinforcements = weapon === 'ecaz-reinforcements' || defense === 'ecaz-reinforcements';
const planetologistSpecial = planetologistSpecialNeedsChoice(w) && specialForLeader(w, leader);
const reinforcements = (weapon === 'ecaz-reinforcements' && !planetologistSpecial) || defense === 'ecaz-reinforcements';
const harass = (weapon === 'ecaz-harass-withdraw' && !planetologistSpecial) || defense === 'ecaz-harass-withdraw';
if (reinforcements && (!b.reinforcements || b.reinforcements.blocked)) continue;
if (harass && (!b.harassWithdraw || b.harassWithdraw.blocked)) continue;
if (voice) {
const used =
playedVoiceMatch(
@@ -878,6 +887,7 @@ function plans(g: GameView): Action[] {
assignments:battleSkills,
selectedLeader:leader ? {id:leader,kind:myLeader?'disc':'hero'} : undefined,
weapon:w,defense:d,skilledLeaderSurvives:true,
planetologistSpecial: planetologistSpecial ? true : undefined,
}).bonus;
const enemySurvivingStrength =
effects.stunned || (inspected && effects.defenderDead)
@@ -916,6 +926,7 @@ function plans(g: GameView): Action[] {
])) {
const action: Action = {
type: 'battlePlan',
...(planetologistSpecial ? { planetologistSpecial: true } : {}),
kwisatz,
dial,
leader,
@@ -976,7 +987,6 @@ function plans(g: GameView): Action[] {
const stonePlan = isStoneBurner(me.hand?.find((c) => c.id === action.weapon)) ||
isMirrorWeapon(me.hand?.find((c) => c.id === action.weapon));
if (stonePlan && !action.leader) continue;
const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
const stoneContext = stonePlan && harass
? stoneBurnerWithdrawalContext(b.stoneBurnerContext?.occupy,
b.attacker === me.id ? 'attacker' : 'defender', b.harassWithdraw?.occupy)
+2 -2
View File
@@ -1,4 +1,4 @@
import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
import { homeworldLeaderSkillsProfile, classicIndependentLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
export type BureaucratPaymentSource = {
@@ -40,7 +40,7 @@ export function bureaucratPaymentSignature(value: object): string {
return JSON.stringify({ ...value, signature: undefined });
}
export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean {
return (!game.homeworlds && game.expansions.length === 1 &&
return classicIndependentLeaderSkillsProfile(game) || (!game.homeworlds && game.expansions.length === 1 &&
(game.expansions[0] === 'choam' || game.expansions[0] === 'ix') &&
pairedNexusLeaderSkillsProfile(game)) ||
mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
+1 -1
View File
@@ -118,7 +118,7 @@ export const ECAZ_TREACHERY_DEFINITIONS: readonly EcazTreacheryDefinition[] =
verification: {
inventory: 'verified',
sourceRules: 'verified',
runtime: 'not-implemented',
runtime: 'prototype',
combinedInteractions: 'incomplete',
unresolved: [
'Reserve-cost timing when either side calls a traitor and in explosion or instant-outcome battles.',
+52 -28
View File
@@ -1,7 +1,7 @@
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicIndependentLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -10,7 +10,7 @@ import { leaderSkillCard } from './leader-skill-cards';
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
canUsePlanetologistBattleSpecial,
usesPlanetologistBattleSpecial, planetologistSpecialNeedsChoice,
type BattleLeaderSkill,
} from './leader-skill-combat';
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
@@ -29,7 +29,7 @@ import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState,
import { quoteDiplomatDefense, diplomatDefenseModeSupported, diplomatDefenseLabel, type DiplomatDefenseQuote } from './diplomat-defense';
import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat';
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, usesNativeBattleSpecial } from './battle-card-slots';
import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
@@ -681,6 +681,8 @@ export type Player = {
export type Plan = {
/** Separate own-spice commitment for the selected Spice Banker; absent is zero. */
bankerSpice?: number;
/** Explicit alternate weapon use for a green Special with its own battle effect. */
planetologistSpecial?: true;
dial: number;
leader: string | null;
weapon: string | null;
@@ -693,7 +695,7 @@ export type Plan = {
};
export type PlanField = Exclude<
keyof Plan,
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources'
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources' | 'planetologistSpecial'
>;
export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
event: string;
@@ -8365,7 +8367,7 @@ function planCombatForces(g: Game, p: Player, opponent: Player,
const home = homeworldBattleLocation(g, g.battle!.territory);
if (!home || home.native !== p.id) return forces;
const plan = g.battle!.plans[p.id];
const cost = reserveCost ?? (plan && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)
const cost = reserveCost ?? (plan && usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)
? currentReinforcementsCost(g, p, plan.reinforcementsSources) : undefined);
return reinforcementsBattleForces(forces, cost?.sources?.[home.id]);
}
@@ -8831,11 +8833,13 @@ function canUsePlanetologistWeapon(
player: Player,
leader: string | null,
card: Card | undefined,
planetologistSpecial?: true,
) {
return canUsePlanetologistBattleSpecial({
return usesPlanetologistBattleSpecial({
assignments: battleLeaderSkills(g, player),
selectedLeader: leader,
card,
planetologistSpecial,
});
}
function nativeLeaderSkill(g: Game, owner: string) {
@@ -10634,6 +10638,12 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
const homeworlds = homeworldLeaderSkillsProfile(state);
const independent = classicIndependentLeaderSkillsProfile(state);
if (state.ecazTreachery) {
requireRule(independent, 'Independent Treachery with Leader Skills requires a fresh classic lobby without other modules.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
if (state.homeworlds) {
requireRule(homeworlds && state.homeworlds.custody === null,
'Homeworld Leader Skills require a fresh supported classic or native E1/E2/standalone E3 lobby with original modules and undealt custody.');
@@ -10654,7 +10664,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g), independent);
if (advancedMoritaniLeaderSkillsProfile(initialized) || ((mixedE3 || pairedE3) && initialized.advanced &&
byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized);
if ((mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
@@ -13703,29 +13713,29 @@ function ecazTreacheryIntegrity(g: Game) {
'The Ecaz Treachery Cards inventory contains an unknown physical card.',
);
for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) {
if ([plan.weapon, plan.defense].includes('ecaz-reinforcements')) {
if (usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)) {
const owner = getPlayer(g, player);
requireRule(plan.leader !== 'ecaz-reinforcements' &&
g.battle?.lateDefense?.[player] !== 'ecaz-reinforcements' &&
plan.weapon !== plan.defense &&
owner.hand.some(isReinforcements) &&
[plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense), true),
'The sealed Reinforcements plan lost its physical card or legal slot.');
currentReinforcementsCost(g, owner, plan.reinforcementsSources);
}
requireRule(plan.reinforcementsSources === undefined ||
(g.homeworlds && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)),
(g.homeworlds && usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)),
'Native reserve fee sources require the physical Reinforcements card in a Homeworld plan.');
requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD &&
!(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD),
'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.');
if ([plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) {
if (usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)) {
const owner = getPlayer(g, player);
requireRule(owner.hand.some(isHarassWithdraw),
'The sealed Harass & Withdraw card must remain in its owner’s hand.');
requireRule([plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense), true),
'The sealed Harass & Withdraw plan has an invalid weapon and defense pair.');
currentHarassWithdrawQuote(g, owner, plan);
}
@@ -13756,7 +13766,7 @@ function recruitsModeSupported(g: Game) {
return (
(!g.homeworlds || ecazHomeworldProfile(g)) &&
!g.nexusCards &&
!g.leaderSkills &&
(!g.leaderSkills || classicIndependentLeaderSkillsProfile(g)) &&
(!g.techTokens || modules) &&
(!g.strongholdCards || modules) &&
!g.discoveryEnabled
@@ -13864,7 +13874,7 @@ function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null {
if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID)
return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.';
if (!recruitsModeSupported(g))
return 'Recruits requires a supported independent-card profile: faction-only, clean classic Homeworlds, or clean classic/Ix/CHOAM with original Tech and Advanced Strongholds.';
return 'Recruits requires a supported independent-card profile: faction-only, classic all14 Skills without other modules, or the existing native Homeworld and phase-module compositions.';
if (g.status !== 'playing' || g.phase !== 4)
return 'Play Recruits during Revival.';
if (g.recruits?.turn === g.turn)
@@ -21139,12 +21149,12 @@ function stoneOccupyContext(g: Game): StoneBurnerContext | undefined {
function stonePlanBlock(
g: Game,
p: Player,
plan: Pick<Plan, 'dial' | 'support' | 'weapon' | 'defense'>,
plan: Pick<Plan, 'dial' | 'support' | 'weapon' | 'defense' | 'planetologistSpecial'>,
): string | null {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const baseContext = stoneOccupyContext(g);
const context = [plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)
const context = usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)
? stoneBurnerWithdrawalContext(baseContext, b.attacker === p.id ? 'attacker' : 'defender',
harassWithdrawContext(g, p).occupy)
: baseContext;
@@ -21210,7 +21220,15 @@ function validatePlan(
): Plan {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const selectedReinforcements = input.weapon === REINFORCEMENTS_CARD || input.defense === REINFORCEMENTS_CARD;
requireRule(input.planetologistSpecial === undefined || typeof input.planetologistSpecial === 'boolean',
'Choose whether to use the Planetologist alternate Special role.');
const planetologistWeapon = canUsePlanetologistWeapon(g, p,
typeof input.leader === 'string' ? input.leader : null,
typeof input.weapon === 'string' ? cardOf(p, input.weapon) : undefined,
input.planetologistSpecial === true ? true : undefined);
requireRule(input.planetologistSpecial !== true || planetologistWeapon,
'The alternate Special role requires the actual Planetologist leader and a green Special in the weapon slot.');
const selectedReinforcements = usesNativeBattleSpecial(input, REINFORCEMENTS_CARD);
requireRule(input.reinforcementsSources === undefined || selectedReinforcements,
'Native reserve fee sources require the physical Reinforcements card.');
const reserveCost = selectedReinforcements
@@ -21244,6 +21262,7 @@ function validatePlan(
);
const plan: Plan = {
...(bankerSpice ? { bankerSpice } : {}),
...(input.planetologistSpecial === true ? { planetologistSpecial: true as const } : {}),
...(allyPayment ? { allyPayment } : {}),
...(reserveCost?.sources ? { reinforcementsSources: structuredClone(reserveCost.sources) } : {}),
dial,
@@ -21310,12 +21329,6 @@ function validatePlan(
if (smugglerPlan.assignments.some(a => a.skill === 'smuggler' && a.leader === l?.id))
nexusRule(() => smugglerBattlePile(b.territory, g.spice));
}
const planetologistWeapon = canUsePlanetologistWeapon(
g,
p,
plan.leader,
w,
);
requireRule(
!plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })),
'Choose a weapon, Worthless card, eligible Planetologist Special, Harass & Withdraw, or Reinforcements.',
@@ -21336,7 +21349,7 @@ function validatePlan(
validBattleSlotPair(w, d, planetologistWeapon),
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
);
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
if (usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)) currentHarassWithdrawQuote(g, p, plan);
if (isMirrorWeapon(w)) {
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, plan);
requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.');
@@ -21858,6 +21871,8 @@ function findLegalBattlePlan(
if (!accepts({ leader })) continue;
for (const weapon of weapons) {
if (!accepts({ leader, weapon })) continue;
const alternateSpecial = planetologistSpecialNeedsChoice(cardOf(p, weapon)) &&
canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon), true);
for (const defense of defenses) {
if (
!accepts({ leader, weapon, defense }) ||
@@ -21883,6 +21898,15 @@ function findLegalBattlePlan(
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
if (alternateSpecial) {
candidate.planetologistSpecial = true;
delete candidate.reinforcementsSources;
try {
return validatePlan(g, p, candidate, promises);
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
}
}
}
}
@@ -22782,7 +22806,7 @@ function harassAllocationOffer(g: Game) {
if (!b?.revealed || b.harassAllocationVersion !== 1) return null;
for (const id of [b.attacker, b.defender]) {
const p = getPlayer(g, id), plan = b.plans[id];
if (![plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) continue;
if (!usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)) continue;
const context = harassWithdrawContext(g, p);
try {
if (harassWithdrawNeedsAllocation(context, plan.dial, plan.support))
@@ -22968,11 +22992,11 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: planCombatForces(g, p, opponent),
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements)
...(usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)
? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0,
} } : {}),
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw)
...(usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)
? { harassWithdraw: harassWithdrawContext(g, p),
...(b.harassAllocation?.player === p.id && b.harassAllocation.stage === 'selected'
? { harassSelection: b.harassAllocation.selection! } : {}) } : {}),
@@ -23337,7 +23361,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
}
if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome };
for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const)
if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw))
if (canceled && usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD))
log(g, `${player.name}'s Harass & Withdraw was canceled by an opposing traitor. No forces returned through this card.`);
for (const withdrawal of quote.harassWithdraw ?? []) {
const player = getPlayer(g, withdrawal.player);
+20 -1
View File
@@ -7,6 +7,7 @@ import {
} 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'
@@ -80,6 +81,8 @@ for (const definition of RICHESE_CARD_DEFINITIONS) {
)
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(
@@ -111,6 +114,20 @@ export function canUsePlanetologistBattleSpecial(input: {
);
}
/** 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,
@@ -183,6 +200,7 @@ export function leaderSkillBattleBonus(input: {
effectiveDefense?: Card;
skilledLeaderSurvives: boolean;
bankerSpice?: number;
planetologistSpecial?: true;
}): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = [];
@@ -195,10 +213,11 @@ export function leaderSkillBattleBonus(input: {
if (
input.selectedLeader.kind === 'disc' &&
input.skilledLeaderSurvives &&
canUsePlanetologistBattleSpecial({
usesPlanetologistBattleSpecial({
assignments: input.assignments,
selectedLeader: input.selectedLeader.id,
card: input.weapon,
planetologistSpecial: input.planetologistSpecial,
})
)
applied.push({
+23 -1
View File
@@ -507,9 +507,31 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return true;
}
/** Independent utility cards with original classic all14 setup, without other modules. */
export function classicIndependentLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (game.ecazTreachery !== true || game.expansions.length ||
typeof game.advanced !== 'boolean' || !game.players ||
game.players.length < 2 || game.players.length > 6 ||
game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled ||
game.discoveryStash || game.greatMaker || game.techTokens || game.strongholdCards ||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
const players = game.players;
for (let i = 0; i < players.length; i++) {
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
let classic = false;
for (const faction of FACTIONS)
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
if (!classic) return false;
}
return true;
}
/** Shared by rule quotes, controls and minimal legal bot participation. */
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
return (noOtherLeaderSkillModules(game) &&
return classicIndependentLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) &&
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||