diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 17e8ed7e..fac64a98 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -13,7 +13,6 @@ import { useId, useState } from 'react'; import { Button } from '@/components/ui/button'; import { shipmentAvailable as targetShipmentAvailable } from '@/game/shipment-opportunity'; import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; -import { shipmentPromiseModeSupported } from '@/game/shipment-promises'; import { truthQuestionText, type TruthFact, @@ -69,7 +68,7 @@ export function Truthtrance({ const invalidMixed = mixedShipmentInputError(mixed, g, target); const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause); const shipmentAvailable = - shipmentPromiseModeSupported(g) && + g.truthShipmentSupported && g.phase === 5 && g.active === target && targetShipmentAvailable(g, {id:target,shipped:g.players.find(p => p.id === target)?.shipped ?? false}) && @@ -358,11 +357,11 @@ export function Truthtrance({ {!shipmentAvailable && (
Automatic enforcement currently supports the active - player’s unused shipment in base Basic games and base - Advanced games without Guild or optional modules. Select - that player and finish pending decisions first. Earlier - questions and other rules configurations remain - unfinished. + player’s unused ordinary shipment in base rules or a + classic payer in the Richese Semuta profile. Select + that player and finish pending decisions first. + Native No-Field shipments and other rules configurations + remain unfinished.
)}diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b14b1a48..20c473e6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,19 @@ 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 / Truthtrance shipment promise — 11 October.** A classic payer's ordinary +Truthtrance shipment answer in the Richese Semuta profile now offers the consumed +card; the binding promise and its enforcement are unchanged. The structured +shipment-question scope gained that profile for a non-Richese active payer, and the +availability boundary is now server-projected (`truthShipmentSupported`) so the +human Truthtrance panel and validation share it. Native No-Field shipments and +other module combinations stay automatic. Types/lint pass and 125 affected cases +pass. Eight isolated runtime scenarios cover both answers across four legal AI +profiles; a real 390px control check confirms the shipment question is selectable +for a classic payer and refused for a Richese target, with no horizontal overflow. +No saved store or deployed game was targeted. See +[the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Guidance accuracy — 11 October.** Three stale player/developer records were corrected: the card-inspector Semuta availability text now lists every connected producer and the approved full-hand exchange; `verification.runtime` marks all ten diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 37b45873..ac93f720 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 11 October 2026 — Semuta with a bound Truthtrance shipment promise + +The typed `truthtranceDiscard` receipt already carried a bound promise and the +integrity validator already required it; the Semuta admission check excluded every +promise. Admitting only `kind === 'shipment'` lets a classic payer's ordinary +shipment answer offer its consumed card while leaving battle-plan promises and +every other promise path automatic. + +The structured shipment question was still limited to base games, so the Richese +Semuta profile could not even ask one. `shipmentPromiseModeSupported` now admits +that profile for a non-Richese active payer, and the result is projected as +`truthShipmentSupported` so the human Truthtrance panel and the engine validation +use one boundary instead of the client re-deriving it. + +Both regressions fail before the change (no offer; question rejected) and pass +after, covering claim and decline, promise preservation and enforcement, public +history, and inventory conservation. Types/lint pass and the 125-case affected +union passes. Eight isolated CLI scenarios cover both answers across four legal AI +profiles. A real 390px check confirms the shipment question is selectable for the +Emperor target and refused for a Richese target, with no horizontal overflow. + +Native No-Field shipments and other module combinations remain outside this +boundary. This is local changed-path proof, not complete Semuta or deployed +acceptance. Contracts: [source boundary](SEMUTA_SOURCE_UPDATE_20260907.md), +[discard continuations](SEMUTA_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — Semuta before a reserved Truthtrance question resumes The typed `truthtranceDiscard` receipt already captured the remaining queue and diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index bbd6ce48..49875ec5 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -15,10 +15,11 @@ 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. +question remains reserved, and also when the answer bound a shipment promise in +the supported Richese Semuta profile. Claim or all-seat decline leaves public +history, the consumed card's custody and any bound promise settled, then restores +the same remaining queue at `ask` once. Shrine-source, battle-plan and unsupported +native No-Field shipment 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. An diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 8318fb61..b1b70595 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, -promise-bound Truthtrance answers, pending battle rewards and other +battle-plan 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,21 @@ 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 — shipment-promise answer.** A classic payer's ordinary +Truthtrance shipment answer in the Richese Semuta profile now offers the consumed +card. The binding promise, public history and enforcement are unchanged; claim or +all-seat decline leaves the same obligation enforceable. To admit it, the +structured shipment-question scope gained the Richese Semuta profile for a +non-Richese active payer; the availability boundary is now server-projected +(`truthShipmentSupported`) so human controls and validation share it. Native +No-Field shipments and other module combinations stay automatic. + +The permanent regressions fail before and pass after. The affected union passes +125 cases and typecheck/lint pass. Eight isolated CLI scenarios cover both answers +across four legal AI profiles, plus a real 390px control check that the shipment +question is selectable for a classic payer and refused for a Richese target. This +is local changed-path evidence, not complete Semuta or deployed acceptance. + **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 diff --git a/game/engine.ts b/game/engine.ts index 569d9674..f3d228c9 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.promise === null)) return false; + (continuation.promise === null || continuation.promise.kind === 'shipment'))) return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || @@ -32063,6 +32063,7 @@ export function viewGame(state: Game, id: string) { battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [], battleOrder: projectedBattleOrder(g), guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id), + truthShipmentSupported: shipmentPromiseModeSupported(g), truthShipmentAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && diff --git a/game/reference.ts b/game/reference.ts index e3e9f99d..8284fd5a 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 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.' + ? '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 or a supported shipment promise, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment promise; 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. 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.', + '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, and also when the answer bound a supported shipment promise; 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 (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.', + 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance (including a queued ordinary answer or a supported shipment promise), 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/game/shipment-promises.ts b/game/shipment-promises.ts index dcea12bf..3d9d5fc2 100644 --- a/game/shipment-promises.ts +++ b/game/shipment-promises.ts @@ -150,12 +150,28 @@ export function shipmentClaimText( export function shipmentPromiseModeSupported(game: { advanced: boolean; expansions: readonly unknown[]; - players: readonly { faction: string }[]; + players: readonly { id?: string; faction: string }[]; techTokens?: unknown; strongholdCards?: unknown; nexusCards?: unknown; homeworlds?: unknown; + active?: string | null; + semutaPreview?: unknown; + leaderSkills?: unknown; + discoveryEnabled?: unknown; }): boolean { + if (game.semutaPreview === true && game.expansions.length === 1 && + game.expansions[0] === 'choam' && !game.techTokens && + !game.strongholdCards && !game.nexusCards && !game.homeworlds && + !game.leaderSkills && !game.discoveryEnabled && + game.players.some(player => player.faction === 'richese') && + game.players.every(player => player.faction === 'richese' || + player.faction === 'atreides' || player.faction === 'harkonnen' || + player.faction === 'emperor' || player.faction === 'beneGesserit' || + player.faction === 'fremen')) { + const target = game.players.find(player => player.id === game.active); + return !!target && target.faction !== 'richese'; + } return ( !game.expansions.length && (!game.advanced || diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 3b7d7518..2cebfdbe 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -201,7 +201,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { !g.decision && !g.phaseOpening && !g.pendingShipment, - 'Structured shipment promises currently support base Basic games and base Advanced games without Guild or optional modules, during the active unused shipment. Finish any pending decision first.', + 'Structured shipment promises require a supported active unused shipment: base rules or a classic payer in the Richese Semuta profile. Finish pending decisions first.', ); try { if (v.kind === 'mixedShipment') { diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 5cf760c9..7e932172 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -736,6 +736,54 @@ void test('a queued definite Truthtrance answer offers its card before the next assert.equal(claimed.discard.some(card => card.id === semuta.id), true); }); +for (const answer of ['yes', 'no'] as const) void test( + `Semuta preserves a ${answer} Truthtrance shipment promise through claim and decline`, () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const truth = take(g, 'truthtrance'); + player(g, 'a').hand.push(truth); + Object.assign(g, { active: 'e', order: ['e', 'a', 'r'], movementRemaining: ['e', 'a', 'r'] }); + const original = inventory(g); + let state = applyAction(g, 'a', { type: 'card', card: truth.id }); + while (state.truthtrance?.stage === 'priority') { + const actor = state.players.find(p => !state.truthtrance!.passed.includes(p.id))!; + state = applyAction(state, actor.id, { type: 'truthPass' }); + } + state = applyAction(state, 'a', { type: 'truthAsk', + question: { kind: 'shipment', target: 'e', territory: 'carthag', minimum: 2 } }); + const pending = applyAction(state, 'e', { type: 'truthAnswer', answer }); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.shipmentPromises?.length, 1); + assert.equal(pending.shipmentPromises![0].answer, answer === 'yes'); + const event = pending.pendingTreacheryDiscard!.batch.event; + let declined = reload(pending); + for (const id of ['e', 'a', 'r']) + declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + for (const resumed of [declined, claimed]) { + assert.deepEqual(resumed.shipmentPromises, pending.shipmentPromises); + assert.deepEqual(resumed.truthHistory, pending.truthHistory); + assert.deepEqual(inventory(resumed), original); + const wrong = answer === 'yes' + ? { type: 'endMovement' } + : { type: 'ship', territory: 'carthag', sector: 11, amount: 2 }; + assert.throws(() => applyAction(resumed, 'e', wrong)); + const legal = answer === 'yes' + ? { type: 'ship', territory: 'carthag', sector: 11, amount: 2 } + : { type: 'endMovement' }; + const fulfilled = applyAction(resumed, 'e', legal); + assert.equal(fulfilled.shipmentPromises!.length, 1); + assert.equal(fulfilled.shipmentPromises![0].fulfilled, true); + assert.equal(fulfilled.truthHistory!.length, 1); + if (answer === 'yes') + assert.equal(player(fulfilled, 'e').forces['carthag:11'], 2); + assert.deepEqual(inventory(fulfilled), original); + } + assert.equal(player(claimed, 'r').hand.some(card => card.id === truth.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 = [];