feat: connect Semuta to reactive CHOAM denial discards

This commit is contained in:
Quintine committed 2026-10-12 02:43:16 +11:00
1 parent 11487e0cf9
commit 3171afc522
9 files changed
+208 -26

No files matched your search

@@ -2,6 +2,16 @@
This checkpoint implements the bounded work identified in [CHOAM_WORTHLESS_CONTINUATION_AUDIT.md](CHOAM_WORTHLESS_CONTINUATION_AUDIT.md). The source audit remains the record of the original defects and applicable publisher evidence. No new rule interpretation or public mode activation is introduced.
11 October 2026 Semuta update: accepted reactive Baliset and La La La now pause
after the used-card cost in the explicit development preview. The original
uncommitted ordinary move or free-force request is already stopped; a typed
marker resumes only the original restriction and notice, never the denied
request. Claim/pass leaves resources unspent and permits another legal move or
paid revival. Canceled declarations still retain their card, create no
Worthless discard and resume the original request under the unchanged quote.
Nexus, movement-card/Ambassador and competing parents remain separate.
See [the bounded runtime evidence](SEMUTA_SOURCE_UPDATE_20260907.md).
## Resolved defects and shared execution
The original in-memory probe produced a real reactive La La La declaration, then corrupted its saved parent. Increasing the request to 99 forces returned negative Tanks after printed cancellation; a null benefit queue accepted a new BG cancellation cost before failing later; a contradictory Mentat flag restored an impossible phase-4 decision. The unchanged probe now rejects all three cases for both printed Karama and BG conversion. These were malformed-save defects, not demonstrated legitimate-action exploits.
+16
View File
@@ -29,6 +29,22 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Semuta / reactive Baliset and La La La — 11 October.** Accepted native
declarations now offer their used card after stopping the original uncommitted
move or free-force request. Claim/pass installs the same restriction once;
denied requests consume no forces, movement, revival usage or spice. Another
legal move or paid revival remains available. Both regressions fail before
and pass after; types/lint and 130 affected unit cases across seven files pass.
Twenty-four runtime scenarios cover both effects, both rule
modes, claim/pass and four legal AI profiles; four Karama cancellations retain
the card and finish the original request without a Worthless discard.
Real GameTable at 390px claims Baliset, keeps the Carthag restriction and
moves once to Arrakeen; its revival case claims La La La, shows the four-spice
quote and returns two paid counters to reserves19/Tanks1/spice6. No overflow.
Other parents remain separate. Temporary QA scaffolds are removed; no saved
store or deployed game was targeted. See
[the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md).
**Semuta / clean battle Distrans — 11 October.** The completed clean transfer
now offers only its public used Distrans during battle. The private gift and
sealed opposing plan remain committed; the recipient may itself claim with
+35
View File
@@ -1,5 +1,40 @@
# Dune implementation status
## 11 October 2026 — reactive Worthless denials with Semuta
The existing `choamBalisetDiscard` and `choamLaLaLaDiscard` receipts now admit
clean reactive ordinary movement/free-force requests. The accepted cost stops
the uncommitted request before staging. An optional true marker records only
that stopped status; retirement installs the original restriction and notice,
never re-executing a move or revival. Existing proactive signatures are
unchanged when the marker is absent. If a competing parent prevents the offer,
the temporary pointer is restored before the original handler runs.
Both native regressions fail before and pass after. Claim/pass preserves
resources, force/movement/revival usage and card inventory; the original target
restriction remains, and a legal alternate move or paid two-force revival
works. Retired events reject. Twenty-four isolated runtime scenarios cover both
effects in Basic/Advanced with claim/pass and all four legal AI profiles.
Four printed-Karama cancellations retain the Worthless card and Semuta, open
no Worthless discard reaction and complete the original request. The first
cancel probe's absent-versus-null internal-state pin was removed in favor of
observing the public reaction.
Real GameTable at 390px performs reactive Prevent movement, Semuta commitment,
visible Carthag restriction, one alternate move to Arrakeen and turn completion.
The revival case performs La La La, Semuta commitment and one actual paid
two-force revival: quote4, reserves19/Tanks1/spice6 and no free usage. Both
surfaces have no horizontal overflow. A Vite optimization reload caused one
initial open timeout; the optimized server opened without an existing-service
restart. Temporary scripts, isolated server and browser tab are removed/stopped;
no saved store or deployed game was targeted.
Types/lint and 130 affected unit cases across seven files pass.
Nexus, movement-card/Ambassador and other competing parents remain outside
these new paths. Cancellation rules and public gates are unchanged.
Contracts: [Semuta](SEMUTA_SOURCE_UPDATE_20260907.md),
[native denial/resumption](CHOAM_WORTHLESS_CANCELLATION_RECOVERY.md).
## 11 October 2026 — Semuta after a clean battle Distrans transfer
The completed `distransDiscard` receipt already binds the donor/recipient hand
+9 -7
View File
@@ -126,21 +126,23 @@ unspent movement. A neutral claim or decline restores the same
one-range increment without replaying the response. Reactive,
Nexus and overlapping movement parents retain their automatic path.
Clean proactive La La La stages `choamLaLaLaDiscard` after its
Clean proactive or reactive La La La stages `choamLaLaLaDiscard` after its
accepted public Worthless card cost but before blocking a selected
target's free force revival. The receipt binds the target, old
Revival rules and physical card. A neutral claim or decline applies
the one saved block; paid revival remains legal. Reactive requests,
Nexus and other Worthless effects retain their automatic paths.
the one saved block; paid revival remains legal. A reactive request is already
stopped and its marker cannot revive or charge it. Nexus and other contexts
retain their automatic paths.
Clean proactive Baliset stages `choamBalisetDiscard` after its
Clean proactive or reactive Baliset stages `choamBalisetDiscard` after its
accepted public Worthless card cost but before restricting the
chosen player from movement into CHOAM's occupied territory. The
receipt binds the target, territory, CHOAM's current forces,
existing restrictions and physical card. A neutral claim or
decline installs the one saved restriction; shipment remains
possible. Declared moves, Nexus conversion and overlapping
arrival parents retain their automatic paths.
decline installs the one saved restriction; shipment remains possible. A clean
ordinary declared move is already stopped without spending its allowance.
Nexus, movement-card/Ambassador and overlapping arrival parents retain their
automatic paths.
Clean proactive ordinary Trip to Gamont stages
`choamGamontDiscard` after the accepted public card cost but
+46 -9
View File
@@ -131,22 +131,23 @@ The same one-range effect then applies exactly once. Fixed-range
Ornithopter combinations, reactive movement parents and other
Worthless powers retain their original automatic paths.
A clean proactive La La La response also pauses after its named
A clean proactive or reactive La La La response also pauses after its named
public Worthless card cost but before blocking the selected
target's remaining free force revival. The saved receipt binds the
target and old Revival rules; claim or decline installs the block
once without replaying the response. Paid revival remains legal.
A reactive free-revival request, Nexus conversion and other
Worthless effects retain their earlier automatic paths.
once without replaying the response. Paid revival remains legal. A reactive
free-revival request is already stopped without revival or payment. Nexus
conversion and other Worthless contexts retain their automatic paths.
A clean proactive Baliset response pauses after its public
A clean proactive or reactive Baliset response pauses after its public
Worthless card cost but before restricting the selected player
from moving into the CHOAM-occupied territory. The saved
receipt binds that target, territory, current CHOAM occupancy
and existing restrictions. Claim or decline installs one
restriction without replaying the response. Shipment remains
legal. Reactive declared movement, Nexus conversion and
competing arrivals retain their original automatic paths.
legal. A clean ordinary declared move is already stopped without spending a
movement. Movement-card/Ambassador parents, Nexus conversion and competing
arrivals retain their original automatic paths.
A clean proactive ordinary Trip to Gamont response pauses after
its named public card cost but before moving one selected force
@@ -165,8 +166,8 @@ spice-clearance sequence. Canceled Jubba, Nexus conversion
and competing parents retain their original direct paths.
An early-ended Ornithopter advances the saved queue; a completed
flight resumes one committed arrival only without BG, Ecaz or
Moritani entry reactions. Distrans's gift remains private with its
flight may resume native Bene Gesserit Intrusion; Ecaz and Moritani entry
reactions remain separate. Distrans's gift remains private with its
recipient; Truthtrance answer/history and Sapho first/last order are
already bound. A clean Once Around Sapho discard commits its public
finite bidder order, retains prior bids/funding and frees the holder's
@@ -247,6 +248,42 @@ separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
auto-closing only when nobody secretly holds the card would leak
possession. The main game and public expansion gates remain closed.
**11 October 2026 — reactive Baliset and La La La.** Accepted native
declarations now offer their used public card in the clean reactive paths too.
After the cost, the uncommitted ordinary move or force-revival request is
detached as already stopped; the existing typed receipt carries only
`stoppedMovement` or `stoppedRevival`, not a request to execute. Claim or all-seat
decline installs the original restriction and stopped-request notice once.
No forces, movement allowance, revival usage or spice payment were consumed by
the denied request. Another legal move or a paid revival remains available.
When another parent prevents the offer, the temporary pointer is restored and
the original automatic handler runs. Proactive receipts retain their existing
signature when the new optional marker is absent.
Both permanent regressions fail before and pass after, covering claim/pass,
unchanged request resources, retained restrictions, a legal alternate move or
paid two-force revival and retired-event rejection. Twenty-four isolated
runtime scenarios cover both effects, Basic/Advanced, claim/pass and four legal
AI profiles. Four additional printed-Karama cancellations retain the Worthless
and Semuta, open no Worthless discard reaction, and complete the original move
or free revival. The first cancel probe pinned absent-versus-null internal state;
its corrected assertion observes the actual public reaction instead.
Real GameTable at 390px uses Prevent movement, Commit Semuta, the unchanged
Imperial Basin source and the visible Carthag restriction, then moves one force
legally to Arrakeen and ends the turn with one movement. A second memory-only
case uses La La La, commits Semuta, displays zero free allowance and the actual
four-spice quote, then revives two paid forces to reserves19/Tanks1/spice6.
Neither surface overflows horizontally. The isolated Vite dependency reload
caused one browser-open timeout; the optimized server opened successfully
without restarting any existing service. Temporary scripts/server/browser are
removed or stopped; no saved store or deployed game was targeted.
Types/lint and 130 affected unit cases across seven files pass.
Nexus, movement-card/Ambassador and other competing parents remain separate.
This is bounded reaction coverage, not a new publisher cancellation/timing
ruling, complete CHOAM/Semuta or deployed acceptance.
**11 October 2026 — clean battle Distrans.** A completed clean private
Distrans transfer now offers its public used card during battle. Only the
battle-presence guard gained that existing receipt exception; transfer legality,
+2 -2
View File
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
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 in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La also pause after cost without completing the stopped move or free revival. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
@@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
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 aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
: 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 or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry 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. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
? '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 or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry 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. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
: richese.card.effect === 'mirrorWeapon'
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
: richese.card.effect === 'stoneBurner'
+25 -4
View File
@@ -1379,6 +1379,7 @@ export type Game = {
player: string;
card: string;
target: string;
stoppedRevival?: true;
stateSignature: string;
}
| {
@@ -1387,6 +1388,7 @@ export type Game = {
card: string;
target: string;
territory: string;
stoppedMovement?: true;
stateSignature: string;
}
| {
@@ -5218,6 +5220,7 @@ function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation)
const owner = getPlayer(g, c.player), target = getPlayer(g, c.target);
return JSON.stringify({
player: c.player, card: c.card, target: c.target,
...(c.stoppedRevival ? { stoppedRevival: true } : {}),
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
revivalRules: g.revivalRules, pendingRevival: g.pendingRevival,
parent: nullentropyParentSignature(g,
@@ -5235,6 +5238,7 @@ function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuatio
const owner = getPlayer(g, c.player);
return JSON.stringify({
player: c.player, card: c.card, target: c.target, territory: c.territory,
...(c.stoppedMovement ? { stoppedMovement: true } : {}),
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
restrictions: g.choamBaliset, remaining: g.movementRemaining,
parent: nullentropyParentSignature(g,
@@ -5928,6 +5932,7 @@ function treacheryDiscardIntegrity(g: Game) {
requireRule(
g.phase === 4 && owner?.faction === 'choam' &&
g.players.some(p => p.id === c.target) &&
(c.stoppedRevival === undefined || c.stoppedRevival === true) &&
!g.revivalRules?.freeBlocked?.includes(c.target) &&
!g.pendingChoamWorthless && !g.pendingRevival &&
!g.response && !g.decision && !g.phaseOpening &&
@@ -5944,6 +5949,7 @@ function treacheryDiscardIntegrity(g: Game) {
requireRule(
g.phase === 5 && owner?.faction === 'choam' &&
c.target !== c.player && g.players.some(p => p.id === c.target) &&
(c.stoppedMovement === undefined || c.stoppedMovement === true) &&
at(owner, c.territory) > 0 &&
!g.pendingChoamWorthless && !g.pendingChoamMove &&
!g.response && !g.decision && !g.phaseOpening && !g.ornithopter &&
@@ -6945,8 +6951,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
completeChoamKulon(g, getPlayer(g, next.player));
} else if (next.kind === 'choamLaLaLaDiscard') {
completeChoamLaLaLa(g, next.target);
if (next.stoppedRevival)
log(g, 'The free revival request was stopped without moving forces or paying spice. Choose a paid revival if desired.');
} else if (next.kind === 'choamBalisetDiscard') {
completeChoamBaliset(g, next.target, next.territory);
if (next.stoppedMovement)
log(g, 'The declared move was prevented without moving forces or spending a movement.');
} else if (next.kind === 'choamGamontDiscard') {
completeChoamGamont(g, getPlayer(g, next.player), next.target,
next.location, 0);
@@ -24008,12 +24018,17 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
completeChoamJubba(g, choam, pending.location!);
} else if (pending.effect === 'baliset') {
if (g.semutaPreview && used.name === 'Baliset' &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
!pending.storm && !pending.revival && !pending.mentat && !pending.nexusEvent &&
(!pending.movement || (g.pendingChoamMove && !g.pendingChoamMove.source &&
!g.pendingChoamMove.ornithopterEvent && !g.pendingChoamMove.ornithopterRange &&
!g.pendingChoamMove.discoveryFlight))) {
const movement = pending.movement ? g.pendingChoamMove : null;
if (movement) g.pendingChoamMove = null;
const continuation: ChoamBalisetDiscardContinuation = {
kind: 'choamBalisetDiscard', player: choam.id,
card: used.id, target: pending.target!,
territory: pending.location!, stateSignature: '',
...(movement ? { stoppedMovement: true as const } : {}),
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
@@ -24021,6 +24036,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
stageTreacheryDiscard(g, 'choam:baliset', entries, continuation);
return;
}
if (movement) g.pendingChoamMove = movement;
}
completeChoamBaliset(g, pending.target!, pending.location!);
if (pending.movement) {
@@ -24055,11 +24071,15 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
pending.elite!, pending.noFieldEvent);
} else {
if (g.semutaPreview && used.name === 'La La La' &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
!pending.storm && !pending.movement && !pending.mentat && !pending.nexusEvent &&
(!pending.revival || (g.pendingRevival?.kind === 'forces' &&
g.pendingRevival.player === pending.target))) {
const revival = pending.revival ? g.pendingRevival : null;
if (revival) g.pendingRevival = null;
const continuation: ChoamLaLaLaDiscardContinuation = {
kind: 'choamLaLaLaDiscard', player: choam.id,
card: used.id, target: pending.target!, stateSignature: '',
...(revival ? { stoppedRevival: true as const } : {}),
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
@@ -24067,6 +24087,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
stageTreacheryDiscard(g, 'choam:laLaLa', entries, continuation);
return;
}
if (revival) g.pendingRevival = revival;
}
completeChoamLaLaLa(g, pending.target!);
if (pending.revival) {
+3 -2
View File
@@ -1169,13 +1169,14 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'portableSnooper'
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
: definition.card.effect === 'semutaDrug'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready Residual Poison battles, nested responses, pending rewards and public starts remain unfinished.'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, elite or concealed Trip returns, inspected/partly ready Residual Poison battles, nested responses, other pending rewards and public starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
...(definition.card.effect === 'semutaDrug' ? [
'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.',
'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.',
'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.',
'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.',
'Reactive Baliset and La La La also offer their used card after the accepted cost. The original uncommitted move or free-revival request is already stopped: claim or Continue installs the original restriction without moving forces, consuming movement, reviving or charging the denied request. Another legal move or paid revival remains available. Movement-card, Ambassador, Nexus and other competing parents remain separate.',
'The same local semuta entry also admits Basic native Richese and Moritani with classic opponents, exactly CHOAM/Ecaz decks and no optional modules. A clean shipment/movement Sabotage or Robbery overflow offers its private fresh discard without showing the face before commitment. Claim or Continue preserves the original victim/draw, removed token, shipment payment and forces; Sabotage may then offer its original optional gift. Advanced Moritani, native Ecaz, Ix/E3 mixtures and worm/Ambassador/overlap arrivals remain outside this bounded entry. The table visibly labels the development preview.',
] : []),
],
@@ -1186,7 +1187,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and other pending rewards remain unfinished.',
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished.',
},
{
area: 'Player controls' as const,
+62 -2
View File
@@ -8,6 +8,7 @@ import { spiceDeck } from '../game/cards';
import { saphoAggressorGame, aggressorAction, prepareAggressorPlans } from './fixture-sapho-aggressor';
import { takeSaphoBattleCard } from './fixture-sapho-battle-order';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { newRevivalRules } from '../game/revival';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
@@ -24,11 +25,11 @@ function inventory(g: Game) {
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' | 'moritani' = 'emperor') {
function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' | 'moritani' | 'choam' = 'emperor') {
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced,
opponent === 'moritani' ? ['choam', 'ecaz'] : ix ? ['choam', 'ix'] : ['choam']);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : opponent === 'harkonnen' ? 'Harkonnen' : opponent === 'moritani' ? 'Moritani' : 'Emperor',
joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : opponent === 'harkonnen' ? 'Harkonnen' : opponent === 'moritani' ? 'Moritani' : opponent === 'choam' ? 'CHOAM' : 'Emperor',
ixianAlly ? 'ixians' : opponent));
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
g = initializeSemutaGameForAudit(g);
@@ -52,6 +53,65 @@ function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emp
return { g, semuta, hajr };
}
for (const effect of ['Baliset', 'La La La'] as const) void test(
`Semuta preserves reactive ${effect} denial before native resumption`, () => {
const { g, semuta } = fixture(false, false, false, 'choam');
const card = take(g, g.deck.find(c => c.name === effect)!.id);
player(g, 'e').hand.push(card);
for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0, shipped: true, moved: 0 });
const original = inventory(g);
let requested: Game;
if (effect === 'Baliset') {
Object.assign(player(g, 'a'), { forces: { 'imperial_basin:10': 2 }, reserves: 18 });
Object.assign(player(g, 'e'), { forces: { 'carthag:11': 2 }, reserves: 18 });
requested = applyAction(g, 'a', { type: 'move', forces: { 'imperial_basin:10': 1 },
territory: 'carthag', sector: 11 });
assert.equal(requested.decision?.kind, 'choamMovement');
} else {
Object.assign(g, { phase: 4, active: null, movementRemaining: [], revivalRules: newRevivalRules() });
Object.assign(player(g, 'a'), { tanks: 3, reserves: 17 });
requested = applyAction(g, 'a', { type: 'revive', amount: 2 });
assert.equal(requested.decision?.kind, 'choamFreeRevival');
}
let pending = applyAction(requested, 'e', { type: 'card', mode: 'choam', card: card.id });
while (pending.response) {
const actor = pending.players.find(p => !pending.response!.passed.includes(p.id))!;
pending = applyAction(pending, actor.id, { type: 'passResponse' });
}
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = pending.pendingTreacheryDiscard!.batch.event;
const resources = (game: Game) => game.players.map(p => ({
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
tanks: p.tanks, moved: p.moved, revived: p.revived, free: p.freeForcesRevived,
}));
let declined = reload(pending);
for (const id of ['a', 'e', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
for (const resumed of [declined, claimed]) {
assert.deepEqual(resources(resumed), resources(requested));
if (effect === 'Baliset') assert.equal(resumed.pendingChoamMove, null);
else assert.equal(resumed.pendingRevival, null);
assert.deepEqual(inventory(resumed), original);
assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event }));
if (effect === 'Baliset') {
assert.throws(() => applyAction(resumed, 'a', { type: 'move',
forces: { 'imperial_basin:10': 1 }, territory: 'carthag', sector: 11 }));
const moved = applyAction(resumed, 'a', { type: 'move',
forces: { 'imperial_basin:10': 1 }, territory: 'arrakeen', sector: 10 });
assert.equal(player(moved, 'a').moved, 1);
assert.deepEqual(player(moved, 'a').forces, { 'imperial_basin:10': 1, 'arrakeen:10': 1 });
} else {
const revived = applyAction(resumed, 'a', { type: 'revive', amount: 2 });
assert.equal(player(revived, 'a').spice, player(requested, 'a').spice - 4);
assert.equal(player(revived, 'a').tanks, 1);
assert.equal(player(revived, 'a').reserves, 19);
assert.equal(player(revived, 'a').freeForcesRevived, player(requested, 'a').freeForcesRevived);
}
}
assert.equal(player(claimed, 'r').hand.some(c => c.id === card.id), true);
assert.equal(claimed.discard.some(c => c.id === semuta.id), true);
});
for (const kind of ['sabotage', 'robbery'] as const) void test(
`Semuta takes the private ${kind} discard before the original native arrival suffix`, () => {
const { g, semuta } = fixture(false, false, false, 'moritani');