Files
Dune/game/ecaz-special-karama.ts

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);
}