Compose independent utility cards with classic Leader Skills
This commit is contained in:
1 parent
40a5e51229
commit
5a32c1ac85
23 files changed
+1845
-87
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in new issue
Block a user