Handle Sabotage gifts at the recipient hand limit

This commit is contained in:
Quintine committed 2026-10-27 21:59:52 +11:00
1 parent e9154a90f1
commit f1bef950e0
13 files changed
+440 -30

No files matched your search

+39 -16
View File
@@ -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<Game['pendingTerrorEntry']>;
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<typeof quoteSabotageGift<Card>>;
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);
}
+2 -2
View File
@@ -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: {
+1 -1
View File
@@ -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.',
+71
View File
@@ -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<T extends IdentifiedCard>(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<T extends IdentifiedCard>(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<T extends IdentifiedCard>(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 };
}