184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
import type { Card } from './cards';
|
|
import type { FactionId } from './catalog';
|
|
import type { ResolvedBattleReceipt } from './karama-battle-preflight';
|
|
|
|
export class MoritaniSpecialKaramaError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'MoritaniSpecialKaramaError';
|
|
}
|
|
}
|
|
|
|
/** Prototype precedence, not a publisher clarification of Moritani F. */
|
|
export const MORITANI_SPECIAL_KARAMA_BOUNDARY =
|
|
'Provisional first-version precedence: mandatory named-card disposal wins. Force keep applies only to ordinarily retainable cards; this is not a publisher clarification.';
|
|
|
|
export type MoritaniSpecialKaramaSource = {
|
|
event: string;
|
|
turn: number;
|
|
territory: string;
|
|
owner: string;
|
|
opponent: string;
|
|
};
|
|
|
|
export type MoritaniSpecialKaramaInput = {
|
|
advanced: boolean;
|
|
phase: number;
|
|
battlePresent: boolean;
|
|
turn: number;
|
|
owner: { id: string; faction: FactionId };
|
|
used: boolean;
|
|
/** The actual resolved battle, never a prospective or reconstructed result. */
|
|
battle: ResolvedBattleReceipt;
|
|
/** Owned, unreserved actual Karama Cards, supplied by the integration owner. */
|
|
karamas: readonly Card[];
|
|
/** Only publicly revealed winner cards played in this battle, never their hand. */
|
|
playedCards: readonly Card[];
|
|
/** Public played identities still in the winner's physical custody. */
|
|
heldPlayedIds: readonly string[];
|
|
/** Normal retainable identities after compulsory named-card disposal. */
|
|
keepableIds: readonly string[];
|
|
};
|
|
|
|
export type MoritaniSpecialKaramaQuote = {
|
|
source: MoritaniSpecialKaramaSource;
|
|
karamas: readonly Card[];
|
|
/** Public played cards still held; all are legal force-discard targets. */
|
|
playedCards: readonly Card[];
|
|
keepableIds: readonly string[];
|
|
boundary: string;
|
|
};
|
|
|
|
/** Project only to source.owner; independent of GameView and hidden hands. */
|
|
export type MoritaniSpecialKaramaView = MoritaniSpecialKaramaQuote;
|
|
|
|
export type MoritaniSpecialKaramaAction = {
|
|
type: 'card';
|
|
mode: 'special';
|
|
card: string;
|
|
event: string;
|
|
keep: string[];
|
|
discard: string[];
|
|
};
|
|
|
|
/** Detached instruction only: the caller owns cost, once-use and continuation. */
|
|
export type MoritaniSpecialKaramaChoice = {
|
|
source: MoritaniSpecialKaramaSource;
|
|
card: string;
|
|
keep: string[];
|
|
discard: string[];
|
|
};
|
|
|
|
function requireMoritani(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new MoritaniSpecialKaramaError(message);
|
|
}
|
|
|
|
function id(value: unknown): value is string {
|
|
return typeof value === 'string' && value.trim().length > 0;
|
|
}
|
|
|
|
function ids(value: unknown): value is string[] {
|
|
return Array.isArray(value) && value.every(id) && new Set(value).size === value.length;
|
|
}
|
|
|
|
function cardList(value: unknown): value is Card[] {
|
|
return Array.isArray(value) &&
|
|
value.every(card => card && id(card.id) && id(card.name) && id(card.kind)) &&
|
|
new Set(value.map(card => card.id)).size === value.length;
|
|
}
|
|
|
|
/**
|
|
* Call after compulsory aftermath and before optional winner disposal. The
|
|
* supplied played-card list is already public; this helper never finds cards
|
|
* in an opposing hand or infers whether an unrevealed card was played.
|
|
*/
|
|
export function quoteMoritaniSpecialKarama(
|
|
input: MoritaniSpecialKaramaInput,
|
|
): MoritaniSpecialKaramaQuote | null {
|
|
if (!input.advanced || input.owner.faction !== 'moritani' || input.used ||
|
|
input.phase !== 6 || input.battlePresent) return null;
|
|
const battle = input.battle;
|
|
requireMoritani(
|
|
battle && id(battle.event) && Number.isSafeInteger(input.turn) && input.turn >= 1 &&
|
|
battle.turn === input.turn && id(battle.territory) && id(input.owner.id) &&
|
|
ids(battle.combatants) && battle.combatants.length === 2 &&
|
|
['normal', 'traitor', 'mutualTraitors', 'explosion', 'legacy'].includes(battle.result) &&
|
|
(battle.winner === null || (id(battle.winner) && battle.combatants.includes(battle.winner))) &&
|
|
((battle.result === 'mutualTraitors' || battle.result === 'explosion')
|
|
? battle.winner === null : battle.winner !== null),
|
|
'Moritani special Karama needs the current resolved battle receipt.',
|
|
);
|
|
if (!battle.combatants.includes(input.owner.id) || battle.winner === null ||
|
|
battle.winner === input.owner.id) return null;
|
|
requireMoritani(
|
|
cardList(input.karamas) && input.karamas.every(card => card.kind === 'special' && card.effect === 'karama'),
|
|
'Supply only unique, owned, available actual Karama Cards.',
|
|
);
|
|
requireMoritani(
|
|
cardList(input.playedCards) && ids(input.heldPlayedIds) && ids(input.keepableIds) &&
|
|
input.heldPlayedIds.every(card => input.playedCards.some(played => played.id === card)) &&
|
|
input.keepableIds.every(card => input.heldPlayedIds.includes(card)) &&
|
|
!input.karamas.some(card => input.playedCards.some(played => played.id === card.id)),
|
|
'Supply unique public played cards, their current custody, and normal retainable identities.',
|
|
);
|
|
const playedCards = input.playedCards.filter(card => input.heldPlayedIds.includes(card.id));
|
|
if (!input.karamas.length || !playedCards.length) return null;
|
|
return {
|
|
source: {
|
|
event: battle.event,
|
|
turn: battle.turn,
|
|
territory: battle.territory,
|
|
owner: input.owner.id,
|
|
opponent: battle.winner,
|
|
},
|
|
karamas: input.karamas,
|
|
playedCards,
|
|
keepableIds: input.keepableIds,
|
|
boundary: MORITANI_SPECIAL_KARAMA_BOUNDARY,
|
|
};
|
|
}
|
|
|
|
/** Validate against a freshly rebuilt quote for the exact pending opportunity. */
|
|
export function validateMoritaniSpecialKaramaChoice(
|
|
quote: MoritaniSpecialKaramaQuote,
|
|
source: MoritaniSpecialKaramaSource,
|
|
actor: string,
|
|
action: unknown,
|
|
): MoritaniSpecialKaramaChoice {
|
|
requireMoritani(
|
|
source.event === quote.source.event && source.turn === quote.source.turn &&
|
|
source.territory === quote.source.territory && source.owner === quote.source.owner &&
|
|
source.opponent === quote.source.opponent,
|
|
'This Moritani special Karama must resume its exact original battle opportunity.',
|
|
);
|
|
requireMoritani(action && typeof action === 'object' && !Array.isArray(action),
|
|
'Choose a Moritani special Karama disposition.');
|
|
const choice = action as Record<string, unknown>;
|
|
requireMoritani(
|
|
actor === quote.source.owner && choice.type === 'card' && choice.mode === 'special' &&
|
|
choice.event === quote.source.event &&
|
|
Object.keys(choice).every(key => ['type', 'mode', 'card', 'event', 'keep', 'discard'].includes(key)),
|
|
'This Moritani special Karama must match its owner and original battle event.',
|
|
);
|
|
requireMoritani(id(choice.card) && quote.karamas.some(card => card.id === choice.card),
|
|
'Spend one of your available actual Karama Cards.');
|
|
requireMoritani(ids(choice.keep) && ids(choice.discard),
|
|
'Keep and discard must be unique card-identity lists.');
|
|
const keep = choice.keep;
|
|
const discard = choice.discard;
|
|
requireMoritani(keep.length + discard.length > 0,
|
|
'Force at least one disposition, or decline the opportunity without spending a Karama.');
|
|
requireMoritani(
|
|
keep.every(card => quote.keepableIds.includes(card) && quote.playedCards.some(played => played.id === card)) &&
|
|
discard.every(card => quote.playedCards.some(played => played.id === card)) &&
|
|
!keep.some(card => discard.includes(card)),
|
|
'Choose disjoint played cards still held; force keep only normally retainable cards.',
|
|
);
|
|
return {
|
|
source: { ...quote.source },
|
|
card: choice.card,
|
|
keep: [...keep],
|
|
discard: [...discard],
|
|
};
|
|
}
|