diff --git a/components/game-table.tsx b/components/game-table.tsx
index 2bcaefeb..33feb207 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1406,10 +1406,12 @@ export function GameTable({
{g.players.find((player) => player.id === g.arrivalOverlap!.entrant)?.name ?? 'The entrant'}
{' '}entered {territory(g.arrivalOverlap.territory).name}. Ecaz Ambassador
- and Moritani Terror resolve in storm order:{' '}
+ and Moritani Terror resolve in this table’s provisional storm order:{' '}
{g.arrivalOverlap.first === 'ambassador' ? 'Ecaz, then Moritani' : 'Moritani, then Ecaz'}.
- {' '}{g.arrivalOverlap.active === 'ambassador' ? 'Ecaz' : 'Moritani'} decides now;
- the original entry is not repeated.
+ {g.semutaReaction
+ ? ' The current fresh-discard response must finish before the next arrival decision.'
+ : <> {g.arrivalOverlap.active === 'ambassador' ? 'Ecaz' : 'Moritani'} decides now.>}
+ {' '}The original entry is not repeated.
)}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 2fd62ec5..ea0b3768 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,28 @@ open mode gates.
## Current checkpoint and work
+**Basic shared Ambassador/Terror arrival — 24 October. Prototyped / Partial.**
+The existing bare Basic Richese profile now admits a paid shared native entry.
+Both originally eligible owners keep the selected provisional storm order
+while their separate CHOAM/Ix or Sabotage/Robbery fresh groups offer Semuta.
+Source-qualified child matching preserves the original entry, already-paid
+credit, one draw/gift, full-hand exchange and the other owner's opportunity.
+The table banner explains the shared discard response instead of attributing
+that choice to the paused native owner.
+
+Disjoint real-source/control lanes, bounded continuation review and owner
+integration pass **12 files144/144**, types/lint/build, eight actual gameplay/
+control/GameTable SSR journeys and eight existing-profile continuations
+(69 accepted proposals). Real five-seat setup, lobby circles, paid cache and
+placements supply139–142 source actions. Prior Semuta transfer/overflow hands
+are explicitly conserved staging. Feedback fixes an unopened optional-field
+assertion, two lint declarations and a stale Truthtrance test expecting the
+previously removed native Ornithopter discard exclusion.
+[Source, proof and limits](SEMUTA_SOURCE_UPDATE_20260907.md#basic-shared-ambassador-and-terror-arrival--24-october-2026).
+Native Advanced/optional profiles, unsupported child effects, full combinations,
+complete modes, comprehensive/browser/deployed acceptance and full AI remain
+unfinished. No saved-room/D1/reset/restart/deployment operation.
+
**Native Basic Ornithopter recovery and arrival — 24 October. Prototyped / Partial.**
Completed range-three/final-two-group flights now offer Semuta in the existing
bare Basic E3 profile. Retire that first fresh group before the original native
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index c9216a2a..0348d1ac 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,33 @@
# Dune implementation status
+## 24 October 2026 — Basic shared Ambassador and Terror arrival
+
+Admit the original ordered native entry in the existing bare Basic Richese
+profile, with no new serialized receipt or timing ruling. CHOAM/Ix Ambassador
+and Sabotage/Robbery fresh groups can suspend their own active parent stage
+for Semuta while the other originally eligible owner remains queued. Bind the
+child to original signature/order, owner/token/event, cause, sector and turn;
+Terror also retains its entering amount and elite count. Keep the original
+credit/draw/gift and physical entry exactly once. The banner names the shared
+discard response and labels the inherited storm-order interpretation provisional.
+
+Disjoint source/control lanes and bounded review pass12 files144/144,
+types/lint/build, eight gameplay/control/GameTable SSR journeys and eight
+existing-profile continuations accepting69 proposals. Real five-seat setup,
+lobby circles and paid cache/placements supply139–142 source actions; prior
+Semuta transfer and overflow hands remain explicit conserved staging. Both
+owner orders, shipment/movement, selected BG copy, full-hand claim and empty
+CHOAM grants have focused evidence. A first actual entry reproduces preflight
+rejection; parent-only admission then reproduces the missing Semuta pause.
+Feedback repairs a null-vs-unopened optional-field assertion, two const lint
+issues and an older Truthtrance test's obsolete native Orni exclusion.
+
+Private final proof:20261024-semuta-arrival-overlap/frozen-integrated-3.
+The canonical [source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#basic-shared-ambassador-and-terror-arrival--24-october-2026)
+retain Advanced/module/nested-effect limits and unfinished complete-mode,
+browser/deployed and comprehensive assurance. No game/database/reset/restart/
+deployment operation or AI strategy/calibration.
+
## 24 October 2026 — Native Basic Ornithopter recovery and arrival
Admit the completed movement-card fresh group in the existing bare Basic E3
diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md
index a3003e33..0c19ad69 100644
--- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md
+++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md
@@ -2,6 +2,68 @@
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
+## Basic shared Ambassador and Terror arrival — 24 October 2026
+
+**Prototyped / Partial.** The existing bare Basic Richese profile now admits
+an ordinary paid entry that originally qualifies for both native markers.
+Keep the [selected provisional owner order](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026):
+this is composition of the existing interpretation, not a new publisher ruling.
+Both optional opportunities belong to the same committed shipment or movement.
+
+CHOAM/Ix Ambassador and Sabotage/Robbery overflow fresh groups can each pause
+for Semuta while the other owner's earned opportunity remains queued. Bind the
+suspended child to the signed original parent, current owner order/stage,
+owner/token/event, entry cause, territory/sector, turn/phase and ordinary return;
+Terror also keeps the original entering amount and elite count. No new saved
+field or receipt/signature format is introduced. CHOAM credit and the Robbery
+draw already committed before their discard remain paid/drawn once. Ix draws
+one replacement afterward; Sabotage finishes its original gift choice before
+the second owner. Neither path replays the physical entry or its payment.
+One Semuta cannot be spent on both fresh groups. Empty CHOAM creates no empty
+event, and the existing full-hand exchange remains available.
+
+The table banner labels storm order provisional and says the shared fresh
+discard finishes before the next native decision. Actual Continue/Commit/Select
+controls remain persistent, event-bound and disabled while busy. Minimal bots
+use their own entitled projected choices; no strategy refinement occurs.
+
+The source uses genuine five-seat Basic Ecaz/CHOAM/Richese/Guild/Moritani setup
+with35 main cards and10 cache cards, original unopened card/Spice positions
+and nine selected original Ambassador packet draws. Actual lobby circles set
+either owner order. Guild buys Semuta for1, Ecaz buys an ordinary card for1 and
+pays its actual turn-one placement, and the original Mentat opportunity places
+Terror at the same Sietch. Legal later1+1 Storm dials reach turn2; the caller
+makes the actual destination shipment or move after a paid Ridge Flat shipment.
+These139–142 source actions exclude the inherited constructor's unused entry
+branch. A prior transfer of the paid Semuta to Richese, Robbery's four-card
+hand and optional claimant full hand are explicitly conserved acquisitions;
+no clock, wallet, force, token or pending receipt is installed.
+
+The first source reproduces the original arrival-preflight rejection. With
+parent admission alone, its CHOAM discard skips Semuta and opens Terror; the
+source-qualified child guards repair that second failure. Final **12-file144/144**,
+`npm run check:quick` and production build pass. Eight actual gameplay/control/
+GameTable SSR journeys cover both orders, CHOAM/Ix, Sabotage/Robbery, selected
+BG copy/full hand, separate events and actual movement end. Eight existing
+profiles continue the original suspended groups with69 accepted proposals.
+A null-vs-unopened optional-field test, two const declarations and an older
+Truthtrance test's obsolete Orni exclusion were repaired during feedback.
+
+Private proof: `/home/quintine/.local/share/dune/checkpoints/20261024-semuta-arrival-overlap/`,
+`frozen-integrated-3/report.json`, `runtime-smoke.json`, HTML/logs and
+`verified-files.json`. Before final prose: base7b9938219d7f3e0e3b9f82ae752ad368b26fe901,
+source treeba53b110f9b3ca6b0d6169f23f0167a24bcdc90dabe73484bd14ad6d77f1a7ff,
+2673 tracked/nonignored files unchanged throughout verification. Four final
+prose/reference overlays retain code/test hashes; delivery verifies local links,
+evidence paths, reference syntax/keys, explicit staging and remote push.
+
+Native Advanced E3, optional combined profiles, unsupported child effects and
+wider originating-card/shared-entry chains remain unfinished. The unchanged
+prior module/entry guards do not certify complete modes. Framework aliases/CSS
+stubs mean SSR is not browser/visual/deployed acceptance. Comprehensive rules,
+saved-game/privacy/custody assurance and full AI/calibration remain due. No saved
+room, D1, reset, restart or deployment operation occurred.
+
## Native Ornithopter movement and arrival — 24 October 2026
**Prototyped / Partial.** Fresh bare Basic Richese tables with selected native
@@ -27,10 +89,10 @@ Semuta on Ornithopter cannot spend it again on that later group. Passing the
first group can preserve it for an eligible later card.
This composes existing printed movement/discard mechanics and the current
-Semuta protocol, not a new publisher timing ruling. Ambassador/Terror overlap
-keeps its existing preflight/order: Ambassador discard admission remains guarded
-while an overlap is active. Other nested arrivals, native Advanced E3,
-optional combinations, complete modes and final acceptance remain unfinished.
+Semuta protocol, not a new publisher timing ruling. Ordinary shared-entry
+recovery is prototyped above; a full originating-card/shared-entry chain has
+no separate three-group runtime proof here. Other nested arrivals, native
+Advanced E3, optional combinations, complete modes and final acceptance remain unfinished.
### Native Ornithopter source and proof
diff --git a/game/arrival-overlap.ts b/game/arrival-overlap.ts
index 5e50c4d0..15f1fd07 100644
--- a/game/arrival-overlap.ts
+++ b/game/arrival-overlap.ts
@@ -2,6 +2,8 @@ import type { Game } from './engine';
type Overlap = NonNullable;
type TerrorEntry = NonNullable;
+type AmbassadorEntry = NonNullable;
+type DiscardContext = Pick;
/** The immutable trigger, not later owner choices or child stages. */
export function arrivalOverlapSignature(record: Overlap): string {
@@ -11,6 +13,42 @@ export function arrivalOverlapSignature(record: Overlap): string {
record.ambassadorToken, record.terrorTokens]);
}
+/** A fresh discard belongs to the currently active owner of one ordinary entry.
+ * The parent keeps the other owner's opportunity while the child is suspended. */
+function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Overlap | null {
+ const overlap = g.pendingArrivalOverlap;
+ if (!overlap || overlap.signature !== arrivalOverlapSignature(overlap) ||
+ overlap.turn !== g.turn || overlap.phase !== g.phase || g.phase !== 5 ||
+ overlap.resume !== 'none' || !['shipment', 'movement'].includes(overlap.cause) ||
+ !['first', 'second'].includes(overlap.stage)) return null;
+ const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
+ if (ecaz < 0 || moritani < 0 || ecaz === moritani ||
+ overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror') ||
+ !g.players.some(p => p.id === overlap.ecaz && p.faction === 'ecaz') ||
+ !g.players.some(p => p.id === overlap.moritani && p.faction === 'moritani')) return null;
+ const active = overlap.stage === 'first' ? overlap.first :
+ overlap.first === 'ambassador' ? 'terror' : 'ambassador';
+ return active === owner ? overlap : null;
+}
+
+export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean {
+ const overlap = ordinaryDiscardOverlap(g, 'ambassador');
+ return !!overlap && entry.stage === 'cards' && entry.resume === 'none' &&
+ entry.event === overlap.ambassadorEvent && entry.owner === overlap.ecaz &&
+ entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
+ entry.territory === overlap.territory && entry.sector === overlap.sector &&
+ entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase;
+}
+
+export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry, owner: string): boolean {
+ const overlap = ordinaryDiscardOverlap(g, 'terror');
+ return !!overlap && owner === overlap.moritani && entry.resume === 'none' &&
+ overlap.terrorTokens.includes(entry.token) && entry.entrant === overlap.entrant &&
+ entry.territory === overlap.territory && entry.sector === overlap.sector &&
+ entry.amount === overlap.amount && entry.elite === overlap.elite &&
+ entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase;
+}
+
/** The overlap owns the worm return while either serialized Terror prompt is live. */
export function overlapOwnsWormTerror(
g: Pick,
diff --git a/game/engine.ts b/game/engine.ts
index 4d9ef0bb..876a13c9 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -142,7 +142,7 @@ import {
} from './homeworld-revival-return';
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
-import { arrivalOverlapSignature, overlapOwnsWormTerror } from './arrival-overlap';
+import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap';
import {
homeworldLowBonus,
homeworldRevivalKaramaBlock,
@@ -7551,7 +7551,7 @@ function treacheryDiscardIntegrity(g: Game) {
}
/** Bounded public single-card reaction after completed effects. Battle cleanup
* preserves the original winner queue and bounded native retention/rewards without replaying them.
- * Final flight arrival may resume native Intrusion, but other entry responses remain gated. */
+ * Basic ordinary entry discards retain their ordered Ambassador/Terror parent. */
function semutaOfferSupported(
g: Game,
continuation: NonNullable['continuation'],
@@ -7569,7 +7569,8 @@ function semutaOfferSupported(
continuation.entry.victoryEvent || continuation.entry.relocation ||
continuation.entry.shipmentReceipt || continuation.entry.purchaseReceipt ||
continuation.entry.revivalReceipt || continuation.entry.nexusPurchase ||
- g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
+ (g.pendingArrivalOverlap && !overlapOwnsAmbassadorDiscard(g, continuation.entry)) ||
+ g.pendingArrivalReaction?.length ||
g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
g.pendingRevival)) return false;
@@ -7683,7 +7684,9 @@ function semutaOfferSupported(
if (continuation.kind === 'terrorDiscard' &&
(continuation.entry.resume !== 'none' ||
(continuation.entry.cause !== 'shipment' && continuation.entry.cause !== 'movement') ||
- g.pendingArrivalOverlap || g.pendingChoamMove || g.pendingIxMove ||
+ (g.pendingArrivalOverlap && (!bareBasicSemutaProfile(g) ||
+ !overlapOwnsTerrorDiscard(g, continuation.entry, continuation.owner))) ||
+ g.pendingChoamMove || g.pendingIxMove ||
g.pendingFremenMove || g.ornithopter || g.summonedWorm ||
g.wormRides.length > 0)) return false;
if ((continuation.kind === 'distransDiscard' ||
@@ -17242,6 +17245,7 @@ function ambassadorEffectBlock(
return null;
}
function arrivalOverlapModeSupported(g: Game): boolean {
+ if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) &&
byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
diff --git a/game/reference.ts b/game/reference.ts
index fc383608..9619576a 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1302,11 +1302,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'Original native Homeworld battle-reward cancellation costs also offer printed Karama: Advanced Harkonnen capture, native CHOAM Auditor and quoted force-payment income. Recovery preserves the accepted battle, paid support, leader death and original casualty/cleanup order, then completes the same denial. It cannot sample or transfer a captive, pay/inspect an audit, refund support or credit canceled income. An audit cancellation leaves any earlier allowed force-payment income intact. Existing exact roster, private-custody and source guards remain.',
'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 Ghola can also be claimed while its native Tleilaxu one-spice income is waiting. The piece has already returned. Claim or Continue restores that same allow-or-cancel response without another revival or advance payment; normal revival allowances stay unchanged. This includes Tleilaxu using its own Ghola. Other pending effects retain their existing timing.',
- 'A finished Ornithopter in a fresh Basic Ecaz/Moritani table with Richese offers its own fresh discard before the original arrival. Claim or Continue preserves the moved group and movement counters. A clean Ambassador or Terror effect then has its own later discard event, with its original credit, replacement draw or gift. The single Semuta cannot be spent on both groups; overlapping entries keep their separate timing limits.',
+ 'A finished Ornithopter in a fresh Basic Ecaz/Moritani table with Richese offers its own fresh discard before the original arrival. Claim or Continue preserves the moved group and movement counters. A clean Ambassador or Terror effect then has its own later discard event, with its original credit, replacement draw or gift. The single Semuta cannot be spent on both groups. Ordinary shared Ambassador/Terror entry also preserves its original provisional owner order through each fresh discard.',
'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.',
'A clean proactive Trip to Gamont also pauses for a selected elite or concealed No-Field. Claim or Continue returns the exact selected physical counter, or performs the native No-Field reveal and returns at most one actual force after reserve-capped materialization. A zero token reveals without inventing a force; taking the used card does not undo that effect or spend revival usage.',
+ 'In supported bare Basic tables with Richese, one entry may earn both an Ambassador and Terror opportunity. Their existing provisional storm order stays fixed while CHOAM/Ix or Sabotage/Robbery discards offer their separate Semuta responses. Complete each fresh group before its original effect continues; the other earned opportunity waits. The table shows when the discard response is pending. Credit, replacement, gift and physical entry are never repeated.',
'Trip to Gamont in CHOAM’s final Mentat opportunity also offers the used card. Claim or Continue completes the selected return and restores that same CHOAM-owned opportunity. It does not check victory or advance the turn: CHOAM must still choose Finish and check victory, using the changed board.',
'Fresh bare public Basic Richese previews now initialize Semuta before genuine setup with selected native E1/E2/E3 families. Older games are unchanged; Advanced and optional modules retain their existing separate entries. Clean ordinary shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: CHOAM keeps its already-earned credit and Ixians draw one replacement after the reaction. A multiple-card private group requires commitment before exactly one selection. Original entrant actions then resume once. Existing Basic Sabotage/Robbery overflow keeps the original victim/draw, removed token, payment, forces and optional gift. Nested arrivals, completed movement-card provenance, wider module coverage and complete modes remain unfinished. The table labels the preview and its limits.',
] : []),
@@ -1318,19 +1319,19 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
- "Completed native Basic Ornithopter now offers its fresh group before the original movement-caused arrival. Clean Ambassador/Terror discards then use distinct events and original credit/draw/gift; one Semuta cannot pay twice. Overlap and other profile guards remain. Completed Ghola now offers Semuta before its exact conditional native one-spice income, including self-income. Claim/pass preserves returned pieces and normal quotas, then restores the original allow/cancel response once. Other parent guards remain. 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. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund. Fresh private Discovery composition now connects both physical Shrine-role costs without changing original effects or printed identity. Fresh bare public Basic starts now initialize Semuta with selected native families. Clean CHOAM/Ixian Ambassador private batches, including BG copies, retain the original entry, already-earned credit and one later replacement draw. Older games and optional/Advanced scopes remain separate; nested originating-card coverage remains unfinished.",
+ "Basic shared Ambassador/Terror entry now retains both original opportunities through their own source-qualified Semuta groups in the existing provisional storm order. Original credit/draw/gift and physical entry remain once only. Completed native Basic Ornithopter now offers its fresh group before the original movement-caused arrival. Clean Ambassador/Terror discards then use distinct events and original credit/draw/gift; one Semuta cannot pay twice. Other profile guards remain. Completed Ghola now offers Semuta before its exact conditional native one-spice income, including self-income. Claim/pass preserves returned pieces and normal quotas, then restores the original allow/cancel response once. Other parent guards remain. 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. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain. Native BG physical Worthless cost now precedes the single original conversion emitter; the separately printed cancellation cost retains that pending use and still denies it. All four use bindings, incoming reservations and older passes remain. Allowed converted prices carry claimed-cost provenance through actual later rediscard/refund. Fresh private Discovery composition now connects both physical Shrine-role costs without changing original effects or printed identity. Fresh bare public Basic starts now initialize Semuta with selected native families. Clean CHOAM/Ixian Ambassador private batches, including BG copies, retain the original entry, already-earned credit and one later replacement draw. Older games and optional/Advanced scopes remain separate; nested originating-card coverage remains unfinished.",
},
{
area: 'Player controls' as const,
status: 'Partial' as const,
detail:
- "All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Fresh bare Basic Richese previews now enable the bounded reactions with selected native families. Actual persistent Ambassador reaction controls retain Continue, commitment and private multi-selection; optional/Advanced starts keep their existing scope. Earlier four original-action390px native cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay. Earlier four original-action390px price journeys start at actual activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without replay. Earlier four390px special-cost journeys cover original reserve/typed Guild prevention claim/decline and controlled CHOAM private Kulon selection plus Emperor post-revival cost claim. Two original/two controlled; actual native movement/public force inspectors, no overflow/browser/application errors. Four new original-action390px journeys start from actual Baliset controls: bank3 after native allowance, printed counter recovery with denial intact, original full4 Guild repricing and unchanged free Chaumurky acquisition. Native advisors/next-lot inspection finish, with no overflow/browser/application errors. Two controlled390px Shrine-role recovery journeys retain the bank3 three-force delivery and one definite answer.",
+ "The shared-arrival banner identifies a pending discard response while the native owner waits. All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Fresh bare Basic Richese previews now enable the bounded reactions with selected native families. Actual persistent Ambassador reaction controls retain Continue, commitment and private multi-selection; optional/Advanced starts keep their existing scope. Earlier four original-action390px native cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay. Earlier four original-action390px price journeys start at actual activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without replay. Earlier four390px special-cost journeys cover original reserve/typed Guild prevention claim/decline and controlled CHOAM private Kulon selection plus Emperor post-revival cost claim. Two original/two controlled; actual native movement/public force inspectors, no overflow/browser/application errors. Four new original-action390px journeys start from actual Baliset controls: bank3 after native allowance, printed counter recovery with denial intact, original full4 Guild repricing and unchanged free Chaumurky acquisition. Native advisors/next-lot inspection finish, with no overflow/browser/application errors. Two controlled390px Shrine-role recovery journeys retain the bank3 three-force delivery and one definite answer.",
},
{
area: 'AI' as const,
status: 'Partial' as const,
detail:
- "Eight native Ornithopter/Ambassador-or-Terror later-discard continuations accept28 legal proposals; no new strategy or calibration. Earlier conditional Ghola-income continuations accept32 legal proposals across normal/full hands and all four existing profiles; all recover the card and allow the real income. No strategy tuning. All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration. Thirty-two controlled legal-policy probes cover both physical Shrine-role events in both modes; no calibration. Four existing profiles also complete actual public Basic BG-copy CHOAM Ambassador reactions through twenty legal proposals; no new strategy or calibration.",
+ "Eight shared-arrival suspended-group continuations accept69 legal proposals across both owner orders. Eight native Ornithopter/Ambassador-or-Terror later-discard continuations accept28 legal proposals; no new strategy or calibration. Earlier conditional Ghola-income continuations accept32 legal proposals across normal/full hands and all four existing profiles; all recover the card and allow the real income. No strategy tuning. All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration. For each native BG cost and printed counter-cost family, all four existing profiles recover only the current fresh card through their private legal action. Native response allowance/denial remains separate; subsequent owner actions exercise the real retained source. No tuning or calibration. Thirty-two controlled legal-policy probes cover both physical Shrine-role events in both modes; no calibration. Four existing profiles also complete actual public Basic BG-copy CHOAM Ambassador reactions through twenty legal proposals; no new strategy or calibration.",
},
{
area: 'Documentation' as const,
@@ -1342,7 +1343,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification' as const,
status: 'Partial' as const,
detail:
- "Current native-Ornithopter proof passes10 files135/135, types/lint/build and sixteen gameplay/GameTable SSR programmes. Eight existing-profile paths accept28 proposals. Real paid source prefixes and explicitly conserved prior-card acquisitions cover both modes, separate fresh groups, full hands and once-only native continuation. Source setup/dial/inventory and fixture type feedback is repaired before final frozen proof. Overlap, native Advanced E3, optional profiles and full-mode/browser/deployed/comprehensive gates remain open. Earlier conditional-Ghola proof passes10 files156/156, types/lint/build and eight actual-control/GameTable SSR journeys. Eight existing profiles accept32 proposals. Native160-action source plus explicitly conserved card staging covers claim/pass, full hands and allow/cancel; separate controlled self-income/leader cases retain physical identity and normal quotas. A private runner eligibility repair retains earlier passing checks by unchanged source identity. Full modes, wider parents, browser/deployed and comprehensive assurance remain open. The following counts are historical family evidence. Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Earlier sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim. Earlier nineteen-file238/238 passes with8 new cases. Twenty-six original-action runtime/policy paths and four actual original-action390px journeys pass. One controlled acquisition/phase probe uses a REAL subsequent CHOAM rediscard and Guild stop without force/wallet staging or duplicate refund. Separate2 controlled Kull parent probes and3 repaired focused cases pass. No worker/D1/saved/deployed certification. Eight new Shrine cases pass in the57-case affected union, with eight controlled runtime paths and two actual controlled390px journeys; no natural draw/entry, recovery or deployed acceptance. Fresh Basic/Ambassador evidence passes twelve-file142/142, types/lint/build, ten original-action handler/table-rendering programmes and four existing profiles with twenty legal proposals. Public setup, actual one-spice Semuta auction, card purchase, placement/shipment and two actual ordinary movement entries retain35 main plus10 cache. Only unopened positions and original scalar/packet lotteries are selected. Full-hand/own-card edges separately stage conserved card transfers. Stale battle acknowledgements/module-rejection expectations and fixture types were repaired; representative old failures reproduced against the previous engine. Framework aliases/CSS stub; no browser, visual, saved-room or deployed acceptance. Wider nested/provenance scope and complete modes remain open.",
+ "Current shared-arrival proof passes12 files144/144, types/lint/build, eight gameplay/control/GameTable SSR journeys and eight existing-profile continuations accepting69 proposals. Real139-142-action sources, separate discard events, full-hand/copy/empty-grant edges and parent binding have focused proof. Earlier native-Ornithopter proof passes10 files135/135, types/lint/build and sixteen gameplay/GameTable SSR programmes. Eight existing-profile paths accept28 proposals. Real paid source prefixes and explicitly conserved prior-card acquisitions cover both modes, separate fresh groups, full hands and once-only native continuation. Source setup/dial/inventory and fixture type feedback is repaired before final frozen proof. Native Advanced E3, optional profiles and full-mode/browser/deployed/comprehensive gates remain open. Earlier conditional-Ghola proof passes10 files156/156, types/lint/build and eight actual-control/GameTable SSR journeys. Eight existing profiles accept32 proposals. Native160-action source plus explicitly conserved card staging covers claim/pass, full hands and allow/cancel; separate controlled self-income/leader cases retain physical identity and normal quotas. A private runner eligibility repair retains earlier passing checks by unchanged source identity. Full modes, wider parents, browser/deployed and comprehensive assurance remain open. The following counts are historical family evidence. Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Earlier sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim. Earlier nineteen-file238/238 passes with8 new cases. Twenty-six original-action runtime/policy paths and four actual original-action390px journeys pass. One controlled acquisition/phase probe uses a REAL subsequent CHOAM rediscard and Guild stop without force/wallet staging or duplicate refund. Separate2 controlled Kull parent probes and3 repaired focused cases pass. No worker/D1/saved/deployed certification. Eight new Shrine cases pass in the57-case affected union, with eight controlled runtime paths and two actual controlled390px journeys; no natural draw/entry, recovery or deployed acceptance. Fresh Basic/Ambassador evidence passes twelve-file142/142, types/lint/build, ten original-action handler/table-rendering programmes and four existing profiles with twenty legal proposals. Public setup, actual one-spice Semuta auction, card purchase, placement/shipment and two actual ordinary movement entries retain35 main plus10 cache. Only unopened positions and original scalar/packet lotteries are selected. Full-hand/own-card edges separately stage conserved card transfers. Stale battle acknowledgements/module-rejection expectations and fixture types were repaired; representative old failures reproduced against the previous engine. Framework aliases/CSS stub; no browser, visual, saved-room or deployed acceptance. Wider nested/provenance scope and complete modes remain open.",
evidence: [
'tests/basic-semuta-ambassador.test.ts',
'tests/fixture-basic-semuta-ambassador.ts',
@@ -1350,6 +1351,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-recovery.test.ts',
'tests/bot-semuta.test.ts',
'tests/semuta-reaction-controls.test.tsx',
+ 'game/arrival-overlap.ts',
+ 'tests/fixture-semuta-arrival-overlap.ts',
+ 'tests/semuta-arrival-overlap.test.ts',
+ 'tests/semuta-arrival-overlap-ui.test.tsx',
'tests/fixture-semuta-ornithopter-ambassador.ts',
'tests/fixture-semuta-ornithopter-terror.ts',
'tests/semuta-ornithopter-ambassador.test.ts',
diff --git a/tests/fixture-basic-semuta-ambassador.ts b/tests/fixture-basic-semuta-ambassador.ts
index c493c14c..a61231d4 100644
--- a/tests/fixture-basic-semuta-ambassador.ts
+++ b/tests/fixture-basic-semuta-ambassador.ts
@@ -59,6 +59,8 @@ export function nextBasicSemutaAmbassadorStep(game: Game): BasicSemutaAmbassador
}
if (!game.response && !game.phaseOpening) {
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'richeseCache') return null;
+ if (game.decision?.kind === 'moritaniSetup' && game.status === 'setup' && !game.setupStage)
+ return { actor: game.decision.player, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.status === 'setup' && game.setupStage === 'forces' &&
@@ -108,6 +110,7 @@ function originalPacket(effect: BasicSemutaAmbassadorEffect, copied: boolean) {
export function createBasicSemutaAmbassadorFixture(options: {
effect: BasicSemutaAmbassadorEffect; copied?: boolean; arrival?: 'shipment' | 'movement';
cacheCard?: 'semuta' | 'ornithopter'; ecazTruth?: boolean;
+ overlapOrder?: 'ambassador' | 'terror';
}) {
const effect = options.effect, copied = options.copied ?? false, packet = originalPacket(effect, copied);
const arrivalKind = options.arrival ?? 'shipment';
@@ -115,6 +118,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
let game = createGame('BASICSEMUTAAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
for (const faction of ['choam', 'richese', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
+ if (options.overlapOrder) joinGame(game, newPlayer('moritani', 'Moritani', 'moritani'));
const owner = 'ecaz', beneficiary = owner, holder = 'guild', entrant = holder;
const actions: BasicSemutaAmbassadorStep[] = [];
const act = (actor: string, action: Action) => { game = actBasicSemutaAmbassador(game, actor, action, actions); };
@@ -123,6 +127,13 @@ export function createBasicSemutaAmbassadorFixture(options: {
const before = structuredClone(game); act(step.actor, step.action); reach(until);
return { before, step: structuredClone(step), after: structuredClone(game) };
};
+ if (options.overlapOrder) {
+ act('guild', { type: 'seatPosition', position: 6 });
+ if (options.overlapOrder === 'terror') act('moritani', { type: 'seatPosition', position: 4 });
+ act('ecaz', { type: 'seatPosition', position: options.overlapOrder === 'ambassador' ? 4 : 5 });
+ act('choam', { type: 'seatPosition', position: 1 });
+ act('richese', { type: 'seatPosition', position: 2 });
+ }
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start' });
@@ -137,10 +148,11 @@ export function createBasicSemutaAmbassadorFixture(options: {
const originalMainDeck = game.deck.map(card => card.id).sort();
const originalCache = game.richeseCache!.map(card => card.id).sort();
const originalTreachery = basicSemutaAmbassadorInventory(game);
- // Four still-undealt starts plus the first still-unopened normal purchase.
+ // Still-undealt starts plus the first still-unopened normal purchase.
// Ordinary cards avoid optional card-use controls during the source path.
- const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 5);
- assert.equal(ordinary.length, 5);
+ const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
+ .slice(0, game.players.length + 1);
+ assert.equal(ordinary.length, game.players.length + 1);
if (options.ecazTruth) ordinary[0] = game.deck.find(card => card.effect === 'truthtrance')!;
const deal = ordinary.map(card => card.id);
const dealOrder = deal.map((id, position) => {
@@ -204,7 +216,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
const normalPurchase = transition({ actor: owner, action: { type: 'bid', amount: 1, allyPayment: 0 } },
state => basicSemutaAmbassadorPlayer(state, owner).hand.length === 2 && basicSemutaAmbassadorClean(state));
assert.equal(basicSemutaAmbassadorPlayer(game).spice, basicSemutaAmbassadorPlayer(normalPurchase.before).spice - 1);
- assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0], deal[4]]);
+ assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0], deal[game.players.length]]);
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const territory = 'habbanya_ridge_sietch', sector = 17;
const placement = transition({ actor: owner, action: { type: 'decision', token: token.id, territory } },
@@ -259,6 +271,6 @@ export function createBasicSemutaAmbassadorFixture(options: {
preReplacementDeckTop, balances: basicSemutaAmbassadorBalances(game),
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as BasicSemutaAmbassadorStep,
actions, sourceActionCount: actions.length,
- staging: [`Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot. Optional source selection buys ${cacheCardId} instead and deals original Truthtrance to Ecaz=${!!options.ecazTruth}.`] };
+ staging: [`Only ${game.players.length + 1} unopened original main-deck positions (${game.players.length} starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild${options.overlapOrder ? '/Moritani' : ''} setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot. Optional source selection buys ${cacheCardId} instead and deals original Truthtrance to Ecaz=${!!options.ecazTruth}.`] };
}
export type BasicSemutaAmbassadorFixture = ReturnType;
diff --git a/tests/fixture-semuta-arrival-overlap.ts b/tests/fixture-semuta-arrival-overlap.ts
new file mode 100644
index 00000000..cd8ee0da
--- /dev/null
+++ b/tests/fixture-semuta-arrival-overlap.ts
@@ -0,0 +1,190 @@
+import assert from 'node:assert/strict';
+import type { Action, Game } from '../game/engine';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import {
+ actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
+ basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
+ createBasicSemutaAmbassadorFixture, nextBasicSemutaAmbassadorStep,
+ type BasicSemutaAmbassadorEffect, type BasicSemutaAmbassadorStep,
+} from './fixture-basic-semuta-ambassador';
+
+export type SemutaArrivalOverlapOptions = {
+ effect: BasicSemutaAmbassadorEffect;
+ first: 'ambassador' | 'terror';
+ arrival?: 'shipment' | 'movement';
+ terror?: 'sabotage' | 'robbery';
+ copied?: boolean;
+ fullHand?: boolean;
+};
+export const actSemutaArrivalOverlap = actBasicSemutaAmbassador;
+export const player = basicSemutaAmbassadorPlayer;
+export const inventory = basicSemutaAmbassadorInventory;
+
+/** Quiet native choices after the inherited turn-one prefix; token placement,
+ * arrival and both reactions remain explicit caller actions. */
+export function nextSemutaArrivalOverlapStep(game: Game): BasicSemutaAmbassadorStep | null {
+ if (!game.response && !game.phaseOpening && game.decision) {
+ const d = game.decision;
+ if (d.kind === 'moritaniPlacement' || d.kind === 'moritaniTerror') return null;
+ if (d.kind === 'richeseCache') {
+ const card = game.richeseCache!.find(card => card.id !== SEMUTA_DRUG_ID); assert.ok(card);
+ return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event,
+ card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
+ }
+ if (d.kind === 'richeseUnbid') return { actor: d.player,
+ action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
+ }
+ return nextBasicSemutaAmbassadorStep(game);
+}
+export function advanceSemutaArrivalOverlap(game: Game, until: (state: Game) => boolean,
+ actions: BasicSemutaAmbassadorStep[] = []): Game {
+ for (let count = 0; count < 1600; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ const step = nextSemutaArrivalOverlapStep(game);
+ assert.ok(step, `Supply the live native shared-entry choice: ${JSON.stringify({
+ turn: game.turn, phase: game.phase, decision: game.decision, response: game.response?.kind,
+ })}`);
+ game = actSemutaArrivalOverlap(game, step.actor, step.action, actions);
+ }
+ throw Error('The original shared-entry programme exceeded its finite boundary');
+}
+
+/** Five genuine public Basic seats, original setup and cache purchase, real
+ * turn-one marker placements, then a paid turn-two entry. No clock, order,
+ * force, token or reaction receipt is installed. */
+export function createSemutaArrivalOverlapFixture(options: SemutaArrivalOverlapOptions) {
+ const original = createBasicSemutaAmbassadorFixture({ effect: options.effect,
+ copied: options.copied, overlapOrder: options.first });
+ const prefixEnd = original.actions.findIndex(step => step.actor === original.entrant && step.action.type === 'ship');
+ assert.ok(prefixEnd >= 0);
+ const prefixActions = structuredClone(original.actions.slice(0, prefixEnd));
+ const branchActions: BasicSemutaAmbassadorStep[] = [];
+ let game = structuredClone(original.beforeArrival);
+ const originalInventory = inventory(game);
+ const owner = original.owner, entrant = original.entrant, moritani = 'moritani', claimant = 'richese';
+ const effect = options.effect, copied = !!options.copied;
+ const terror = options.terror ?? 'sabotage', first = options.first;
+ const arrival = options.arrival ?? 'shipment';
+ const territory = original.territory, sector = original.sector;
+ const act = (actor: string, action: Action) => {
+ game = actSemutaArrivalOverlap(game, actor, action, branchActions);
+ };
+ const reach = (until: (state: Game) => boolean) => {
+ game = advanceSemutaArrivalOverlap(game, until, branchActions);
+ };
+ // Abandon only the inherited unplayed branch, not an already submitted entry.
+ act(entrant, { type: 'endMovement' });
+ reach(state => state.turn === 1 && state.phase === 8 &&
+ !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
+ const beforeTerrorPlacement = structuredClone(game);
+ const terrorToken = game.moritaniTerror!.tokens.find(token => token.kind === terror && token.status === 'available');
+ assert.ok(terrorToken);
+ const terrorPlacement: BasicSemutaAmbassadorStep = { actor: moritani,
+ action: { type: 'decision', token: terrorToken.id, territory } };
+ act(terrorPlacement.actor, terrorPlacement.action);
+ reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant &&
+ basicSemutaAmbassadorClean(state));
+ assert.equal(game.storm, 3, 'The first native zero dials and subsequent legal one dials retain the source route');
+ assert.equal(game.order.indexOf(owner) < game.order.indexOf(moritani), first === 'ambassador');
+ assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === original.token)!.location, territory);
+ assert.equal(game.moritaniTerror!.tokens.find(token => token.id === terrorToken.id)!.location, territory);
+ assert.ok(!player(game, entrant).shipped && player(game, entrant).moved === 0);
+ const beforeStaging = structuredClone(game);
+ const held = player(game, entrant).hand.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(held >= 0);
+ player(game, claimant).hand.push(player(game, entrant).hand.splice(held, 1)[0]);
+ const fill = (actor: string) => {
+ while (player(game, actor).hand.length < 4) {
+ const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
+ assert.ok(index >= 0); player(game, actor).hand.push(game.deck.splice(index, 1)[0]);
+ }
+ };
+ if (terror === 'robbery') fill(moritani);
+ if (options.fullHand) fill(claimant);
+ const beforeShipment = structuredClone(game);
+ const shipment: BasicSemutaAmbassadorStep = { actor: entrant, action: { type: 'ship',
+ territory: arrival === 'shipment' ? territory : 'habbanya_ridge_flat', sector,
+ amount: 1, elite: 0, allyPayment: 0 } };
+ let arrivalStep = shipment;
+ if (arrival === 'movement') {
+ act(shipment.actor, shipment.action); reach(basicSemutaAmbassadorClean);
+ assert.equal(player(game, entrant).spice, player(beforeShipment, entrant).spice - 1);
+ arrivalStep = { actor: entrant, action: { type: 'move', from: 'habbanya_ridge_flat:17',
+ amount: 1, territory, sector } };
+ }
+ assert.deepEqual(inventory(game), originalInventory);
+ const chosenCards = player(game, owner).hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
+ assert.equal(chosenCards.length, effect === 'choam' ? 2 : 1);
+ const entrantCard = player(game, entrant).hand[0]; assert.ok(entrantCard);
+ return { game, arrivalStep, effect, first, arrival, terror, copied,
+ owner, beneficiary: owner, moritani, entrant, claimant,
+ token: original.token, terrorToken: terrorToken.id, semuta: SEMUTA_DRUG_ID,
+ territory, sector, location: territory + ':' + sector, chosenCards,
+ entrantCard: entrantCard.id, overflowDiscard: player(game, moritani).hand[0].id,
+ gift: player(game, moritani).hand[0].id, originalInventory,
+ lobby: original.lobby, setup: original.setup, afterSetup: original.afterSetup,
+ beforeTerrorPlacement, terrorPlacement, beforeStaging, beforeShipment, shipment,
+ prefixActions, branchActions, actions: [...prefixActions, ...branchActions],
+ sourceActions: prefixActions.length + branchActions.length,
+ sourceActionCount: prefixActions.length + branchActions.length,
+ balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
+ fullHand: !!options.fullHand,
+ staging: [
+ 'Fresh five-seat Basic Ecaz/CHOAM/Richese/Guild/Moritani E2/E3 setup uses the original 35-card main deck and ten-card cache with no optional modules. Six unopened main-card positions, two unopened ordinary Spice positions and scalar lotteries are selected. Nine original Ambassador packet draws precede native Moritani token shuffling. Actual lobby circle choices create the requested owner order; no playing order is assigned.',
+ 'Guild actually buys original cache Semuta for one spice. Ecaz buys one ordinary card and pays one spice for its real turn-one Habbanya Ridge Sietch placement. The inherited unplayed turn-one Guild entry is omitted, Guild ends its turn, and the real native Mentat opportunity places the selected Terror at that same destination. Native subsequent one-plus-one Storm dials and quiet controls reach the original turn-two Guild movement turn.',
+ 'At that clean boundary, label the conserved prior transfer of the physical paid Semuta from Guild to Richese. Robbery additionally fills Moritani to its four-card limit from original ordinary deck cards; optional full-hand cases also fill Richese. These acquisitions are explicit staging, not played trades or auctions. No wallet, force, token, clock, alliance, discard, pending frame or receipt is installed. A movement branch really pays for its Ridge Flat reserve shipment before the caller-owned final move; a shipment branch leaves the actual paid destination entry to the caller.',
+ ],
+ };
+}
+export type SemutaArrivalOverlapFixture = ReturnType;
+
+/** Only the current event: continuing it must never also pass a later group. */
+export function passCurrentSemuta(source: Game): Game {
+ const pending = source.pendingTreacheryDiscard; assert.equal(pending?.reaction?.stage, 'offer');
+ const event = pending!.batch.event; let game = source;
+ for (const p of source.players) {
+ const current = game.pendingTreacheryDiscard, reaction = current?.reaction;
+ if (!current || current.batch.event !== event || reaction?.stage !== 'offer') break;
+ if (!reaction.passed.includes(p.id))
+ game = actSemutaArrivalOverlap(game, p.id, { type: 'semutaPass', event });
+ }
+ return game;
+}
+export function claimOverlapSemuta(source: Game, actor: string, card: string): Game {
+ const event = source.pendingTreacheryDiscard!.batch.event;
+ let game = actSemutaArrivalOverlap(source, actor, { type: 'semutaCommit', event });
+ if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
+ game = actSemutaArrivalOverlap(game, actor, { type: 'semutaSelect', event, card });
+ return game;
+}
+export function openOverlapAmbassadorGrant(source: Game, f: SemutaArrivalOverlapFixture): Game {
+ let game = source;
+ if (!game.pendingArrivalOverlap) game = actSemutaArrivalOverlap(game, f.arrivalStep.actor, f.arrivalStep.action);
+ assert.ok(game.pendingArrivalOverlap);
+ if (game.pendingTerrorEntry?.stage === 'offer')
+ game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', decline: true });
+ const entry = game.pendingAmbassador; assert.equal(entry?.stage, 'offer');
+ game = actSemutaArrivalOverlap(game, f.owner, { type: 'decision', event: entry!.event,
+ trigger: true, beneficiary: f.owner, choice: 'effect' });
+ if (f.copied) game = actSemutaArrivalOverlap(game, f.owner,
+ { type: 'decision', event: entry!.event, effect: f.effect });
+ assert.equal(game.pendingAmbassador?.stage, 'cards'); return game;
+}
+export function openOverlapTerrorDiscard(source: Game, f: SemutaArrivalOverlapFixture): Game {
+ assert.equal(source.pendingTerrorEntry?.stage, 'offer');
+ let game = actSemutaArrivalOverlap(source, f.moritani, { type: 'decision', reveal: true });
+ if (f.terror === 'robbery') {
+ assert.equal(game.pendingTerrorEntry?.stage, 'robbery');
+ game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', choice: 'card' });
+ assert.equal(game.pendingTerrorEntry?.stage, 'discard');
+ game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', card: f.overflowDiscard });
+ }
+ assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'terrorDiscard');
+ return game;
+}
+export function finishOverlapTerror(source: Game, f: SemutaArrivalOverlapFixture, options: { give?: boolean } = {}): Game {
+ if (f.terror !== 'sabotage') return source;
+ assert.equal(source.pendingTerrorEntry?.stage, 'gift');
+ return actSemutaArrivalOverlap(source, f.moritani, options.give
+ ? { type: 'decision', card: f.gift } : { type: 'decision', decline: true });
+}
diff --git a/tests/semuta-arrival-overlap-ui.test.tsx b/tests/semuta-arrival-overlap-ui.test.tsx
new file mode 100644
index 00000000..b240d85d
--- /dev/null
+++ b/tests/semuta-arrival-overlap-ui.test.tsx
@@ -0,0 +1,214 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, type ReactElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { SemutaReaction } from '../components/semuta-reaction';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ createSemutaArrivalOverlapFixture as fixture,
+ actSemutaArrivalOverlap as act,
+ player, inventory,
+ openOverlapAmbassadorGrant as grant,
+ openOverlapTerrorDiscard as terrorDiscard,
+ finishOverlapTerror as finishTerror,
+} from './fixture-semuta-arrival-overlap';
+
+type Props = Parameters[0];
+type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
+function controls(node: unknown): Control[] {
+ if (Array.isArray(node)) return node.flatMap(controls);
+ if (!node || typeof node !== 'object' || !('props' in node)) return [];
+ const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
+ return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
+}
+function label(node: unknown): string {
+ if (Array.isArray(node)) return node.map(label).join('');
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ if (!node || typeof node !== 'object' || !('props' in node)) return '';
+ return label((node as ReactElement<{ children?: unknown }>).props.children);
+}
+const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props));
+function click(game: Game, actor: string, text: string, expected: Action): Action {
+ const sent: Action[] = [], props = { game: viewGame(game, actor), busy: false,
+ act: (action: Action) => { sent.push(action); } };
+ const control = controls(SemutaReaction(props)).find(button => label(button.children) === text);
+ assert.ok(control, `The actual shared-entry discard offers ${text}`);
+ assert.equal(control.disabled, false);
+ const busy = controls(SemutaReaction({ ...props, busy: true }))
+ .find(button => label(button.children) === text);
+ assert.ok(busy); assert.equal(busy.disabled, true);
+ busy.onClick(); assert.deepEqual(sent, [], 'Busy callbacks cannot advance a suspended native reaction');
+ control.onClick(); assert.deepEqual(sent, [expected]);
+ return sent[0];
+}
+function offerAction(game: Game, actor: string, commit = false): Action {
+ const reaction = viewGame(game, actor).semutaReaction;
+ assert.ok(reaction?.stage === 'offer');
+ return click(game, actor, commit ? 'Commit Semuta Drug' : 'Continue',
+ { type: commit ? 'semutaCommit' : 'semutaPass', event: reaction.event });
+}
+function continueGroup(source: Game): Game {
+ const pending = source.pendingTreacheryDiscard;
+ assert.equal(pending?.reaction?.stage, 'offer');
+ const event = pending!.batch.event;
+ let game = source;
+ for (const seat of source.players) {
+ const current = game.pendingTreacheryDiscard, reaction = current?.reaction;
+ if (!current || current.batch.event !== event || reaction?.stage !== 'offer') break;
+ if (!reaction.passed.includes(seat.id)) game = act(game, seat.id, offerAction(game, seat.id));
+ }
+ return game;
+}
+function readOffer(game: Game, actor: string, native: 'ambassador' | 'terror', canCommit: boolean) {
+ const before = structuredClone(game);
+ assert.ok(game.pendingArrivalOverlap);
+ for (let reading = 0; reading < 2; reading++) {
+ const view = viewGame(game, actor), html = markup({ game: view, busy: false, act() {} });
+ assert.equal(view.arrivalOverlap?.active, native);
+ assert.match(html, /Continue when you are ready/);
+ assert.match(html, />Continue<\/button>/);
+ assert.equal(html.includes('>Commit Semuta Drug'), canCommit);
+ }
+ assert.deepEqual(game, before, 'Reading persistent text cannot finish either originally earned opportunity');
+}
+function claim(game: Game, actor: string, wanted: string): Game {
+ game = act(game, actor, offerAction(game, actor, true));
+ const reaction = viewGame(game, actor).semutaReaction;
+ if (reaction?.stage === 'select') {
+ const before = structuredClone(game);
+ const html = markup({ game: viewGame(game, actor), busy: false, act() {} });
+ assert.match(html, /Choose one eligible freshly discarded/);
+ assert.doesNotMatch(html, />Continue<\/button>/);
+ assert.match(html, /This choice cannot be declined or canceled/);
+ assert.deepEqual(game, before);
+ const card = reaction.candidates.find(card => card.id === wanted); assert.ok(card);
+ game = act(game, actor, click(game, actor, `Claim ${card.name} (${card.id})`,
+ { type: 'semutaSelect', event: reaction.event, card: card.id }));
+ }
+ assert.ok(player(game, actor).hand.some(card => card.id === wanted));
+ return game;
+}
+function arrived(game: Game, entrant: string, location: string) {
+ assert.equal(player(game, entrant).forces[location], 1);
+ return { forces: structuredClone(player(game, entrant).forces), reserves: player(game, entrant).reserves,
+ tanks: player(game, entrant).tanks, spice: player(game, entrant).spice, moved: player(game, entrant).moved };
+}
+function finished(game: Game, f: ReturnType, originalInventory: string[]) {
+ assert.equal(game.pendingArrivalOverlap, null);
+ assert.equal(game.pendingAmbassador, null);
+ assert.equal(game.pendingTerrorEntry, null);
+ assert.equal(game.pendingTreacheryDiscard, null);
+ assert.equal(game.active, f.entrant);
+ assert.equal(game.decision, null);
+ assert.deepEqual(inventory(game), originalInventory);
+ assert.equal(SemutaReaction({ game: viewGame(game, f.claimant), busy: false, act() {} }), null);
+ return act(game, f.entrant, { type: 'endMovement' });
+}
+
+void test('CHOAM Select retires its shared-entry discard before the original Sabotage opportunity uses Continue', () => {
+ const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'shipment', terror: 'sabotage' });
+ const originalInventory = inventory(f.game);
+ let game = grant(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
+ const originalArrival = arrived(game, f.entrant, f.location);
+ const spice = player(game, f.owner).spice;
+ game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
+ assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
+ assert.equal(player(game, f.owner).spice, spice + 6);
+ const firstEvent = game.pendingTreacheryDiscard!.batch.event;
+ readOffer(game, f.claimant, 'ambassador', true);
+ game = claim(game, f.claimant, f.chosenCards[0]);
+ assert.equal(game.pendingTerrorEntry?.stage, 'offer');
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second');
+ assert.equal(game.pendingAmbassador, null);
+ assert.equal(player(game, f.owner).spice, spice + 6);
+ game = terrorDiscard(game, f);
+ assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'terrorDiscard');
+ assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent);
+ readOffer(game, f.claimant, 'terror', false);
+ const before = structuredClone(game);
+ assert.throws(() => act(game, f.claimant, { type: 'semutaCommit', event: firstEvent }));
+ assert.deepEqual(game, before, 'The first discard cannot acknowledge or claim the second native event');
+ game = continueGroup(game);
+ assert.equal(game.pendingTerrorEntry?.stage, 'gift');
+ game = finishTerror(game, f);
+ assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
+ assert.equal(player(game, f.owner).spice, spice + 6);
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ finished(game, f, originalInventory);
+});
+
+void test('Sabotage Commit claims its sole card before the second-owner Ix replacement waits for Continue', () => {
+ const f = fixture({ effect: 'ixians', first: 'terror', arrival: 'movement', terror: 'sabotage' });
+ const originalInventory = inventory(f.game);
+ let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
+ const originalArrival = arrived(game, f.entrant, f.location);
+ game = terrorDiscard(game, f);
+ const discarded = game.pendingTreacheryDiscard!.batch.entries[0].card.id;
+ readOffer(game, f.claimant, 'terror', true);
+ game = claim(game, f.claimant, discarded);
+ assert.equal(game.pendingTerrorEntry?.stage, 'gift');
+ assert.ok(!game.pendingAmbassador, 'Gift still belongs to the first original owner');
+ game = finishTerror(game, f);
+ assert.equal(game.pendingAmbassador?.stage, 'offer');
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second');
+ game = grant(game, f);
+ const top = game.deck[0].id, size = game.deck.length, spice = player(game, f.owner).spice;
+ game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
+ assert.equal(game.deck.length, size);
+ readOffer(game, f.claimant, 'ambassador', false);
+ game = continueGroup(game);
+ assert.equal(game.deck.length, size - 1);
+ assert.ok(player(game, f.owner).hand.some(card => card.id === top));
+ assert.equal(player(game, f.owner).spice, spice);
+ assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ finished(game, f, originalInventory);
+});
+
+void test('Robbery Continue preserves its single draw while a later copied CHOAM Select claims a different fresh group', () => {
+ const f = fixture({ effect: 'choam', first: 'terror', arrival: 'shipment', terror: 'robbery', copied: true });
+ const originalInventory = inventory(f.game);
+ let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
+ const originalArrival = arrived(game, f.entrant, f.location);
+ const size = game.deck.length;
+ game = terrorDiscard(game, f);
+ assert.equal(game.deck.length, size - 1);
+ const firstEvent = game.pendingTreacheryDiscard!.batch.event;
+ readOffer(game, f.claimant, 'terror', true);
+ game = continueGroup(game);
+ assert.equal(game.pendingAmbassador?.stage, 'offer');
+ game = grant(game, f);
+ const spice = player(game, f.owner).spice;
+ game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
+ assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent);
+ readOffer(game, f.claimant, 'ambassador', true);
+ game = claim(game, f.claimant, f.chosenCards[1]);
+ assert.equal(player(game, f.owner).spice, spice + 6);
+ assert.equal(game.deck.length, size - 1, 'The first owner does not draw again after the copied grant');
+ assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ finished(game, f, originalInventory);
+});
+
+void test('first-owner Ix Continue draws once before the second-owner Robbery Commit closes its own discard', () => {
+ const f = fixture({ effect: 'ixians', first: 'ambassador', arrival: 'movement', terror: 'robbery' });
+ const originalInventory = inventory(f.game);
+ let game = grant(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
+ const originalArrival = arrived(game, f.entrant, f.location);
+ const top = game.deck[0].id, size = game.deck.length;
+ game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
+ readOffer(game, f.claimant, 'ambassador', true);
+ game = continueGroup(game);
+ assert.equal(game.deck.length, size - 1);
+ assert.ok(player(game, f.owner).hand.some(card => card.id === top));
+ assert.equal(game.pendingTerrorEntry?.stage, 'offer');
+ game = terrorDiscard(game, f);
+ const discarded = game.pendingTreacheryDiscard!.batch.entries[0].card.id;
+ assert.equal(game.deck.length, size - 2);
+ readOffer(game, f.claimant, 'terror', true);
+ game = claim(game, f.claimant, discarded);
+ assert.equal(game.deck.length, size - 2);
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
+ finished(game, f, originalInventory);
+});
diff --git a/tests/semuta-arrival-overlap.test.ts b/tests/semuta-arrival-overlap.test.ts
new file mode 100644
index 00000000..d91a3452
--- /dev/null
+++ b/tests/semuta-arrival-overlap.test.ts
@@ -0,0 +1,211 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
+ player, inventory, passCurrentSemuta as pass, claimOverlapSemuta as claim,
+ openOverlapAmbassadorGrant as grant, openOverlapTerrorDiscard as terrorDiscard,
+ finishOverlapTerror as finishTerror, type SemutaArrivalOverlapFixture,
+} 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);
+}
+function arrived(game: Game, f: SemutaArrivalOverlapFixture) {
+ assert.equal(player(game, f.entrant).forces[f.location], 1);
+ assert.equal(player(game, f.entrant).shipped, true);
+ assert.equal(player(game, f.entrant).moved, f.arrival === 'movement' ? 1 : 0);
+ assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - Number(f.arrival === 'shipment'));
+ for (const p of game.players)
+ assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20);
+ assert.deepEqual(inventory(game), f.originalInventory);
+}
+function ambassadorDiscard(source: Game, f: SemutaArrivalOverlapFixture) {
+ const entry = source.pendingAmbassador; assert.equal(entry?.stage, 'cards');
+ const game = act(source, f.owner, { type: 'decision', event: entry!.event, cards: f.chosenCards });
+ assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
+ assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
+ assert.equal(game.pendingAmbassador, null);
+ assert.ok(game.pendingArrivalOverlap, 'The original ordered parent remains while the discard is read');
+ assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, []);
+ arrived(game, f); return game;
+}
+function done(game: Game, f: SemutaArrivalOverlapFixture) {
+ assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
+ assert.ok(!game.pendingTreacheryDiscard && !game.decision && !game.response);
+ assert.equal(game.active, f.entrant); arrived(game, f);
+ game = act(game, f.entrant, { type: 'endMovement' });
+ assert.ok(!game.movementRemaining?.includes(f.entrant));
+ assert.deepEqual(inventory(game), f.originalInventory);
+}
+
+for (const first of ['ambassador', 'terror'] as const)
+ for (const arrival of ['shipment', 'movement'] as const)
+ void test(`actual Basic ${arrival} snapshots both original native owners, ${first} first from real lobby circles`, () => {
+ const f = fixture({ effect: 'choam', first, arrival });
+ assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
+ assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.turn, 2);
+ assert.equal(f.game.players.length, 5); assert.equal(f.game.semutaPreview, true);
+ assert.ok(f.actions.some(step => step.action.type === 'seatPosition'));
+ assert.equal(f.game.moritaniTerror!.placementTurn, 1);
+ assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
+ assert.ok(f.game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(f.territory + ':'))));
+ let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
+ const overlap = game.pendingArrivalOverlap; assert.ok(overlap);
+ assert.equal(overlap.first, first); assert.equal(overlap.stage, 'first');
+ assert.equal(overlap.entrant, f.entrant); assert.equal(overlap.cause, arrival);
+ assert.equal(overlap.ambassadorToken, f.token);
+ assert.deepEqual(overlap.terrorTokens, [f.terrorToken]);
+ assert.equal(game.decision?.player, first === 'ambassador' ? f.owner : f.moritani);
+ arrived(game, f);
+ reject(game, f.entrant, { type: 'endMovement' });
+ game = first === 'ambassador'
+ ? act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true })
+ : act(game, f.moritani, { type: 'decision', decline: true });
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second'); arrived(game, f);
+ game = first === 'ambassador'
+ ? act(game, f.moritani, { type: 'decision', decline: true })
+ : act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true });
+ done(game, f);
+ });
+
+void test('CHOAM first recovers one private card before Sabotage, pays once and cannot spend the same Semuta twice', () => {
+ const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
+ let game = grant(f.game, f);
+ const beforeDiscard = structuredClone(game);
+ game = ambassadorDiscard(game, f);
+ const firstEvent = game.pendingTreacheryDiscard!.batch.event;
+ const overlap = structuredClone(game.pendingArrivalOverlap);
+ assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
+ reject(game, f.moritani, { type: 'decision', reveal: true });
+ game = act(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
+ assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
+ assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates.map(card => card.id), f.chosenCards);
+ assert.deepEqual(game.pendingArrivalOverlap, overlap);
+ for (const p of game.players.filter(p => p.id !== f.claimant))
+ assert.deepEqual(viewGame(game, p.id).semutaReaction!.candidates, []);
+ reject(game, f.owner, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
+ game = act(game, f.claimant, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second');
+ assert.equal(game.pendingTerrorEntry?.stage, 'offer');
+ assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
+ assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
+ game = terrorDiscard(game, f);
+ assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
+ assert.notEqual(game.pendingTreacheryDiscard.batch.event, firstEvent);
+ for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
+ reject(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
+ game = finishTerror(pass(game), f, { give: true });
+ assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
+ done(game, f);
+});
+
+void test('Robbery first passes its overflow before a full-hand Ixian claim and one original replacement', () => {
+ const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery', fullHand: true });
+ let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
+ const top = game.deck[0].id, size = game.deck.length;
+ game = terrorDiscard(game, f);
+ assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
+ assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'terrorDiscard');
+ assert.equal(game.pendingArrivalOverlap?.stage, 'first');
+ assert.equal(game.deck.length, size - 1);
+ assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
+ const terrorEvent = game.pendingTreacheryDiscard.batch.event;
+ game = pass(game);
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second');
+ assert.equal(game.pendingAmbassador?.stage, 'offer');
+ game = grant(game, f); const replacement = game.deck[0].id;
+ game = ambassadorDiscard(game, f);
+ reject(game, f.claimant, { type: 'semutaCommit', event: terrorEvent });
+ assert.equal(game.deck.length, size - 1, 'Ixian replacement has not drawn during the fresh discard');
+ game = claim(game, f.claimant, f.chosenCards[0]);
+ assert.equal(player(game, f.claimant).hand.length, 4);
+ assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
+ assert.equal(game.deck.length, size - 2);
+ assert.ok(player(game, f.owner).hand.some(card => card.id === replacement));
+ assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ done(game, f);
+});
+
+void test('Sabotage first spends Semuta on its victim before copied CHOAM creates a distinct discard', () => {
+ const f = fixture({ effect: 'choam', first: 'terror', arrival: 'movement', copied: true });
+ let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
+ game = terrorDiscard(game, f);
+ const terrorEvent = game.pendingTreacheryDiscard!.batch.event;
+ assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.entrantCard);
+ game = claim(game, f.claimant, f.entrantCard);
+ assert.equal(game.pendingArrivalOverlap?.stage, 'first');
+ game = finishTerror(game, f);
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second');
+ game = grant(game, f);
+ assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'removed');
+ game = ambassadorDiscard(game, f);
+ assert.notEqual(game.pendingTreacheryDiscard!.batch.event, terrorEvent);
+ for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
+ game = pass(game);
+ assert.ok(player(game, f.claimant).hand.some(card => card.id === f.entrantCard));
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
+ done(game, f);
+});
+
+void test('Ixian first passes before Robbery overflow recovery without replaying either draw', () => {
+ const f = fixture({ effect: 'ixians', first: 'ambassador', terror: 'robbery', arrival: 'movement' });
+ let game = grant(f.game, f);
+ const size = game.deck.length;
+ const ixianDraw = game.deck[0].id;
+ game = ambassadorDiscard(game, f);
+ const event = game.pendingTreacheryDiscard!.batch.event;
+ game = pass(game);
+ assert.equal(game.deck.length, size - 1);
+ assert.ok(player(game, f.owner).hand.some(card => card.id === ixianDraw));
+ const robberyDraw = game.deck[0].id;
+ game = terrorDiscard(game, f);
+ assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
+ assert.notEqual(game.pendingTreacheryDiscard.batch.event, event);
+ assert.equal(game.deck.length, size - 2);
+ assert.ok(player(game, f.moritani).hand.some(card => card.id === robberyDraw));
+ game = claim(game, f.claimant, f.overflowDiscard);
+ assert.equal(game.deck.length, size - 2);
+ assert.ok(player(game, f.claimant).hand.some(card => card.id === f.overflowDiscard));
+ done(game, f);
+});
+
+void test('an empty copied CHOAM grant opens no empty fresh group and retains the second native Terror offer', () => {
+ const f = fixture({ effect: 'choam', first: 'ambassador', copied: true });
+ let game = grant(f.game, f); const spice = player(game, f.owner).spice;
+ game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: [] });
+ assert.ok(!game.pendingTreacheryDiscard);
+ assert.equal(player(game, f.owner).spice, spice);
+ assert.equal(game.pendingArrivalOverlap?.stage, 'second');
+ assert.equal(game.pendingTerrorEntry?.stage, 'offer');
+ game = act(game, f.moritani, { type: 'decision', decline: true });
+ done(game, f);
+});
+
+void test('the Ambassador discard must keep the original shared entry sector and active owner', () => {
+ const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
+ const game = ambassadorDiscard(grant(f.game, f), f);
+ const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
+ const moved = structuredClone(game), continuation = moved.pendingTreacheryDiscard!.continuation;
+ assert.equal(continuation.kind, 'ambassador');
+ if (continuation.kind === 'ambassador') continuation.entry.sector = 18;
+ reject(moved, f.claimant, action);
+ const advanced = structuredClone(game);
+ advanced.pendingArrivalOverlap!.stage = 'second';
+ reject(advanced, f.claimant, action);
+});
+
+void test('the Terror discard must retain the original shared entry amount before Semuta resumes it', () => {
+ const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery' });
+ const game = terrorDiscard(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
+ const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
+ const changed = structuredClone(game), continuation = changed.pendingTreacheryDiscard!.continuation;
+ assert.equal(continuation.kind, 'terrorDiscard');
+ if (continuation.kind === 'terrorDiscard') continuation.entry.amount++;
+ reject(changed, f.claimant, action);
+});
diff --git a/tests/truth-movement-engine.test.ts b/tests/truth-movement-engine.test.ts
index a1e319ae..3a68ef6f 100644
--- a/tests/truth-movement-engine.test.ts
+++ b/tests/truth-movement-engine.test.ts
@@ -150,8 +150,12 @@ for (const answer of ['yes', 'no'] as const) void test(`actual bought two-group
if (answer === 'yes') {
game = act(game, f.target, second);
assert.equal(game.movementPromises![0].fulfilled, true, 'Physical commitment precedes final card disposal');
- // Native Ecaz retains its existing final-move Semuta boundary; the
- // physical card still retires once before the ordinary entry resumes.
+ // Completed native Basic flights now offer their physical card before
+ // the original arrival; the already fulfilled promise survives that pause.
+ assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard');
+ assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
+ game = passSemuta(game);
+ assert.equal(game.movementPromises![0].fulfilled, true);
assert.equal(game.pendingTreacheryDiscard, null);
game = act(game, f.target, { type: 'endMovement' });
} else {