119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
import type { Card } from './cards';
|
|
import type { FactionId } from './catalog';
|
|
|
|
export type EcazSpecialKaramaPlan = {
|
|
weapon: string | null;
|
|
defense: string | null;
|
|
};
|
|
|
|
/** Store this normalized declaration on the battle, not on a hidden plan. */
|
|
export type EcazSpecialKaramaDeclaration = {
|
|
event: string;
|
|
owner: string;
|
|
card: string;
|
|
};
|
|
|
|
export type EcazSpecialKaramaContext = {
|
|
status: string;
|
|
phase: number;
|
|
advanced: boolean;
|
|
owner: {
|
|
id: string;
|
|
faction: FactionId;
|
|
specialKaramaUsed?: boolean;
|
|
};
|
|
/** Authoritative owned, unreserved, actual Karama cards supplied by the engine. */
|
|
cards: readonly Card[];
|
|
battle: {
|
|
event?: string;
|
|
attacker: string;
|
|
defender: string;
|
|
revealed: boolean;
|
|
/** Only this owner's sealed plan; omit/null means it has not been sealed. */
|
|
ownPlan?: EcazSpecialKaramaPlan | null;
|
|
declaration?: EcazSpecialKaramaDeclaration | null;
|
|
} | null;
|
|
};
|
|
|
|
/** Private owner-only projection; it contains no opposing leader or plan data. */
|
|
export type EcazSpecialKaramaQuote = {
|
|
event: string | null;
|
|
blocked: string | null;
|
|
cards: readonly Card[];
|
|
declared: boolean;
|
|
};
|
|
|
|
export class EcazSpecialKaramaError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'EcazSpecialKaramaError';
|
|
}
|
|
}
|
|
|
|
/** Revised Dune Rulebook v2.3, physical p. 30, Ecaz G: neither card slot may be used. */
|
|
export function ecazSpecialKaramaPlanAllowed(plan: EcazSpecialKaramaPlan): boolean {
|
|
return plan.weapon === null && plan.defense === null;
|
|
}
|
|
|
|
/**
|
|
* Revised Dune Rulebook v2.3, physical p. 30, Ecaz G allows play before reveal.
|
|
* An unsealed own plan does not block declaration; the engine must enforce the
|
|
* empty-slot commitment when that plan is later sealed or changed. No opponent
|
|
* plan, leader or hidden inspection is an input to eligibility.
|
|
*/
|
|
export function quoteEcazSpecialKarama(
|
|
context: EcazSpecialKaramaContext,
|
|
): EcazSpecialKaramaQuote {
|
|
const { battle, owner } = context;
|
|
const event = battle?.event || null;
|
|
const declared = !!(
|
|
event &&
|
|
battle?.declaration?.event === event &&
|
|
battle.declaration.owner === owner.id
|
|
);
|
|
let blocked: string | null = null;
|
|
if (declared) blocked = 'Your Ecaz special Karama is already declared for this battle.';
|
|
else if (!context.advanced) blocked = 'Special Karama powers require the Advanced game.';
|
|
else if (owner.faction !== 'ecaz') blocked = 'Only native Ecaz may declare this special Karama.';
|
|
else if (context.status !== 'playing' || context.phase !== 6 || !battle || !event)
|
|
blocked = 'Declare this special Karama in your current battle before plans are revealed.';
|
|
else if (battle.attacker !== owner.id && battle.defender !== owner.id)
|
|
blocked = 'You must be a combatant in the current battle.';
|
|
else if (battle.revealed) blocked = 'Battle Plans have already been revealed.';
|
|
else if (owner.specialKaramaUsed) blocked = 'Your special Karama power has already been used this game.';
|
|
else if (battle.ownPlan && !ecazSpecialKaramaPlanAllowed(battle.ownPlan))
|
|
blocked = 'Your sealed Battle Plan must contain neither a weapon nor a defense.';
|
|
else if (context.cards.length === 0) blocked = 'You need an available Karama card.';
|
|
return { event, blocked, cards: context.cards, declared };
|
|
}
|
|
|
|
/** Pure preflight only: resource cost, once-use and delayed execution belong to the engine. */
|
|
export function quoteEcazSpecialKaramaDeclaration(
|
|
context: EcazSpecialKaramaContext,
|
|
selection: { event: string; card: string },
|
|
): EcazSpecialKaramaDeclaration {
|
|
const quote = quoteEcazSpecialKarama(context);
|
|
if (quote.blocked) throw new EcazSpecialKaramaError(quote.blocked);
|
|
if (!quote.event || selection.event !== quote.event)
|
|
throw new EcazSpecialKaramaError('Choose the exact current battle event for this declaration.');
|
|
if (!quote.cards.some((card) => card.id === selection.card))
|
|
throw new EcazSpecialKaramaError('Choose one of your available Karama cards.');
|
|
return { event: quote.event, owner: context.owner.id, card: selection.card };
|
|
}
|
|
|
|
/**
|
|
* Ecaz G (p. 30) says to add the leader-disc difference to the number dialed.
|
|
* Provisional prototype policy, not a publisher ruling: use the absolute
|
|
* printed/copied disc-value difference as virtual dial strength only. Caller
|
|
* supplies authoritative revealed disc values (including Zoal's copied value),
|
|
* excluding KH, skills and other strength modifiers. Add this result to battle
|
|
* strength, never to physical counters, spice support or dialed casualties.
|
|
* Existing traitor/explosion outcome precedence remains the engine's concern.
|
|
*/
|
|
export function ecazSpecialKaramaBonus(
|
|
ownDiscValue: number,
|
|
opposingDiscValue: number,
|
|
): number {
|
|
return Math.abs(ownDiscValue - opposingDiscValue);
|
|
}
|