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 (
+ { if (!disabled && card) act({ type: 'decision', card }); }}>
+ Discard selected card
+
+ );
+}
+
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 (
+
+
+
+ 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.
+
+ Your card to discard
+ setSelectedCard(event.target.value)}>
+ {!hand.length && No available card }
+ {hand.map(held => {held.name} )}
+
+
+
+ );
+ }
+
// 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.'}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 516168ed..769b35ed 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,15 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Sabotage gift at the hand limit — 27 October. Prototyped / Partial.**
+Moritani can give a card to a full-handed entrant, who provisionally chooses one
+held card to discard before the original entry continues. The private choice,
+fresh discard and supported Semuta response share native controls and minimal
+legal bot participation. The exceptional hand was staged with conserved cards
+in runtime evidence. Pre-existing excess hands, broader interactions and full
+rules/deployed acceptance remain open. This procedure is not a publisher ruling.
+[Scope](MORITANI_TERROR_RULES.md#sabotage-gift-to-a-full-hand--27-october-2026).
+
**Basic Kull default and Sapho own-turn last — 27 October. Prototyped / Partial.**
New eligible Basic CHOAM/Ix lobbies select the printed Kull preview by default;
hosts can opt out, and existing lobbies retain their previous selection. At a
@@ -36,8 +45,9 @@ current-lot bid, Revival and ordinary Nexus alliance questions through their
native controls. A Kull transaction suspends controls while the original action
resolves; a Karama purchase cannot evade its actor's bid Yes. Richese Semuta,
phase-wide bid and combined turn-action questions remain excluded, as do
-unfunded non-CHOAM overbids. Automatic Kull activation, wider interactions and
-full rules/deployed acceptance remain open.
+unfunded non-CHOAM overbids. The supported fresh-lobby default is described
+above; wider automatic activation, interactions and full rules/deployed
+acceptance remain open.
[Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-native-truthtrance-questions--27-october-2026).
**Truthtrance ground-movement sectors — 27 October. Prototyped / Partial.**
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index a09fd01b..de1a3f50 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,14 @@
# Dune implementation status
+## 27 October 2026 — Sabotage gift at the hand limit
+
+When Moritani gives a card to a full-handed entrant after Sabotage, the
+recipient provisionally chooses one card to discard, including the gift. The
+physical entry waits through the fresh discard and supported Semuta response.
+The exceptional full hand is staged in the runtime fixture; this is not a
+complete faction or combination certification.
+[Contract](MORITANI_TERROR_RULES.md#sabotage-gift-to-a-full-hand--27-october-2026).
+
## 27 October 2026 — Basic Kull default and Sapho own-turn last
New eligible Basic CHOAM/Ix lobbies select the existing Kull preview by default;
diff --git a/docs/MORITANI_TERROR_RULES.md b/docs/MORITANI_TERROR_RULES.md
index 9b19d203..abe65605 100644
--- a/docs/MORITANI_TERROR_RULES.md
+++ b/docs/MORITANI_TERROR_RULES.md
@@ -1,5 +1,23 @@
# Moritani terror rules and components
+## Sabotage gift to a full hand — 27 October 2026
+
+**Prototyped / Partial.** Moritani may give the chosen physical card to an
+entrant already at their hand limit after the original Sabotage discard. The
+recipient then privately chooses one card from that overflowing hand, including
+the gift, to discard; supported Semuta reactions resolve before the same
+committed entry resumes. A separately played card that returns the hand to its
+limit also closes the pending choice. Pre-existing hands already over the limit
+remain outside this bounded one-card procedure.
+
+The publisher's [expansion rules, printed p. 6](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=6)
+permit the optional gift but do not prescribe this overflow procedure. The
+Treachery Card Stash on printed p. 13 uses a recipient choice for its own
+overflow; applying a similar choice here is a provisional interpretation, not
+an official Sabotage ruling. The [runtime evidence](../tests/sabotage-gift-overflow-runtime.test.ts)
+uses real setup, entry and discard, with the exceptional full hand supplied by
+a labelled conserved-card staging; broader combinations remain open.
+
Audit: 2026-09-06. Scope: the six Terror tokens, setup, placement, entry reactions and continuing effects. Initial audit made no runtime edits; see the implementation checkpoint below. This is an implementation contract with explicit evidence limits, not certification that the faction is playable.
## Native module Atomics — 18 October 2026
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 3f7fb4c4..25506cad 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,13 @@
# Rule decision index
+## Sabotage gift overflow — 27 October 2026
+
+Provisionally let a recipient whose hand is full receive Moritani's optional
+Sabotage gift, then choose one held card to discard. The same entry remains
+pending through that discard and any supported Semuta reaction. The printed
+gift rule does not prescribe overflow; the Stash rule is an analogy, not an
+official ruling on Sabotage. [Scope](MORITANI_TERROR_RULES.md#sabotage-gift-to-a-full-hand--27-october-2026).
+
## Juice of Sapho and an already-started Basic turn — 27 October 2026
Provisionally permit the holder to pause its own combined shipment and movement
diff --git a/game/engine.ts b/game/engine.ts
index 7ba2e477..621aa161 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,4 +1,5 @@
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
+import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
import { kullDefaultEligible } from './kull-default-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
@@ -1863,7 +1864,7 @@ export type Game = {
}
| {
kind: 'terrorDiscard';
- source: 'sabotage' | 'robberyOverflow';
+ source: 'sabotage' | 'robberyOverflow' | 'sabotageGiftOverflow';
owner: string;
entry: NonNullable;
discardedHandSize: number;
@@ -2126,6 +2127,7 @@ export type Game = {
| 'robbery'
| 'discard'
| 'gift'
+ | 'giftDiscard'
| 'sneakAttack'
| 'allianceResponse'
| 'allianceReply';
@@ -8397,7 +8399,7 @@ function treacheryDiscardIntegrity(g: Game) {
);
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
if (entry?.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
- const sabotage = source === 'sabotage';
+ const sabotage = source === 'sabotage' || source === 'sabotageGiftOverflow';
const discarder = sabotage ? entrant : owner;
requireRule(
!g.pendingTerrorEntry &&
@@ -8406,7 +8408,7 @@ function treacheryDiscardIntegrity(g: Game) {
entry &&
entry.turn === g.turn &&
entry.phase === g.phase &&
- ['sabotage', 'robberyOverflow'].includes(source) &&
+ ['sabotage', 'robberyOverflow', 'sabotageGiftOverflow'].includes(source) &&
owner?.faction === 'moritani' &&
entrant &&
entrant.id !== owner.id &&
@@ -8438,7 +8440,7 @@ function treacheryDiscardIntegrity(g: Game) {
tokens[0].kind === (sabotage ? 'sabotage' : 'robbery') &&
tokens[0].status === 'removed' &&
tokens[0].location === null &&
- entry.stage === (sabotage ? 'offer' : 'discard') &&
+ entry.stage === (source === 'sabotageGiftOverflow' ? 'giftDiscard' : sabotage ? 'offer' : 'discard') &&
batch.cause === `terror:${source}` &&
batch.entries.length === 1 &&
!batch.entries[0].publicFace &&
@@ -8447,7 +8449,7 @@ function treacheryDiscardIntegrity(g: Game) {
Number.isSafeInteger(discardedHandSize) &&
discardedHandSize > 0 &&
discardedHandSize === discarder.hand.length + 1 &&
- (sabotage || discardedHandSize > handLimit(owner)),
+ (source === 'sabotage' || discardedHandSize > handLimit(discarder)),
'The saved Terror discard does not match its consumed entry and hand.',
);
} else if (continuation?.kind === 'terrorAtomics') {
@@ -25302,10 +25304,11 @@ function finishTerrorEntry(g: Game) {
/** The token, random victim/draw, discard and arrival have already committed. */
function continueTerrorDiscard(
g: Game,
- source: 'sabotage' | 'robberyOverflow',
+ source: 'sabotage' | 'robberyOverflow' | 'sabotageGiftOverflow',
) {
const entry = g.pendingTerrorEntry!;
const owner = byFaction(g, 'moritani')!;
+ if (source === 'sabotageGiftOverflow') { finishTerrorEntry(g); return; }
const needsChoice =
source === 'sabotage'
? owner.hand.length > 0
@@ -25404,7 +25407,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
entry &&
entry.turn === g.turn &&
entry.phase === g.phase &&
- (entry.stage === 'allianceReply'
+ (entry.stage === 'allianceReply' || entry.stage === 'giftDiscard'
? p.id === entry.entrant
: p.faction === 'moritani'),
'This Terror entry opportunity is no longer current.',
@@ -25571,6 +25574,21 @@ function decideTerror(g: Game, p: Player, action: Action) {
token.status === 'removed',
'Reveal the Terror token before resolving its effect.',
);
+ if (entry.stage === 'giftDiscard') {
+ requireRule(token.kind === 'sabotage' && p.id === entrant.id,
+ 'Only the Sabotage recipient may resolve their excess card.');
+ const cardId = stringField(action.card);
+ try { quoteSabotageGiftDiscard({ recipientHand: p.hand, cardId, handLimit: handLimit(p) }); }
+ catch (error) { if (error instanceof SabotageGiftError) throw new RuleError(error.message); throw error; }
+ const discardedHandSize = p.hand.length;
+ const card = discard(g, p, cardId);
+ log(g, `${p.name} discarded a card after the Sabotage gift to return to their hand limit.`);
+ g.pendingTerrorEntry = null;
+ stageTreacheryDiscard(g, 'terror:sabotageGiftOverflow',
+ [{ card, discardedBy: p.id, publicFace: false }],
+ { kind: 'terrorDiscard', source: 'sabotageGiftOverflow', owner: owner.id, entry, discardedHandSize });
+ return;
+ }
if (entry.stage === 'sneakAttack') {
requireRule(
token.kind === 'sneakAttack',
@@ -25713,13 +25731,18 @@ function decideTerror(g: Game, p: Player, action: Action) {
if (action.decline !== true) {
const card = p.hand.find((c) => c.id === action.card);
requireRule(card, 'Choose a card from your own hand to give.');
- requireRule(
- entrant.hand.length < handLimit(entrant),
- 'This recipient’s exceptional hand overflow is still being implemented. You may decline the gift.',
- );
- p.hand = p.hand.filter((c) => c.id !== card.id);
- entrant.hand.push(card);
+ let gift: ReturnType>;
+ try { gift = quoteSabotageGift({ownerHand:p.hand, recipientHand:entrant.hand, cardId:card.id, handLimit:handLimit(entrant)}); }
+ catch (error) { if (error instanceof SabotageGiftError) throw new RuleError(error.message); throw error; }
+ p.hand = gift.ownerHand;
+ entrant.hand = gift.recipientHand;
log(g, `${p.name} gave ${entrant.name} a Treachery card after Sabotage.`);
+ if (gift.overflow) {
+ entry.stage = 'giftDiscard';
+ g.decision = { kind: 'moritaniTerror', player: entrant.id, entrant: entrant.id, territory: entry.territory };
+ log(g, `${entrant.name} must choose a card to discard after the Sabotage gift. This hand-limit procedure is provisional.`);
+ return;
+ }
} else log(g, `${p.name} declined the optional Sabotage gift.`);
finishTerrorEntry(g);
}
@@ -40084,11 +40107,11 @@ function finishActionContinuations(g: Game) {
}
if (
!g.truthtrance &&
- g.pendingTerrorEntry?.stage === 'discard' &&
+ (g.pendingTerrorEntry?.stage === 'discard' || g.pendingTerrorEntry?.stage === 'giftDiscard') &&
g.decision?.kind === 'moritaniTerror'
) {
- const moritani = getPlayer(g, g.decision.player);
- if (moritani.hand.length <= handLimit(moritani)) {
+ const discarder = getPlayer(g, g.decision.player);
+ if (discarder.hand.length <= handLimit(discarder)) {
g.decision = null;
finishTerrorEntry(g);
}
diff --git a/game/faction-reference.ts b/game/faction-reference.ts
index 619e4ab9..d7eb28af 100644
--- a/game/faction-reference.ts
+++ b/game/faction-reference.ts
@@ -14,7 +14,7 @@ export const FACTION_RULES: Record<
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
- 'The host can opt into the Basic Kull preview with CHOAM, the CHOAM and Ix decks, each selected faction’s deck, and no other optional modules. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; some complex response windows and structured bribe commitments remain unavailable. The preview also withholds other structured Truthtrance questions and defers unfunded non-CHOAM Karama-dependent overbids; see the Kull guide. Complete CHOAM rules remain unfinished.',
+ 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta, while structured bid, Revival, alliance and bribe commitments are unavailable. Phase-wide bid and combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
],
advanced: [
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
@@ -161,7 +161,7 @@ export const FACTION_RULES: Record<
'Before the regular auction sequence, Black Market may offer a concealed card from your hand. The buyer pays you. An unsold card stays in your hand; a completed sale reduces the regular auction pool by one. Atreides can inspect the offered card.',
'Once per game, spend Karama and three spice to acquire an eligible cache card privately for your hand. This is separate from an auction purchase.',
'Richese occupation of Tuek’s Sietch also prevents the Fremen special victory in Advanced play.',
- 'Ordinary mixed No-Field battles and Guild/active-Karama allied prices share Basic’s visibly provisional policies, with native Advanced support and shipment-stop timing. Full-hand Box exchange is likewise provisional. Black Market self-purchase and wider card interactions remain guarded; supplied p34 excludes Ixian Technology from Richese auctions.',
+ 'Ordinary mixed No-Field battles and Guild/active-Karama allied prices share Basic’s visibly provisional policies, with native Advanced support and shipment-stop timing. Full-hand Box exchange is likewise provisional. A positive Black Market self-purchase provisionally keeps the same card while making the normal payment; wider card interactions remain guarded, and supplied p34 excludes Ixian Technology from Richese auctions.',
],
},
ecaz: {
diff --git a/game/reference.ts b/game/reference.ts
index 24931713..256820eb 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4118,7 +4118,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Native revelation retains original Guild invoices, Ambassador/Terror arrival order and selected modules. A caught No-Field provisionally materializes capped reserves before all physical losses. Later alliances now retain the original affected hand-limit targets under the explicit provisional interpretation in the Aftermath topic.',
'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.',
'Robbery: choose between taking half the entrant’s spice, rounded up, and drawing the top Treachery Card. If the draw exceeds your hand limit, choose a card to discard afterward; a full hand does not remove the draw option.',
- 'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional.',
+ 'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional. If the gift puts the recipient above their hand limit, the recipient provisionally chooses one held card to discard, including the gift; the original entry waits for that fresh discard and any supported Semuta reaction.',
'Sneak Attack: send up to five of your reserve forces into the triggered territory at no cost, respecting storm and occupancy restrictions. This particular entry is allowed even when Atomics Aftermath is present.',
'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves. A positive entry during Shipment and Movement is treated as an off-planet shipment for the Heighliners token, which accrues its once-per-turn income for its owner, and the committed entry finishes before Bene Gesserit may answer it with an Intrusion choice or a free spiritual-advisor shipment.',
'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.',
diff --git a/game/sabotage-gift.ts b/game/sabotage-gift.ts
new file mode 100644
index 00000000..e12e7e87
--- /dev/null
+++ b/game/sabotage-gift.ts
@@ -0,0 +1,71 @@
+/** Pure physical-card quotes for the provisional Sabotage gift overflow rule.
+ * The engine owns timing, card reservations, the private decision and the
+ * actual discard continuation. These helpers do not expose hands to a view. */
+export class SabotageGiftError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SabotageGiftError';
+ }
+}
+
+type IdentifiedCard = { id: string };
+const validId = (id: unknown): id is string =>
+ typeof id === 'string' && id.length > 0 && id === id.trim();
+function requireGift(value: unknown, message: string): asserts value {
+ if (!value) throw new SabotageGiftError(message);
+}
+function validateHand(hand: readonly T[]): void {
+ requireGift(Array.isArray(hand) && hand.every(card => card && validId(card.id)) &&
+ new Set(hand.map(card => card.id)).size === hand.length,
+ 'Keep each physical Treachery card uniquely identified in its hand.');
+}
+function validateLimit(limit: number): void {
+ requireGift(Number.isSafeInteger(limit) && limit >= 0, 'Retain the recipient’s current hand limit.');
+}
+
+/** Move one exact physical card; exceeding the limit opens one recipient choice.
+ * Do not construct a fresh face, discard a card or touch the original hands. */
+export function quoteSabotageGift(input: {
+ ownerHand: readonly T[];
+ recipientHand: readonly T[];
+ cardId: string;
+ handLimit: number;
+}): { card: T; ownerHand: T[]; recipientHand: T[]; overflow: 0 | 1 } {
+ requireGift(input && typeof input === 'object', 'Choose the current Sabotage gift.');
+ const { ownerHand, recipientHand, cardId, handLimit } = input;
+ validateHand(ownerHand);
+ validateHand(recipientHand);
+ validateLimit(handLimit);
+ requireGift(!ownerHand.some(held => recipientHand.some(other => other.id === held.id)),
+ 'A physical card cannot be held by both players.');
+ requireGift(recipientHand.length <= handLimit, 'Resolve the recipient’s existing excess cards before giving a card.');
+ requireGift(validId(cardId), 'Choose an exact card in Moritani’s hand.');
+ const card = ownerHand.find(held => held.id === cardId);
+ requireGift(card && !recipientHand.some(held => held.id === cardId),
+ 'Choose one uniquely held card from Moritani’s hand.');
+ return {
+ card,
+ ownerHand: ownerHand.filter(held => held.id !== cardId),
+ recipientHand: [...recipientHand, card],
+ overflow: recipientHand.length === handLimit ? 1 : 0,
+ };
+}
+
+/** The recipient, not the giver, chooses one card from the actual overflowing
+ * hand. They may discard the received card. The caller stages that discard. */
+export function quoteSabotageGiftDiscard(input: {
+ recipientHand: readonly T[];
+ cardId: string;
+ handLimit: number;
+}): { card: T; recipientHand: T[]; overflow: 0 } {
+ requireGift(input && typeof input === 'object', 'Resolve the current Sabotage gift.');
+ const { recipientHand, cardId, handLimit } = input;
+ validateHand(recipientHand);
+ validateLimit(handLimit);
+ requireGift(recipientHand.length === handLimit + 1,
+ 'The recipient must retain the original one-card Sabotage overflow.');
+ requireGift(validId(cardId), 'Choose one card from your hand to discard.');
+ const card = recipientHand.find(held => held.id === cardId);
+ requireGift(card, 'Choose one card from your current hand to discard.');
+ return { card, recipientHand: recipientHand.filter(held => held.id !== cardId), overflow: 0 };
+}
diff --git a/tests/moritani-entry.test.ts b/tests/moritani-entry.test.ts
index f4ec641a..371481d8 100644
--- a/tests/moritani-entry.test.ts
+++ b/tests/moritani-entry.test.ts
@@ -394,18 +394,20 @@ void test('worm entry pauses the remaining ride queue until Terror resolves, the
rejected(done, 'm', { type: 'decision', decline: true });
});
-void test('Sabotage refuses exceptional recipient overflow but leaves a safe optional pass', () => {
+void test('Sabotage permits a full recipient to discard the gift, or the giver may decline', () => {
const initial = fixture('sabotage');
hold(initial, 'm', 1);
const offered = decide(enter(initial), { reveal: true });
- // A persisted exceptional hand state must not turn the optional gift into an overflow bypass.
+ // Controlled exceptional hand fill; the same original entry remains pending.
hold(offered, 'e', 4);
- rejected(
- offered,
- 'm',
- { type: 'decision', card: player(offered, 'm').hand[0].id },
- /overflow/,
- );
+ const card = player(offered, 'm').hand[0].id;
+ const gifted = decide(reload(offered), { card });
+ assert.equal(gifted.pendingTerrorEntry?.stage, 'giftDiscard');
+ assert.equal(gifted.decision?.player, 'e');
+ const resolved = applyAction(gifted, 'e', { type: 'decision', card });
+ assert.equal(player(resolved, 'e').hand.length, 4);
+ assert.equal(resolved.pendingTerrorEntry, null);
+ assert.equal(resolved.discard.filter(row => row.id === card).length, 1);
const passed = decide(offered, { decline: true });
assert.equal(player(passed, 'e').hand.length, 4);
assert.equal(player(passed, 'm').hand.length, 1);
@@ -461,6 +463,32 @@ void test('resolved Truthtrance can satisfy Robbery overflow without leaving a s
rejected(g, 'm', { type: 'decision', card: player(g, 'm').hand[0].id });
});
+void test('resolved Truthtrance can satisfy Sabotage gift overflow without leaving a stale choice', () => {
+ const initial = fixture('sabotage');
+ hold(initial, 'm', 1);
+ const offered = decide(enter(initial), { reveal: true });
+ const index = offered.deck.findIndex(card => card.effect === 'truthtrance');
+ assert.ok(index >= 0);
+ const truth = offered.deck.splice(index, 1)[0];
+ player(offered, 'e').hand.push(truth);
+ hold(offered, 'e', 3);
+ let game = decide(offered, { card: player(offered, 'm').hand[0].id });
+ assert.equal(game.pendingTerrorEntry?.stage, 'giftDiscard');
+ game = applyAction(game, 'e', { type: 'card', card: truth.id });
+ while (game.truthtrance?.stage === 'priority') {
+ const id = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!.id;
+ game = applyAction(game, id, { type: 'truthPass' });
+ }
+ game = applyAction(game, 'e', { type: 'truthAsk', question: {
+ kind: 'fact', target: 'a', fact: { kind: 'hand', name: 'Shield' },
+ } });
+ game = applyAction(reload(game), 'a', { type: 'truthAnswer', answer: 'no' });
+ assert.equal(player(game, 'e').hand.length, 4);
+ assert.equal(game.pendingTerrorEntry, null);
+ assert.equal(game.decision, null);
+ rejected(game, 'e', { type: 'decision', card: player(game, 'e').hand[0].id });
+});
+
void test('changing a hidden face leaves the entrant projection unchanged before revelation', () => {
const initial = fixture();
const alternative = structuredClone(initial);
diff --git a/tests/sabotage-gift-controls.test.tsx b/tests/sabotage-gift-controls.test.tsx
new file mode 100644
index 00000000..bd34196d
--- /dev/null
+++ b/tests/sabotage-gift-controls.test.tsx
@@ -0,0 +1,49 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { baseDeck } from '../game/cards';
+import { MoritaniEntry, SabotageGiftDiscardButton } from '../components/moritani-terror';
+
+/** UI-only projection; the runtime suite owns the real physical arrival. */
+function projected(viewer: 'entrant' | 'moritani'): GameView {
+ const game = createGame('GIFTCONTROLS', newPlayer('moritani', 'Moritani', 'moritani'), false, ['ecaz']);
+ joinGame(game, newPlayer('entrant', 'Entrant', 'emperor'));
+ const view = viewGame(game, viewer);
+ view.status = 'playing';
+ view.phase = 5;
+ view.decision = { kind: 'moritaniTerror', player: 'entrant', entrant: 'entrant', territory: 'arrakeen' };
+ view.terrorEntry = { entrant: 'entrant', territory: 'arrakeen', sector: 9,
+ cause: 'movement', stage: 'giftDiscard' } as unknown as GameView['terrorEntry'];
+ const own = view.players.find(player => player.id === viewer)!;
+ own.hand = baseDeck().slice(0, 2);
+ return view;
+}
+
+void test('only the recipient sees their own cards and the provisional overflow choice', () => {
+ const entrant = projected('entrant');
+ const hand = entrant.players.find(player => player.id === 'entrant')!.hand!;
+ const html = renderToStaticMarkup(createElement(MoritaniEntry, { game: entrant, busy: false, act() {} }));
+ assert.match(html, /Sabotage gift · discard a card/);
+ assert.match(html, /including the card you just received/);
+ assert.match(html, /provisional interpretation/);
+ for (const card of hand) {
+ assert.ok(html.includes(card.name));
+ assert.ok(html.includes(card.id));
+ }
+ const other = projected('moritani');
+ assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: other, busy: false, act() {} })), '');
+});
+
+void test('the recipient button submits only a chosen physical card and respects busy state', () => {
+ const actions: Action[] = [];
+ const submit = (action: Action) => { actions.push(action); };
+ SabotageGiftDiscardButton({ card: 'received-card', act: submit, disabled: false }).props.onClick();
+ assert.deepEqual(actions, [{ type: 'decision', card: 'received-card' }]);
+ SabotageGiftDiscardButton({ card: 'received-card', act: submit, disabled: true }).props.onClick();
+ SabotageGiftDiscardButton({ card: null, act: submit, disabled: false }).props.onClick();
+ assert.equal(actions.length, 1);
+ const html = renderToStaticMarkup(createElement(MoritaniEntry, { game: projected('entrant'), busy: true, act() {} }));
+ assert.match(html, /disabled=""[^>]*>Discard selected card/);
+});
diff --git a/tests/sabotage-gift-overflow-runtime.test.ts b/tests/sabotage-gift-overflow-runtime.test.ts
new file mode 100644
index 00000000..7b247e95
--- /dev/null
+++ b/tests/sabotage-gift-overflow-runtime.test.ts
@@ -0,0 +1,96 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { handLimit, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import {
+ createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
+ inventory, player, passCurrentSemuta as passSemuta, claimOverlapSemuta as claimSemuta,
+ openOverlapTerrorDiscard,
+} from './fixture-semuta-arrival-overlap';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+/** Real public Basic setup, original token placement, paid shipment and
+ * Sabotage discard. Only the exceptional intervening hand fill is staged:
+ * original unopened physical cards move from the deck to the entrant's hand. */
+function source() {
+ const f = fixture({ effect: 'choam', first: 'terror', arrival: 'shipment', terror: 'sabotage' });
+ let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
+ assert.equal(game.pendingTerrorEntry?.stage, 'offer');
+ game = openOverlapTerrorDiscard(game, f);
+ const originalDiscard = game.pendingTreacheryDiscard!.batch.event;
+ game = passSemuta(game);
+ assert.equal(game.pendingTerrorEntry?.stage, 'gift');
+ assert.equal(game.decision?.kind, 'moritaniTerror');
+ assert.equal(game.decision?.player, f.moritani);
+ assert.ok(player(game, f.moritani).hand.some(card => card.id === f.gift));
+ const beforeFill = structuredClone(game);
+ while (player(game, f.entrant).hand.length < handLimit(player(game, f.entrant))) {
+ const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
+ assert.ok(index >= 0);
+ player(game, f.entrant).hand.push(game.deck.splice(index, 1)[0]);
+ }
+ assert.equal(player(game, f.entrant).hand.length, handLimit(player(game, f.entrant)));
+ assert.deepEqual(inventory(game), f.originalInventory);
+ return { f, game, beforeFill, originalDiscard };
+}
+
+function finishArrival(game: Game, f: ReturnType) {
+ if (game.pendingAmbassador?.stage === 'offer')
+ game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador.event, decline: true });
+ assert.equal(game.pendingTerrorEntry, null);
+ assert.equal(game.pendingTreacheryDiscard, null);
+ assert.equal(game.pendingArrivalOverlap, null);
+ assert.equal(game.active, f.entrant);
+ assert.equal(player(game, f.entrant).forces[f.location], 1);
+ assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - 1);
+ assert.equal(player(game, f.entrant).shipped, true);
+ assert.deepEqual(inventory(game), f.originalInventory);
+ reject(game, f.entrant, f.arrivalStep.action);
+ return game;
+}
+
+for (const discard of ['gift', 'existing'] as const) for (const claim of [false, true]) {
+ void test(`Sabotage full-hand entrant discards ${discard}; Semuta ${claim ? 'claims' : 'passes'} once`, () => {
+ const { f, game: sourceGame, originalDiscard } = source();
+ const priorHand = player(sourceGame, f.entrant).hand.map(card => card.id);
+ const discardId = discard === 'gift' ? f.gift : priorHand[0];
+ let game = act(sourceGame, f.moritani, { type: 'decision', card: f.gift });
+ assert.equal(game.pendingTerrorEntry?.stage, 'giftDiscard');
+ assert.equal(game.decision?.kind, 'moritaniTerror');
+ assert.equal(game.decision?.player, f.entrant);
+ assert.equal(player(game, f.entrant).hand.length, handLimit(player(game, f.entrant)) + 1);
+ assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
+ assert.ok(!player(game, f.moritani).hand.some(card => card.id === f.gift));
+ for (const seat of game.players.filter(row => row.id !== f.entrant))
+ assert.equal(viewGame(game, seat.id).players.find(row => row.id === f.entrant)?.hand, undefined);
+ reject(game, f.moritani, { type: 'decision', card: discardId });
+ reject(game, f.entrant, { type: 'decision', card: 'stale-or-foreign-card' });
+ reject(game, f.entrant, { type: 'decision', decline: true });
+ const bot = viewGame(game, f.entrant);
+ bot.players.find(row => row.id === f.entrant)!.bot = 'Easy';
+ const botAction = botActions(bot).find(action => action.type === 'decision' &&
+ typeof action.card === 'string' && player(game, f.entrant).hand.some(card => card.id === action.card));
+ assert.ok(botAction, 'A minimal bot must select its own actual excess card');
+ const botResult = act(structuredClone(game), f.entrant, botAction);
+ assert.equal(player(botResult, f.entrant).hand.length, handLimit(player(botResult, f.entrant)));
+ game = act(game, f.entrant, { type: 'decision', card: discardId });
+ const pending = game.pendingTreacheryDiscard; assert.ok(pending);
+ assert.equal(pending.batch.cause, 'terror:sabotageGiftOverflow');
+ assert.notEqual(pending.batch.event, originalDiscard);
+ assert.equal(pending.batch.entries.length, 1);
+ assert.equal(pending.batch.entries[0].card.id, discardId);
+ assert.equal(pending.batch.entries[0].discardedBy, f.entrant);
+ assert.equal(pending.batch.entries[0].publicFace, false);
+ assert.equal(player(game, f.entrant).hand.length, handLimit(player(game, f.entrant)));
+ assert.deepEqual(inventory(game), f.originalInventory);
+ game = claim ? claimSemuta(game, f.claimant, discardId) : passSemuta(game);
+ assert.equal(player(game, f.claimant).hand.some(card => card.id === discardId), claim);
+ assert.equal(player(game, f.entrant).hand.some(card => card.id === f.gift), discard !== 'gift');
+ finishArrival(game, f);
+ });
+}
diff --git a/tests/sabotage-gift.test.ts b/tests/sabotage-gift.test.ts
new file mode 100644
index 00000000..78c33a08
--- /dev/null
+++ b/tests/sabotage-gift.test.ts
@@ -0,0 +1,55 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { quoteSabotageGift, quoteSabotageGiftDiscard, SabotageGiftError } from '../game/sabotage-gift';
+
+const card = (id: string) => Object.freeze({ id, face: `face of ${id}` });
+
+void test('a Sabotage gift transfers the same physical card without mutating either hand', () => {
+ const given = card('gift'), held = card('keep'), existing = card('existing');
+ const ownerHand = Object.freeze([given, held]), recipientHand = Object.freeze([existing]);
+ const result = quoteSabotageGift({ ownerHand, recipientHand, cardId: given.id, handLimit: 2 });
+ assert.equal(result.card, given);
+ assert.deepEqual(result.ownerHand, [held]);
+ assert.deepEqual(result.recipientHand, [existing, given]);
+ assert.equal(result.overflow, 0);
+ assert.deepEqual(ownerHand, [given, held]);
+ assert.deepEqual(recipientHand, [existing]);
+});
+
+void test('a full recipient chooses any actual card, including the gift, to resolve one overflow', () => {
+ const cards = [card('a'), card('b'), card('c'), card('d')];
+ const given = card('gift');
+ const gift = quoteSabotageGift({ ownerHand: [given], recipientHand: cards, cardId: given.id, handLimit: 4 });
+ assert.equal(gift.overflow, 1);
+ for (const choice of [cards[1], given]) {
+ const before = [...gift.recipientHand];
+ const discard = quoteSabotageGiftDiscard({ recipientHand: gift.recipientHand, cardId: choice.id, handLimit: 4 });
+ assert.equal(discard.card, choice);
+ assert.equal(discard.overflow, 0);
+ assert.equal(discard.recipientHand.length, 4);
+ assert.ok(!discard.recipientHand.some(held => held.id === choice.id));
+ assert.deepEqual(gift.recipientHand, before);
+ }
+});
+
+void test('gift quotes reject stale, duplicate and already-invalid physical hands', () => {
+ const gift = card('gift'), other = card('other');
+ for (const input of [
+ { ownerHand: [gift], recipientHand: [other], cardId: 'missing', handLimit: 4 },
+ { ownerHand: [gift, gift], recipientHand: [other], cardId: gift.id, handLimit: 4 },
+ { ownerHand: [gift], recipientHand: [gift], cardId: gift.id, handLimit: 4 },
+ { ownerHand: [gift], recipientHand: [other], cardId: gift.id, handLimit: 0 },
+ { ownerHand: [gift], recipientHand: [other], cardId: gift.id, handLimit: -1 },
+ { ownerHand: [gift], recipientHand: [other], cardId: ' gift ', handLimit: 4 },
+ ]) assert.throws(() => quoteSabotageGift(input), SabotageGiftError);
+});
+
+void test('overflow resolution rejects a stale limit, unavailable card or duplicate custody', () => {
+ const a = card('a'), b = card('b');
+ for (const input of [
+ { recipientHand: [a, b], cardId: a.id, handLimit: 2 },
+ { recipientHand: [a, b], cardId: 'missing', handLimit: 1 },
+ { recipientHand: [a, a], cardId: a.id, handLimit: 1 },
+ { recipientHand: [a, b], cardId: a.id, handLimit: 0 },
+ ]) assert.throws(() => quoteSabotageGiftDiscard(input), SabotageGiftError);
+});