Files
Dune/game/mentat-question.ts
Quintine fde44baab5 Compose standalone E3 Skills and independent cards with native power defaults
Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
2026-10-17 20:36:01 +11:00

109 lines
3.5 KiB
TypeScript

import { treacheryDeck, type Card } from './cards';
import { richeseCards } from './richese-cards';
import { ecazTreacheryCards } from './ecaz-cards';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
import { ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
export class MentatQuestionError extends Error {}
export const MENTAT_EMPTY_HAND =
'Mentat cannot ask while the opponent has no Treachery Card to show; the empty-hand ruling remains unresolved. This question was skipped without a disclosure.';
export type MentatQuestionReceipt = {
event: string;
battle: string;
turn: number;
territory: string;
owner: string;
target: string;
leader: string;
stage: 'name' | 'reveal' | 'answered' | 'declined' | 'unavailable';
weapon: string | null;
card: Card | null;
/** Private snapshot for this atomic question; never a later hand obligation. */
hand: Card[];
signature: string;
};
export type MentatObservation = Omit<
MentatQuestionReceipt,
'stage' | 'hand' | 'weapon' | 'card'
> & { weapon: string; card: Card };
export type MentatView = {
pending: null | {
event: string;
owner: string;
target: string;
player: string;
stage: 'name' | 'reveal';
weapon: string | null;
weapons: string[];
cards: Card[];
blocked: string | null;
};
history: Omit<MentatObservation, 'signature'>[];
};
export function mentatQuestionModeSupported(game: LeaderSkillProfile): boolean {
return ordinaryLeaderSkillModeSupported(game);
}
function inventory(expansions: readonly string[], ecazTreachery: boolean): Card[] {
return [
...treacheryDeck(expansions),
...(expansions.includes('choam') ? richeseCards() : []),
...(ecazTreachery ? ecazTreacheryCards() : []),
];
}
/** Printed/default held-card roles, shared with Tupile; never battle-slot legality. */
export function mentatWeaponNames(expansions: readonly string[]): string[] {
return [
...new Set(
inventory(expansions, false)
.filter(
(card) =>
quoteTupileIntelligenceAnswer([card], 0, 'weapons').count === 1,
)
.map((card) => card.name),
),
];
}
export function mentatHand(
hand: readonly Card[],
expansions: readonly string[],
ecazTreachery: boolean,
): Card[] {
const canonical = new Map(
inventory(expansions, ecazTreachery).map((card) => [card.id, card]),
);
if (
!Array.isArray(hand) ||
hand.some(
(card) =>
!card ||
!canonical.has(card.id) ||
JSON.stringify(card) !== JSON.stringify(canonical.get(card.id)),
) ||
new Set(hand.map((card) => card.id)).size !== hand.length
)
throw new MentatQuestionError(
'Mentat needs a valid uniquely held physical Treachery hand.',
);
return hand.map((card) => ({ ...card }));
}
export function quoteMentatReveal(
hand: readonly Card[],
weapon: string,
expansions: readonly string[],
ecazTreachery: boolean,
): { cards: Card[] } {
if (!mentatWeaponNames(expansions).includes(weapon))
throw new MentatQuestionError('Name one specific canonical weapon.');
const held = mentatHand(hand, expansions, ecazTreachery);
if (!held.length) throw new MentatQuestionError(MENTAT_EMPTY_HAND);
const matching = held.filter((card) => card.name === weapon);
return {
cards: matching.length ? matching : held,
};
}
export function mentatSignature(
receipt: MentatQuestionReceipt | MentatObservation,
): string {
return JSON.stringify({ ...receipt, signature: undefined });
}