From b19e6dc940e84bed8f2d951312c342b8cd9262e7 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:19:42 +1100 Subject: [PATCH] Offer Semuta on a queued Truthtrance answer --- docs/CURRENT_STATUS.md | 12 +++++++ docs/IMPLEMENTATION_STATUS.md | 20 +++++++++++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 16 ++++++--- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 14 +++++++- game/engine.ts | 2 +- game/reference.ts | 6 ++-- tests/semuta-engine.test.ts | 48 +++++++++++++++++++++++++++ 7 files changed, 108 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6c3bd26b..7dcb0852 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,18 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Queued Truthtrance answer — 11 October.** An ordinary definite Truthtrance +answer now offers its consumed card even when another question remains reserved. +Only the `remaining === null` software restriction changed: claim or all-seat +decline leaves public history and the consumed card's custody settled, then +restores the same remaining queue at `ask` once. Promise-bound shipment and +battle-plan answers, shrine source and every other Truthtrance path stay +automatic. Types/lint pass; the affected 97-case union passes. Seven isolated +runtime scenarios cover claim/pass, four legal AI profiles and a rejected +retired-event replay. The permanent regression fails before and passes after. +No saved store or deployed game was targeted. See +[discard continuations](SEMUTA_DISCARD_CONTINUATIONS.md). + **Fremen Cunning / original components — 11 October.** Classic Nexus games with original Tech Tokens and/or Advanced Stronghold Cards now receive the same empty-worm native Cunning offer as Nexus alone. The isolated software guard is diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 728354de..37b45873 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Dune implementation status +## 11 October 2026 — Semuta before a reserved Truthtrance question resumes + +The typed `truthtranceDiscard` receipt already captured the remaining queue and +restored it at `ask`; the Semuta admission check additionally required no +reserved question. Removing only that clause lets an ordinary definite answer +offer its consumed card while another question stays pending. The integrity +validator already enforced the remaining queue's card custody and priority, so +no new custody path was added. Shrine-source answers, promise-bound shipment +answers and battle-plan answers keep their automatic continuation because the +promise clause is retained. + +The permanent regression fails before the change with no offer and passes after, +covering the reserved queue's identity, public history, claimant/decline custody, +a retired-event rejection and full inventory conservation. Types/lint pass and +the 97-case affected union passes. Seven isolated CLI scenarios cover claim and +all-seat decline, four legal AI profiles and a rejected replayed claim. This is +local changed-path proof, not complete Semuta, recovery or deployed acceptance. +Contracts: [discard continuations](SEMUTA_DISCARD_CONTINUATIONS.md), +[source boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + ## 11 October 2026 — native Fremen Cunning with original Tech/Strongholds The classic original-component Nexus envelope already permits Tech Tokens3+ diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 743340d8..bbd6ce48 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -14,12 +14,18 @@ gifts and competing parents retain the original direct transfer behavior without this Semuta reaction. The table below remains the broader producer/continuation inventory, not a claim of complete coverage. +A clean definite Truthtrance answer now offers its consumed card even when another +question remains reserved, provided no promise bound this answer. Claim or +all-seat decline leaves public history and the consumed card's custody settled, +then restores the same remaining queue at `ask` once. Shrine-source, queued +shipment and queued battle-plan answers retain their automatic continuation. + The same preview also pauses a final definite Truthtrance after its -public answer, history and physical card disposal are committed. Only -an ordinary held Truthtrance with no following queued question, new -promise or competing parent qualifies. A claim may move that consumed -card, never re-answer the question or rewrite its history; other -Truthtrance continuations remain automatic. +public answer, history and physical card disposal are committed. An +ordinary held Truthtrance with no new promise or competing parent +qualifies; a claim may move that consumed card, never re-answer the +question or rewrite its history. Other Truthtrance continuations remain +automatic. Clean Once Around Sapho stages `saphoAuctionDiscard` after the open lot's finite bidder reorder, hand-slot release and public card disposal, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 6eb042e6..8318fb61 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -234,7 +234,7 @@ 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 Ecaz Ambassador or Moritani Terror, -queued or promise-bound Truthtrance, pending battle rewards and other +promise-bound Truthtrance answers, pending battle rewards and other typed producers retain their automatic continuation; Semuta cannot claim those events yet. The completed Box's selected @@ -245,6 +245,18 @@ 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 — queued Truthtrance answer.** An ordinary definite answer now +offers its consumed card while another question remains reserved. Only the +`remaining === null` software restriction changed; promise-bound shipment and +battle-plan answers, shrine source and every other Truthtrance path stay +automatic. Claim or all-seat decline restores the same remaining queue at `ask` +once with history, card custody and priority intact. + +The permanent regression fails before and passes after. The affected union +passes 97 cases and typecheck/lint pass. Seven isolated CLI scenarios cover +claim/pass, four legal AI profiles and a rejected retired-event replay. This is +local changed-path evidence, not complete Semuta or deployed acceptance. + **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 diff --git a/game/engine.ts b/game/engine.ts index ce2ae251..569d9674 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6678,7 +6678,7 @@ function semutaOfferSupported( !g.pendingArrivalOverlap))) && !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && - continuation.remaining === null && continuation.promise === null)) return false; + continuation.promise === null)) return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || diff --git a/game/reference.ts b/game/reference.ts index 86972a29..e3e9f99d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1169,7 +1169,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' : definition.card.effect === 'semutaDrug' - ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards and public starts remain unfinished.' + ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance including a queued ordinary answer, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved; promise-bound shipment or battle-plan answers do not. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards and public starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1179,7 +1179,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages. 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. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and pending rewards remain unfinished.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages. 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, while promise-bound shipment or battle-plan answers stay automatic. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and pending rewards remain unfinished.', }, { area: 'Player controls' as const, @@ -1197,7 +1197,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes clean public 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 and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources.', + 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance (including a queued ordinary answer), 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 and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources.', }, { area: 'Verification' as const, diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index ff61ec62..5cf760c9 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -688,6 +688,54 @@ void test('a final definite Truthtrance answer offers its consumed card after pu assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); }); +void test('a queued definite Truthtrance answer offers its card before the next reserved question resumes', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const truthA = take(g, 'truthtrance'); + const truthE = take(g, 'truthtrance'); + const shield = g.deck.splice(g.deck.findIndex(card => card.name === 'Shield'), 1)[0]; + assert.ok(shield); + player(g, 'a').hand.push(truthA); + player(g, 'e').hand.push(truthE, shield); + Object.assign(g, { order: ['a', 'e', 'r'] }); + const original = inventory(g); + let declared = applyAction(g, 'a', { type: 'card', card: truthA.id }); + declared = applyAction(declared, 'e', { type: 'card', card: truthE.id }); + while (declared.truthtrance?.stage === 'priority') { + const responder = declared.players.find(p => !declared.truthtrance!.passed.includes(p.id))!; + declared = applyAction(declared, responder.id, { type: 'truthPass' }); + } + const queue = declared.truthtrance!.queue.map(entry => entry.player); + assert.equal(queue.length, 2); + const asked = applyAction(declared, queue[0], { type: 'truthAsk', + question: { kind: 'fact', target: 'e', fact: { kind: 'hand', name: 'Shield' } }, + }); + const pending = applyAction(asked, 'e', { type: 'truthAnswer', answer: 'yes' }); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const continuation = pending.pendingTreacheryDiscard!.continuation; + assert.deepEqual(continuation.kind === 'truthtranceDiscard' ? continuation.remaining?.queue.map(entry => entry.player) : null, + queue.slice(1)); + assert.equal(pending.truthHistory?.length, 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.truthtrance?.stage, 'ask'); + assert.equal(resumed.truthtrance!.question, null); + assert.deepEqual(resumed.truthtrance!.queue.map(entry => entry.player), queue.slice(1)); + assert.deepEqual(resumed.truthHistory, pending.truthHistory); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.filter(card => card.id === truthA.id).length, 1); + assert.equal(player(claimed, 'r').hand.some(card => card.id === truthA.id), true); + assert.equal(claimed.discard.some(card => card.id === semuta.id), true); +}); + void test('a completed Sapho first-movement reorder offers its public card before changing the active turn', () => { const { g, semuta, hajr } = fixture(); player(g, 'a').hand = [];