From f1bef950e0fcc07d2845cb2be2a805819a91f459 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:59:52 +1100 Subject: [PATCH] Handle Sabotage gifts at the recipient hand limit --- components/moritani-terror.tsx | 45 ++++++++- docs/CURRENT_STATUS.md | 14 ++- docs/IMPLEMENTATION_STATUS.md | 9 ++ docs/MORITANI_TERROR_RULES.md | 18 ++++ docs/RULE_DECISIONS.md | 8 ++ game/engine.ts | 55 +++++++---- game/faction-reference.ts | 4 +- game/reference.ts | 2 +- game/sabotage-gift.ts | 71 +++++++++++++++ tests/moritani-entry.test.ts | 44 +++++++-- tests/sabotage-gift-controls.test.tsx | 49 ++++++++++ tests/sabotage-gift-overflow-runtime.test.ts | 96 ++++++++++++++++++++ tests/sabotage-gift.test.ts | 55 +++++++++++ 13 files changed, 440 insertions(+), 30 deletions(-) create mode 100644 game/sabotage-gift.ts create mode 100644 tests/sabotage-gift-controls.test.tsx create mode 100644 tests/sabotage-gift-overflow-runtime.test.ts create mode 100644 tests/sabotage-gift.test.ts diff --git a/components/moritani-terror.tsx b/components/moritani-terror.tsx index f916cd7b..28493cbf 100644 --- a/components/moritani-terror.tsx +++ b/components/moritani-terror.tsx @@ -404,6 +404,22 @@ export function MoritaniPlacement({ ); } +/** The card identity is private to the recipient and never part of the public + * Terror marker or another seat's controls. */ +export function SabotageGiftDiscardButton({ card, act, disabled }: { + card: string | null; + act: (action: Action) => void; + disabled: boolean; +}) { + return ( + + ); +} + export function MoritaniEntry({ game, act, @@ -430,6 +446,33 @@ export function MoritaniEntry({ const controlClass = 'h-auto min-h-11 whitespace-normal motion-reduce:transition-none'; + // The giver's selection is already committed. Only the entrant sees their + // own actual hand and chooses one card to restore their hand limit. + if (entry.stage === 'giftDiscard') { + if (entry.entrant !== game.me) return null; + const hand = me.hand ?? []; + const card = hand.find(held => held.id === selectedCard) ?? hand[0]; + const unavailable = disabled || !!game.phaseOpening || !!game.automaticContinuationPending; + return ( +
+

Sabotage gift · discard a card

+

+ You received a Treachery card and are over your hand limit. Choose one + card from your hand to discard, including the card you just received. + This recipient-choice procedure is a provisional interpretation. +

+ + + +
+ ); + } + // This entrant branch must precede every private kind, definition and token lookup. if (entry.stage === 'allianceReply') { if (entry.entrant !== game.me) return null; @@ -763,7 +806,7 @@ export function MoritaniEntry({ <>

{giving - ? 'Sabotage’s random discard has resolved where possible. You may now give the entrant one of your own cards, or keep your hand.' + ? 'Sabotage’s random discard has resolved where possible. You may now give the entrant one of your own cards, or keep your hand. If the gift exceeds their limit, they choose a card to discard under a provisional procedure.' : 'Your Robbery draw exceeded your hand limit. Choose one of your own cards to discard, including the card just drawn.'}