From 12e4bcbf454dbab2f7ea048d40cff8f485733a12 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:37:23 +1100 Subject: [PATCH] feat: restore final CHOAM Mentat after Semuta Gamont reactions --- docs/CURRENT_STATUS.md | 14 +++++++ docs/IMPLEMENTATION_STATUS.md | 30 ++++++++++++++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 5 ++- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 38 ++++++++++++++++-- game/engine.ts | 7 +++- game/reference.ts | 1 + tests/semuta-engine.test.ts | 57 +++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e0cb916e..c3ea9ff1 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 / CHOAM final Mentat — 11 October.** Gamont disposal in the native +final opportunity now offers Semuta, then restores the same CHOAM-owned window. +Claim/pass returns the selected force but never checks victory or advances +the turn; the original owner must still finish. The regression fails before +and passes after, with a native baseline that would give Atreides victory +without the recall. Twelve runtime scenarios cover both modes, claim/pass and +four legal AI profiles, including owner-window completion. +Types/lint and 82 affected unit cases across six files pass. +Real GameTable at 390px performs Return this force → Commit Semuta → restored +Finish and check victory → turn3/Storm, still playing, without overflow. +Temporary QA scaffolds are removed; no saved store or deployed game was targeted. +Nexus and other parents remain separate. See +[the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Semuta / typed and concealed Gamont — 11 October.** A clean proactive discard now preserves the selected elite counter or native No-Field event, instead of hard-coding an ordinary return. The original native helper performs diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c22c4719..bafaa6e1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 11 October 2026 — original final Mentat after Semuta/Gamont + +The Gamont receipt now carries an optional final-Mentat marker and signs the +native pending opportunity. After the selected physical return, claim/pass +restores the same `choamMentat` decision for CHOAM. No victory check or phase +advance runs inside the receipt; the original owner must still finish. +Unmarked proactive signatures remain unchanged. + +The regression fails before and passes after. Actual native market, readiness +and done actions create the closing window. Its unopposed board gives Atreides +a three-stronghold victory; recall removes Arrakeen before the same owner can +finish. Claim/pass preserves turn2/Mentat, exact force return and cards; +wrong-owner completion and retired reactions reject. CHOAM's actual Done then +advances to turn3/Storm with no winner. The fixture initially checked phase +before native market completion and placed Tabr in sector13; corrected +progression and canonical sector14 required no production rule change. + +Twelve isolated runtime scenarios cover Basic/Advanced, claim/pass and all +four legal AI profiles, including each profile finishing the restored owner +window. Real production GameTable at 390px performs Return this force, Semuta +commitment, the restored Finish and check victory and actual turn3/Storm +continuation, without horizontal overflow. +Types/lint and 82 affected unit cases across six files pass. + +Temporary scripts, isolated server and browser tab are removed/stopped; no +saved store or deployed game was targeted. Nexus and other competing parents +remain separate. Full Semuta/CHOAM and deployed acceptance remain open. +Contracts: [source/runtime](SEMUTA_SOURCE_UPDATE_20260907.md), +[discard suffix](SEMUTA_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — typed and concealed Gamont with Semuta The existing receipt already signs target elite and No-Field custody, but the diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 4b24d63f..4c2c25b2 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -144,13 +144,14 @@ ordinary declared move is already stopped without spending its allowance. Nexus, movement-card/Ambassador and overlapping arrival parents retain their automatic paths. -Clean proactive Trip to Gamont stages `choamGamontDiscard` after the accepted +Clean proactive or final-Mentat Trip to Gamont stages `choamGamontDiscard` after the accepted public card cost but before its selected ordinary/elite return or native No-Field reveal and capped recall. The receipt binds location, exact counter type/marker event, target force/reserve/elite/No-Field custody and CHOAM hand. Claim or decline completes the original selection once without replaying the response. A zero marker reveals without inventing a force; revival usage stays -unchanged. Closing Mentat, Nexus and competing parents retain automatic paths. +unchanged. A final-Mentat marker restores the same CHOAM-owned decision before +victory or turn advancement; Nexus and competing parents retain automatic paths. Accepted printed Jubba Cloak stages `choamJubbaDiscard` after its public Worthless card cost but before protecting CHOAM's diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index b2131351..83c44e14 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -149,12 +149,13 @@ legal. A clean ordinary declared move is already stopped without spending a movement. Movement-card/Ambassador parents, Nexus conversion and competing arrivals retain their original automatic paths. -A clean proactive Trip to Gamont response pauses after its public card cost, +A clean proactive or final-Mentat Trip to Gamont pauses after its public card cost, before returning the selected ordinary/elite physical force or performing the native concealed No-Field reveal and capped recall. The receipt binds target, location, exact counter type or marker event and pre-return custody. Claim or decline applies the original selection once; a zero marker reveals without -inventing a force. Closing Mentat, Nexus and competing parents remain automatic. +inventing a force. Final Mentat restores the same owner decision before victory; +Nexus and competing parents remain automatic. An accepted printed Jubba Cloak response pauses after its public Worthless card cost but before the chosen CHOAM territory @@ -247,6 +248,34 @@ 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 — original final-Mentat continuation.** Gamont disposal +inside CHOAM's final opportunity now offers Semuta too. The receipt signs an +optional `mentat` marker and the still-pending native opportunity. Claim or +all-seat decline performs the original selected return, then restores +`choamMentat` for the same CHOAM owner. It never checks victory or advances the +turn itself; the original owner still chooses Finish and check victory. +Proactive receipt signatures are unchanged when this marker is absent. + +The regression fails before and passes after. Genuine native Collection/Mentat +market, readiness and done actions create the final window. Without Gamont, +the same closing board gives Atreides a three-stronghold victory. With the +return and claim/pass, one force leaves Arrakeen, the same owner window remains +on turn2/Mentat, wrong-owner completion and retired reaction reject, and only +CHOAM's Done advances to turn3/Storm with no winner. The fixture first placed +its phase assertion before the market completion and used the wrong Tabr +sector; corrected actual progression and canonical sector14 require no rule +change. + +Twelve isolated runtime scenarios cover Basic/Advanced, claim/pass and all four +legal AI profiles; the four profiles also complete the restored owner window. +Real GameTable at 390px uses Return this force, Commit Semuta, the restored +Finish and check victory control and actual turn3/Storm continuation. The game +stays playing and has no horizontal overflow. Temporary scripts/server/browser +are removed or stopped; no saved store or deployed game was targeted. +Types/lint and 82 affected unit cases across six files pass. +Nexus and competing parents remain separate; no complete or deployed acceptance +is claimed. + **11 October 2026 — elite and concealed Gamont targets.** The existing receipt already signed target elite and No-Field custody, but stored neither selection and resumed with a literal ordinary-force argument. It now carries @@ -276,8 +305,9 @@ Types/lint and 163 affected unit cases across nine files pass. Optional signature fields now serialize directly, and reactive stopped markers are assigned without temporary spread objects. Absent optional fields are omitted by JSON, preserving the prior ordinary/proactive signature shape. -Closing Mentat, Nexus and competing parents remain separate. This is bounded -counter-selection coverage, not complete Semuta/CHOAM or deployed acceptance. +Closing Mentat remained separate at this counter-selection checkpoint; the +later continuation above admits it. Nexus and competing parents remain separate. +This is bounded coverage, not complete Semuta/CHOAM or deployed acceptance. **11 October 2026 — reactive Baliset and La La La.** Accepted native declarations now offer their used public card in the clean reactive paths too. diff --git a/game/engine.ts b/game/engine.ts index d001d099..f16a4a2a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1399,6 +1399,7 @@ export type Game = { location: string; elite?: 1; noFieldEvent?: string; + mentat?: true; stateSignature: string; } | { @@ -5259,6 +5260,7 @@ function choamGamontDiscardSignature(g: Game, c: ChoamGamontDiscardContinuation) return JSON.stringify({ player: c.player, card: c.card, target: c.target, location: c.location, elite: c.elite, noFieldEvent: c.noFieldEvent, + mentat: c.mentat, mentatPending: c.mentat ? g.choamMentatPending : undefined, turn: g.turn, phase: g.phase, active: g.active, order: g.order, ready: g.ready, storm: g.storm, parent: nullentropyParentSignature(g, @@ -5972,6 +5974,7 @@ function treacheryDiscardIntegrity(g: Game) { (c.elite === undefined || c.elite === 1) && (c.noFieldEvent === undefined || (typeof c.noFieldEvent === 'string' && c.noFieldEvent.length > 0)) && + (c.mentat === undefined || (c.mentat === true && g.choamMentatPending === true)) && gamontAvailable(g, c.target, c.location, c.elite ?? 0, c.noFieldEvent) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.choamMarket && @@ -6966,6 +6969,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string } else if (next.kind === 'choamGamontDiscard') { completeChoamGamont(g, getPlayer(g, next.player), next.target, next.location, next.elite ?? 0, next.noFieldEvent); + if (next.mentat) g.decision = { kind: 'choamMentat', player: next.player }; } else if (next.kind === 'choamJubbaDiscard') { completeChoamJubba(g, getPlayer(g, next.player), next.territory); offerChoamStorm(g); @@ -24059,7 +24063,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) } else if (pending.effect === 'gamont') { if (g.semutaPreview && used.name === 'Trip to Gamont' && !pending.storm && !pending.revival && !pending.movement && - !pending.mentat && !pending.nexusEvent) { + !pending.nexusEvent) { const continuation: ChoamGamontDiscardContinuation = { kind: 'choamGamontDiscard', player: choam.id, card: used.id, target: pending.target!, @@ -24067,6 +24071,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) }; if (pending.elite === 1) continuation.elite = 1; if (pending.noFieldEvent) continuation.noFieldEvent = pending.noFieldEvent; + if (pending.mentat) continuation.mentat = true; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamGamontDiscardSignature(g, continuation); diff --git a/game/reference.ts b/game/reference.ts index bd2ff07f..b7028442 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1178,6 +1178,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', + '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.', 'The same local semuta entry also admits Basic native Richese and Moritani with classic opponents, exactly CHOAM/Ecaz decks and no optional modules. A clean shipment/movement Sabotage or Robbery overflow offers its private fresh discard without showing the face before commitment. Claim or Continue preserves the original victim/draw, removed token, shipment payment and forces; Sabotage may then offer its original optional gift. Advanced Moritani, native Ecaz, Ix/E3 mixtures and worm/Ambassador/overlap arrivals remain outside this bounded entry. The table visibly labels the development preview.', ] : []), ], diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index e5607600..7b7830f6 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -53,6 +53,63 @@ function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emp return { g, semuta, hajr }; } +void test('Semuta restores closing CHOAM Mentat after Gamont before the native victory check', () => { + const { g, semuta } = fixture(false, false, false, 'choam'); + const card = take(g, g.deck.find(c => c.name === 'Trip to Gamont')!.id); + player(g, 'e').hand.push(card); + for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0 }); + Object.assign(player(g, 'a'), { forces: { + 'arrakeen:10': 1, 'carthag:11': 1, 'sietch_tabr:14': 1, + }, reserves: 17 }); + Object.assign(g, { phase: 7, active: null, movementRemaining: [], ready: [] }); + const original = inventory(g); + let state = g; + for (const id of ['a', 'e', 'r']) state = applyAction(state, id, { type: 'ready' }); + state = applyAction(state, 'e', { type: 'decision', done: true }); + assert.equal(state.phase, 8); + assert.equal(state.status, 'playing'); + for (const id of ['a', 'e', 'r']) state = applyAction(state, id, { type: 'ready' }); + state = applyAction(state, 'e', { type: 'decision', done: true }); + assert.equal(state.decision?.kind, 'choamMentat'); + assert.equal(state.status, 'playing'); + const unopposed = applyAction(state, 'e', { type: 'decision', done: true }); + assert.equal(unopposed.status, 'finished'); + assert.deepEqual(unopposed.winner, ['a']); + let pending = applyAction(state, 'e', { type: 'card', mode: 'choam', card: card.id, + target: 'a', from: 'arrakeen:10', elite: 0 }); + while (pending.response) { + const actor = pending.players.find(p => !pending.response!.passed.includes(p.id))!; + pending = applyAction(pending, actor.id, { type: 'passResponse' }); + } + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(player(pending, 'a').forces['arrakeen:10'], 1); + assert.equal(pending.status, 'playing'); + 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.decision?.kind, 'choamMentat'); + assert.equal(resumed.decision?.player, 'e'); + assert.equal(resumed.turn, 2); + assert.equal(resumed.phase, 8); + assert.equal(resumed.status, 'playing'); + assert.deepEqual(player(resumed, 'a').forces, { 'carthag:11': 1, 'sietch_tabr:14': 1 }); + assert.equal(player(resumed, 'a').reserves, 18); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'a', { type: 'decision', done: true })); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event })); + const next = applyAction(resumed, 'e', { type: 'decision', done: true }); + assert.equal(next.status, 'playing'); + assert.equal(next.turn, 3); + assert.equal(next.phase, 0); + assert.deepEqual(next.winner, []); + assert.deepEqual(inventory(next), original); + } + assert.equal(player(claimed, 'r').hand.some(c => c.id === card.id), true); + assert.equal(claimed.discard.some(c => c.id === semuta.id), true); +}); + void test('Semuta preserves one elite Gamont return without spending revival usage', () => { const { g, semuta } = fixture(false, false, true, 'choam', 'emperor'); const card = take(g, g.deck.find(c => c.name === 'Trip to Gamont')!.id);