171 lines
13 KiB
TypeScript
171 lines
13 KiB
TypeScript
import type { Card } from './cards';
|
||
import { BATTLE_CARD_HELP, battleCardLabel } from './battle-cards';
|
||
import { RULE_TOPICS, type RuleTopic } from './reference';
|
||
import { ecazTreacheryDefinition } from './ecaz-cards';
|
||
import { richeseCardDefinition } from './richese-cards';
|
||
import { printedCardCategory } from './card-category';
|
||
|
||
/** Presentation uses only a card the caller has already been authorized to see. */
|
||
export type VisibleCard = Readonly<
|
||
Pick<Card, 'id' | 'name' | 'kind' | 'effect'>
|
||
>;
|
||
export type CardPresentation = {
|
||
category: string;
|
||
role: 'weapon' | 'defense' | 'leader' | 'utility';
|
||
guidance: string;
|
||
/** Complete player-facing rules for a canonical component, separate from audit metadata. */
|
||
gameplay?: readonly string[];
|
||
/** A bounded component may be inspectable before its actions are implemented. */
|
||
availability?: string;
|
||
topics: readonly RuleTopic[];
|
||
};
|
||
|
||
/** UI eligibility only; exact identity avoids disabling unrelated special cards. */
|
||
export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||
const definition = richeseCardDefinition(card);
|
||
if (definition?.card.effect === 'stoneBurner')
|
||
return 'Choose Stone Burner in your battle weapon slot, then choose its leader effect after both plans are revealed.';
|
||
if (definition?.card.effect === 'mirrorWeapon')
|
||
return 'Choose Mirror Weapon in your battle weapon slot. It copies the opposing revealed weapon attack, with your own first post-reveal choice if needed.';
|
||
if (definition?.card.effect === 'portableSnooper')
|
||
return 'Choose Portable Snooper as your battle defense, or use its late-defense panel after reveal before submitting your traitor decision.';
|
||
if (definition?.card.effect === 'residualPoison')
|
||
return 'Use the Residual Poison panel before either combatant commits a leader.';
|
||
if (definition?.card.effect === 'ornithopter')
|
||
return 'Use the Ornithopter movement controls with your selected force group.';
|
||
if (definition?.card.effect === 'nullentropyBox')
|
||
return 'Use the Nullentropy Box panel to pay for a private search.';
|
||
if (definition?.card.effect === 'juiceOfSapho')
|
||
return 'Use the Juice of Sapho panel to choose an available order change or become battle aggressor. Fresh Richese battles can offer the aggressor choice until both combatants finish their final battle actions; older battles retain their pre-plan opportunity.';
|
||
if (definition?.card.effect === 'distrans')
|
||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||
if (definition?.card.effect === 'semutaDrug')
|
||
return 'Use the neutral fresh-discard response when offered. Fresh bare Basic Richese previews enable it with their selected native families; older games and separate module previews keep their existing scope. Connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.';
|
||
return definition && definition.card.effect !== 'karama'
|
||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||
: null;
|
||
}
|
||
|
||
const BASIC_GUIDANCE: Partial<Record<Card['kind'], string>> = {
|
||
projectile:
|
||
'Play in the weapon slot of your battle plan. A projectile weapon kills an opposing leader who has no projectile defense.',
|
||
poison:
|
||
'Play in the weapon slot of your battle plan. A poison weapon kills an opposing leader who has no poison defense.',
|
||
lasgun:
|
||
'Play in the weapon slot. A lasgun kills the opposing leader. If either player uses a Shield or Shield Snooper, a lasgun explosion takes precedence over ordinary weapon effects; traitor resolution comes first.',
|
||
shield:
|
||
'Play in the defense slot to protect your leader from projectile weapons. A lasgun played against any shield causes an explosion unless traitor resolution overrides it.',
|
||
snooper:
|
||
'Play in the defense slot to protect your leader from ordinary poison weapons. A Snooper does not protect against an activated Poison Tooth.',
|
||
worthless:
|
||
'May occupy either the weapon or defense slot in a battle plan, but supplies no attack or protection. Some factions can spend Worthless cards for their own powers.',
|
||
hero: 'Use instead of a leader in your battle plan. A Cheap Hero or Heroine has zero leader strength and is discarded after use.',
|
||
};
|
||
|
||
const BATTLE_TOPIC: Partial<Record<Card['kind'], string>> = {
|
||
poisonTooth: 'card-poison-tooth',
|
||
artillery: 'card-artillery',
|
||
poisonBlade: 'ix-battle-cards',
|
||
shieldSnooper: 'ix-battle-cards',
|
||
weirdingWay: 'ix-battle-cards',
|
||
chemistry: 'ix-battle-cards',
|
||
hero: 'cheap-hero-traitor',
|
||
};
|
||
|
||
/** Canonical component guides and the internal reference supply original explanations.
|
||
* Audit metadata never enters this presentation or its rendered rules text.
|
||
*/
|
||
export function cardPresentation(card: VisibleCard): CardPresentation {
|
||
const richese = richeseCardDefinition(card);
|
||
if (richese)
|
||
return {
|
||
category: richese.printedType,
|
||
role:
|
||
richese.behavior.battleCategory === 'weapon'
|
||
? 'weapon'
|
||
: richese.behavior.battleCategory === 'poison-defense'
|
||
? 'defense'
|
||
: 'utility',
|
||
guidance: richese.summary,
|
||
gameplay: richese.gameplay,
|
||
availability:
|
||
richese.card.effect === 'juiceOfSapho'
|
||
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and a shared aggressor opportunity. Fresh Richese battles can offer it until both combatants finish final battle actions; older battles retain their pre-plan opportunity. Earlier bids stay committed. Other timing and phase combinations remain unfinished; supported Richese and expansion starts are available as unfinished previews.'
|
||
: richese.card.effect === 'semutaDrug'
|
||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity, native capture/Auditor/battle-income/Ix-substitution/Basic allied-retention powers, or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Acquiring a printed cancellation cost never restores its canceled effect. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Fresh bare Basic Richese previews now initialize these reactions with selected native E1/E2/E3 families. Clean native or BG-copy CHOAM and Ixian Ambassador private discards after ordinary shipment/movement retain the committed payout or pause before the replacement draw. Old games are unchanged; optional/Advanced entries retain their existing scope. Other pending rewards, producers and nested arrivals remain unfinished.'
|
||
: richese.card.effect === 'mirrorWeapon'
|
||
? 'Mirror Weapon works in supported CHOAM/Richese-deck tables, including selected Ix/CHOAM with original Tech3+ and Advanced Strongholds2+, or existing Advanced native Ecaz/Moritani. Copied Tooth or Stone chooses before its original; a winning physical Mirror may be kept after copied Tooth or Artillery under the user-selected retention interpretation. Original Stone/Tooth uses provisional Stone-first. Typed public allocation safety, mixed E3 phase-module and other profile/full-start guards remain.'
|
||
: richese.card.effect === 'stoneBurner'
|
||
? 'Stone Burner has development controls, including selected CHOAM/Ix with original Tech3+ and Advanced Strongholds2+. Earned Habbanya may decide a physical-token tie; +2 Reinforcements does not. Original Stone provisionally chooses before uncopied Tooth; Mirror copies still choose first as printed. Winner-changing physical allocations, mixed E3 phase modules and other profile/full-start limits remain guarded.'
|
||
: 'Richese effect integration and verification are incomplete. Karama, Distrans, Nullentropy Box, Ornithopter, Residual Poison, Portable Snooper, Stone Burner and Mirror Weapon have bounded card handlers; supported Richese and expansion starts are available as unfinished previews.',
|
||
topics: RULE_TOPICS.filter(
|
||
(topic) =>
|
||
topic.id ===
|
||
(richese.card.effect === 'juiceOfSapho'
|
||
? 'juice-of-sapho'
|
||
: richese.card.effect === 'stoneBurner'
|
||
? 'stone-burner'
|
||
: richese.card.effect === 'portableSnooper'
|
||
? 'portable-snooper'
|
||
: 'richese-cards'),
|
||
),
|
||
};
|
||
const ecaz = ecazTreacheryDefinition(card);
|
||
if (ecaz && ecaz.card.name === card.name)
|
||
return {
|
||
category: 'Special treachery',
|
||
role: 'utility',
|
||
guidance: ecaz.summary,
|
||
gameplay: ecaz.gameplay,
|
||
availability: ecaz.card.effect === 'recruits'
|
||
? 'Recruits has Revival controls in classic and selected Ix/CHOAM Basic/Advanced Worlds, optionally retaining original Tech3+ and Advanced Strongholds2+. Double current native free rates within the ordinary seven-force total; native unlimited allowances, typed deposits and special-force caps stay. Axlotl keeps its original once-per-phase trigger and delayed payout. Prior paid returns keep payment under the provisional no-refund policy. Pending transactions, unknown ledgers, later Fremen grants and replay remain guarded.'
|
||
: ecaz.card.effect === 'harassWithdraw'
|
||
? 'Harass & Withdraw uses either battle-card slot and exact physical allocation after reveal, including classic and selected Ix/CHOAM Worlds with original Tech3+ and Advanced Strongholds2+. Return actual native types: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; other natives to their original primary World. Never withdraw on any own World. Original commitment/support stays. Returns are not free revival or shipment. Own No-Field provisionally materializes first; Stone keeps its separate profile/allocation guards.'
|
||
: 'Reinforcements uses either slot, including classic and selected Ix/CHOAM Basic/Advanced Worlds with original Tech3+ and Advanced Strongholds2+. Pay three original own reserves normal-first for +2 normal score, not support or physical tokens. Exact native sources seal when ambiguous; an own-World fee cannot also fund its dial, while original traits/face stay. Own No-Field must leave three before payment, including native World reserves; future Harass cannot fund it. Earned bank subsidy pays only actual support. Remaining profile guards and cost/order interpretations stay provisional.',
|
||
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
|
||
};
|
||
const topicIds =
|
||
card.kind === 'special'
|
||
? [`card-${card.effect ?? ''}`]
|
||
: card.kind === 'worthless'
|
||
? [
|
||
'battle-cards',
|
||
'bg-charity-karama',
|
||
...(card.name === 'Trip to Gamont'
|
||
? ['choam-gamont']
|
||
: card.name === 'Jubba Cloak'
|
||
? ['choam-jubba']
|
||
: ['choam-worthless']),
|
||
]
|
||
: [
|
||
'battle-cards',
|
||
...(BATTLE_TOPIC[card.kind] ? [BATTLE_TOPIC[card.kind]!] : []),
|
||
];
|
||
const topics = topicIds
|
||
.map((id) => RULE_TOPICS.find((topic) => topic.id === id))
|
||
.filter((topic): topic is RuleTopic => !!topic);
|
||
const category = printedCardCategory(card);
|
||
const role =
|
||
category === 'hero'
|
||
? 'leader'
|
||
: category === 'special' || category === 'worthless'
|
||
? 'utility'
|
||
: category;
|
||
return {
|
||
category:
|
||
card.kind === 'special'
|
||
? 'Special treachery'
|
||
: battleCardLabel(card.kind),
|
||
role,
|
||
guidance:
|
||
(card.kind === 'special' && card.effect === 'ghola'
|
||
? 'Play at any time to return one eligible dead leader, or up to five forces, for free. Forces return to your reserves. Discard this card after use.'
|
||
: undefined) ??
|
||
BATTLE_CARD_HELP[card.kind] ??
|
||
BASIC_GUIDANCE[card.kind] ??
|
||
topics[0]?.summary ??
|
||
'Inspect the card’s available controls and the internal rules reference for its timing and permitted targets.',
|
||
topics,
|
||
};
|
||
}
|