diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2f4d5f86..00f76d1d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta / native Intrusion — 11 October.** Final Ornithopter movement now +offers the retired card before the original Bene Gesserit Intrusion choice. +Forces and move counters stay committed; claim or decline cannot move twice or +skip the original arrival. Basic keeps no Advanced Intrusion; Ecaz/Moritani and +overlap guards remain. Types/lint pass and 113 affected cases pass after replacing +a mistaken advisor-storage assertion with actual fighter-count behavior. +Ten isolated runtime scenarios cover both modes, claim/pass, fighter retention, +two-group completion and four legal bots. Real GameTable at 390px proves Commit, +Become advisors and Finish shipment & movement advancing to the next seat with +unchanged force counts and no horizontal overflow. The observed stale Semuta +tooltip now names Black Market replacement, native Intrusion and the approved +full-hand exchange. No saved store or deployed game was targeted. See +[the movement continuation](ORNITHOPTER_DISCARD_CONTINUATIONS.md#11-october-2026--semuta-before-native-intrusion). + **Semuta / Black Market replacement — 11 October.** A clean native Ixian allied replacement now offers Semuta before drawing from a Black Market purchase, as already supported for normal auctions. The discarded card belongs diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 65b83185..48012777 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,37 @@ # Dune implementation status +## 11 October 2026 — Semuta before original Bene Gesserit Intrusion + +Final Ornithopter movement already commits forces, counters and a typed +discard-before-arrival frame. Its Semuta admission still excluded every roster +with Bene Gesserit. That software limit is removed without changing the arrival +suffix: claim or all-seat decline precedes the original native Intrusion once. +Basic retains no Advanced Intrusion; Ecaz/Moritani and overlap guards remain. +This is the existing ordering composition, not a publisher priority ruling. + +The regression fails before the admission change. The repaired test checks +fighters remain fighters during the Semuta pause, then become zero fighters +after the original optional flip while the two physical counters remain. +An initial assertion incorrectly expected the advisor marker to be a boolean; +it was deleted rather than re-pinning its object representation. Final 113/113 +affected cases pass. TypeScript and lint pass. + +Ten isolated CLI scenarios cover Basic/Advanced claim/pass, Intrusion decline, +final two-group completion and four legal bots. Real GameTable at 390px, using +the installed production Vinext image adapter, proves Commit recovering the +physical Ornithopter, the original Become advisors control and Finish shipment +& movement advancing Atreides→Richese. Atreides retains Imperial Basin2/Arrakeen1 +and BG retains its original two counters as advisors. No horizontal overflow. +The observed stale Semuta availability tooltip is corrected to describe the +connected private Black Market replacement, native Intrusion and approved +full-hand exchange. + +The fixture uses real setup/engine/table with conserved staged forces and no +saved store. Temporary scripts/services/tabs were removed or closed. This is +local changed-path proof, not deployed, full-module, asset or recovery acceptance. +Contracts: [Semuta](SEMUTA_SOURCE_UPDATE_20260907.md), +[Ornithopter](ORNITHOPTER_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — Semuta before Black Market allied replacement draw The native Black Market purchase already offers Ixian allied replacement and diff --git a/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md b/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md index 3e2eef15..7c0bcf72 100644 --- a/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md +++ b/docs/ORNITHOPTER_DISCARD_CONTINUATIONS.md @@ -2,6 +2,24 @@ 7 September 2026. Retiring the played Ornithopter now preserves the exact remaining arrival or turn-ending step. This continues the shared discard infrastructure without enabling Semuta or changing any unresolved movement rule or expansion start gate. +## 11 October 2026 — Semuta before native Intrusion + +The explicit Semuta development envelope now pauses the final public Ornithopter +discard even when Bene Gesserit is seated. Its forces and movement counters are +already committed. Claim or all-seat decline retires the same saved frame before +the original native Intrusion choice; it cannot move the group again or skip that +choice. Basic has no Advanced Intrusion. Ecaz, Moritani and overlapping entry +reactions remain outside this Semuta boundary. This is the existing typed +discard-before-arrival ordering, not a new publisher priority ruling. + +Ten isolated engine scenarios cover Basic/Advanced claim/pass, retaining fighters, +final two-group completion and four legal bots. In real GameTable at 390px, +Commit recovers the physical Ornithopter; Become advisors flips the original +two BG counters; Finish shipment & movement advances from Atreides to Richese. +Atreides retains two counters in Imperial Basin and one in Arrakeen. No saved +store was targeted. [Current Semuta scope](SEMUTA_SOURCE_UPDATE_20260907.md). +The dated 7 September infrastructure and assurance evidence below is historical. + ## Two endings, one physical card A completed final group has already moved its forces and concealed marker, consumed its original-group quota and incremented movement counters. The retired card is moved from played escrow to the discard pile once, and the retirement log is committed. Its `ornithopterDiscard` frame with source `move` preserves only the final movement facts needed to log and open arrival decisions. Recovery never repeats force removal, placement, marker event generation, shipment completion or movement counters. diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 69adde7f..743340d8 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -121,12 +121,14 @@ claim or decline marks the one protected territory and resumes the same storm exactly once. Canceled cards, Nexus conversion and competing parents retain their direct paths. -Completed Ornithopter movement now offers the same retired public card -before its already committed arrival suffix only in a roster without -Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response -sources. The final forces and move counter are held in the existing -bound flight frame; claim or decline retires that frame and logs the -arrival once. Mixed entry reactions remain unsupported for Semuta. +Completed Ornithopter movement now offers its retired public card before +the original arrival suffix, including native Bene Gesserit Intrusion. +The final forces and move counter stay committed in the bound flight frame. +Claim or decline retires that frame before logging arrival and opening the +original optional fighter-to-advisor choice once. This order is the existing +discard-before-arrival composition, not a new publisher priority ruling. +Ecaz Ambassador, Moritani Terror and overlapping entry reactions remain outside +this Semuta boundary. (11 October 2026) Clean `battleResolved` now pauses the first public mandatory batch after combat result and actual card custody are committed but before diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index c9a4bd16..6eb042e6 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -233,8 +233,7 @@ This neutral all-seat acknowledgement is a conservative **application policy**, not a newly discovered publisher timing rule. Full-hand Semuta uses the explicit user-approved atomic exchange; its completed hand count is unchanged. Pending gifts, competing responses, -completed Ornithopter -arrivals with BG intrusion, Ecaz Ambassador or Moritani Terror, +completed Ornithopter arrivals with Ecaz Ambassador or Moritani Terror, queued or promise-bound Truthtrance, pending battle rewards and other typed producers retain their automatic continuation; Semuta cannot claim those events yet. @@ -245,3 +244,17 @@ from this omission. The separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why auto-closing only when nobody secretly holds the card would leak possession. The main game and public expansion gates remain closed. + +**11 October 2026 — native Intrusion suffix.** Final Ornithopter movement now +offers the retired public card to Semuta before native Bene Gesserit Intrusion. +Physical movement and counters have already committed; claim or decline resumes +the same arrival and optional fighter-to-advisor choice once. Basic still has +no Advanced Intrusion. Range3 and final two-group completion work; other entry +families and overlap guards are unchanged. This is the existing typed +discard-before-arrival composition, not a new publisher priority ruling. +Ten isolated engine scenarios prove claim/pass in both modes, Intrusion decline, +two-group completion and four legal bots. Real GameTable at 390px proves Commit, +the original Become advisors control and Finish shipment & movement advancing +to the next seat with unchanged force counts. Public/full-mode gates stay closed. +TypeScript/lint pass; the final affected union passes 113 cases. The regression +checks actual fighter counts rather than the incidental advisor marker shape. diff --git a/game/card-presentation.ts b/game/card-presentation.ts index e85756e1..24dceb46 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { if (definition?.card.effect === 'distrans') return 'Choose a recipient and card in the Distrans transfer panel.'; if (definition?.card.effect === 'semutaDrug') - return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. The approved full-hand exchange spends Semuta without increasing the completed hand. Pending rewards, other producers and nested responses remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; diff --git a/game/engine.ts b/game/engine.ts index 70146685..ee3d4eda 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6640,7 +6640,7 @@ function treacheryDiscardIntegrity(g: Game) { } /** Bounded public single-card reaction after clean completed effects. Battle * winner cleanup may resume only without other pending battle rewards; final - * flight arrival excludes factions that open intrusion or entry responses. */ + * flight arrival may resume native Intrusion, but other entry responses remain gated. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], @@ -6674,8 +6674,7 @@ function semutaOfferSupported( !(continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || (continuation.source === 'move' && - !g.players.some(p => p.faction === 'beneGesserit' || - p.faction === 'ecaz' || p.faction === 'moritani') && + !g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') && !g.pendingArrivalOverlap))) && !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index bf85f402..ff61ec62 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; +import { fighterCount } from '../game/advisors'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { spiceDeck } from '../game/cards'; import { saphoAggressorGame, aggressorAction, prepareAggressorPlans } from './fixture-sapho-aggressor'; @@ -22,11 +23,11 @@ function inventory(g: Game) { assert.equal(new Set(ids).size, ids.length); return ids; } -function fixture(ix = false, ixianAlly = false, advanced = false) { +function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' = 'emperor') { let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, ix ? ['choam', 'ix'] : ['choam']); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); - joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : 'Emperor', - ixianAlly ? 'ixians' : 'emperor')); + joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : 'Emperor', + ixianAlly ? 'ixians' : opponent)); for (const p of g.players) { p.bot = 'Medium'; p.ready = true; } g = initializeSemutaGameForAudit(g); for (let step = 0; g.status === 'setup' && step < 80; step++) { @@ -538,6 +539,49 @@ void test('a retired Ornithopter can be claimed after one flight group without r assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); }); +void test('final Ornithopter discard resolves Semuta before the original Bene Gesserit intrusion without moving twice', () => { + const { g, semuta, hajr } = fixture(false, false, true, 'beneGesserit'); + player(g, 'a').hand = []; + g.deck.push(hajr); + const ornithopter = take(g, 'richese-ornithopter'); + player(g, 'a').hand.push(ornithopter); + Object.assign(player(g, 'a'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, shipped: true }); + Object.assign(player(g, 'e'), { forces: { 'arrakeen:10': 2 }, reserves: 18, advisors: {} }); + const original = inventory(g); + const pending = applyAction(g, 'a', { + type: 'move', movementCard: ornithopter.id, ornithopter: 'range3', + forces: { 'imperial_basin:10': 1 }, territory: 'arrakeen', sector: 10, + }); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.decision, null); + assert.equal(fighterCount(player(pending, 'e'), 'arrakeen'), 2); + assert.equal(player(pending, 'a').moved, 1); + assert.deepEqual(player(pending, 'a').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 }); + const event = pending.pendingTreacheryDiscard!.batch.event; + let declined = reload(pending); + for (const id of ['a', 'e', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + for (const resumed of [declined, claimed]) { + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.equal(resumed.decision?.kind, 'intrusion'); + assert.equal(resumed.decision?.player, 'e'); + let finished = applyAction(resumed, 'e', { type: 'decision', accept: true }); + while (finished.response?.kind === 'advisorFlip') { + const actor = finished.players.find(p => !finished.response!.passed.includes(p.id))!; + finished = applyAction(finished, actor.id, { type: 'passResponse' }); + } + assert.equal(finished.decision, null); + assert.equal(fighterCount(player(finished, 'e'), 'arrakeen'), 0); + assert.deepEqual(player(finished, 'e').forces, { 'arrakeen:10': 2 }); + assert.equal(player(finished, 'a').moved, 1); + assert.deepEqual(player(finished, 'a').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 }); + assert.deepEqual(inventory(finished), original); + assert.throws(() => applyAction(finished, 'r', { type: 'semutaCommit', event })); + } + assert.equal(player(claimed, 'r').hand.some(card => card.id === ornithopter.id), true); + assert.equal(claimed.discard.some(card => card.id === semuta.id), true); +}); + void test('a completed private Distrans transfer offers only its public used card', () => { const { g, semuta, hajr } = fixture(); const distrans = take(g, 'richese-distrans');