From 0e9945e333716a1b5b6f9bab6fde7437b017e644 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:25:22 +1000 Subject: [PATCH] Compose Semuta with completed paid Box discard --- docs/CURRENT_STATUS.md | 13 ++++---- docs/IMPLEMENTATION_STATUS.md | 30 +++++++++++++++++ docs/RICHESE_COMPONENTS.md | 7 ++-- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 19 ++++++----- game/card-presentation.ts | 4 +-- game/engine.ts | 9 +++--- game/reference.ts | 8 ++--- tests/semuta-engine.test.ts | 46 +++++++++++++++++++++++++++ tests/semuta-recovery.test.ts | 38 +++++++++++++++++++++- 9 files changed, 146 insertions(+), 28 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a07560e2..04cefae4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -7,14 +7,15 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) -now pauses a clean public ordinary-card discard with the same neutral -offer for every seat, independently of hidden Semuta possession. A -holder with a free hand slot can commit to the one fresh other-player -card; its physical claim and Semuta disposal resume the original -effect once. All-seat decline, four minimal legal AI profiles, exact +now pauses a clean public ordinary-card discard or the used Box after +a completed paid search with the same neutral offer for every seat, +independently of hidden Semuta possession. A holder with a free hand +slot can commit to the one fresh other-player card; its physical claim +and Semuta disposal resume the original effect or Box parent once. +All-seat decline, four minimal legal AI profiles, exact JSON/SQLite continuation and competing-room CAS are connected. Phone width showed the owned Commit control and the identical rival Continue -event without a private card face. Multi-card and other discard +event without a private card face. Multi-card and most other discard producers, full-hand ordering, normal Richese starts and deployed acceptance remain unfinished; the neutral passes and free-slot guard are product safety choices, not publisher timing rulings. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index de8ff902..be0b6a11 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 30 September 2026 — Semuta after a completed paid Box search + +The explicit Richese Semuta preview now opens the same neutral +all-seat response when a clean paid Nullentropy Box search finishes +and places its used physical Box on the discard pile. The search +owner has already paid two spice, privately recovered its chosen +card and shuffled the remaining pile once. Another holder may commit +Semuta and take only the freshly discarded Box; declining preserves +the Box on top. Neither path re-pays, reselects, reshuffles or +reopens the search. Older discard cards and the privately recovered +card are not fresh targets. + +Genuine-setup engine cases verify both claim and all-pass paths, +exact card census and discard order, every-seat JSON restoration +and stable two-spice payment. An isolated migrated SQLite room +restores the Box reaction after the paid selection, leaves automatic +recovery idle until a real choice and commits the claim through +authenticated seat control without changing saved seat rows. +Independent privacy and continuation reviews found no scoped issue. +This extends the previous phone-width neutral control surface; no +new browser POST was sent. Auction/CHOAM-market overlaps, multiple +fresh cards, other producer families and full-hand ordering remain +gated. This is not a complete Semuta, Richese or deployed claim. + +`npm run check` passes typecheck, lint and 5,802/5,802 offline cases; +`npm run build` and the 55/55 local development HTTP cases pass. +The phone-width rules deep link shows the updated Box target boundary +and partial checklist. No authenticated production Box/Semuta play or +deployed revision was exercised. + ## 30 September 2026 — neutral Semuta clean-discard prototype The [bounded runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index 1dc5dccb..b94cd82b 100644 --- a/docs/RICHESE_COMPONENTS.md +++ b/docs/RICHESE_COMPONENTS.md @@ -112,6 +112,7 @@ supersedes the older inactive-card observations above for one explicitly opted-in Richese development profile. Neutral all-seat responses, physical one-card recovery, a provisional free-slot guard, minimal legal AI and saved continuation now work after clean public -ordinary-card discards. The other semantic discard families and -full-hand ordering are not connected; the ten-card inventory, cache -custody and public expansion start gate remain unchanged. +ordinary-card discards and the used Box after a completed paid search. +Other semantic discard families and full-hand ordering remain +unconnected; the ten-card inventory, cache custody and public expansion +start gate are unchanged. diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 43682bf1..b0211686 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -63,17 +63,18 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta The explicit `semuta` development profile starts a fresh Richese roster without opening ordinary expansion starts. It now interrupts **one public -ordinary-card discard** from a clean transaction with a neutral -fresh-discard opportunity for every seat, regardless of hidden Semuta +ordinary-card discard or the used Box after a completed paid Nullentropy +search**, provided the parent transaction is clean, with a neutral +fresh-discard opportunity for every seat regardless of hidden Semuta possession. Each seat can Continue; only the canonical holder with a free hand slot and a card discarded by another player can commit. The sole eligible fresh card transfers automatically in the same accepted action, then Semuta goes to the normal discard pile. The saved ordinary -card effect resumes once; passing by every seat preserves the original -discard and effect. No face from an undisclosed event is projected before -commitment. Owner-only multi-candidate selection support exists in the -reaction component and bot adapter, but no multi-discard producer is -activated in this first boundary. +card effect or Box parent resumes once; passing by every seat preserves +the original discard and effect. No face from an undisclosed event is +projected before commitment. Owner-only multi-candidate selection +support exists in the reaction component and bot adapter, but no +multi-discard producer is activated in this bounded runtime. This neutral all-seat acknowledgement is a conservative **application policy**, not a newly discovered publisher timing rule. The explicit @@ -81,7 +82,9 @@ free-slot requirement is a provisional safety guard, not the printed resolution of full-hand take-before-discard. Auction, CHOAM market, pending gifts, competing responses and other typed discard producers retain their existing automatic continuation; Semuta cannot claim those -events yet. No source ruling is inferred from this omission. The +events yet. The completed Box's selected search card stays private and +its paid shuffle is not repeated when another seat takes the used Box. +No source ruling is inferred 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. diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 86e355bb..c28d2f4a 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. Only clean public ordinary-card discards are connected; other discard events remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean public ordinary-card discards and the used Box after a completed paid search are connected; other discard events remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; @@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation { richese.card.effect === 'juiceOfSapho' ? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.' : richese.card.effect === 'semutaDrug' - ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.' + ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards and completed paid Box disposal, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.' : richese.card.effect === 'mirrorWeapon' ? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.' : richese.card.effect === 'stoneBurner' diff --git a/game/engine.ts b/game/engine.ts index a9c56d9d..4ec63b4c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5642,17 +5642,18 @@ function treacheryDiscardIntegrity(g: Game) { 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); } -/** First playable Semuta boundary: one public ordinary-card discard with no - * competing suspended transaction. Other typed frames retain their existing - * automatic continuation until their reaction composition is integrated. */ +/** Bounded public single-card reaction after a completed ordinary effect or + * paid Box search, without any competing suspended transaction. Other typed + * frames retain automatic continuation until their composition is integrated. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], entries: FreshDiscardBatch['entries'], ) { + if (continuation.kind !== 'ordinaryCardDiscard' && + continuation.kind !== 'nullentropyDiscard') return false; return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && - continuation.kind === 'ordinaryCardDiscard' && entries.length === 1 && entries[0].publicFace && !continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && diff --git a/game/reference.ts b/game/reference.ts index 7ad7e34e..6420f516 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -941,7 +941,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.', 'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.', 'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.', - 'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and Semuta response to the used Box remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug has a separate clean public ordinary-card preview, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.', + 'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation and the provisional Guild refund interaction remain unresolved. In the explicit Semuta preview, another holder may recover the used Box after a clean completed search without replaying payment or shuffle; other Semuta discard sources remain unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.', ], related: [ 'card-richese-nullentropy-box', @@ -1155,7 +1155,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 an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically. Other discard producers, full-hand sequencing and normal Richese starts remain unfinished.' + ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard or the used Box following a completed paid search. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically. Other discard producers, full-hand sequencing and normal Richese starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1165,7 +1165,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'An explicitly opted-in Richese profile now interrupts clean public ordinary-card discards with a neutral all-seat offer. Committing a held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card suffix once. Other typed discard producers and full-hand exchange remain unfinished.', + 'An explicitly opted-in Richese profile interrupts clean public ordinary-card and completed paid Box discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card or Box parent once. Other typed discard producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1189,7 +1189,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover the bounded ordinary-card reaction. Other discard producers, full games and live deployment remain unverified.', + 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card and paid Box reactions. Other discard producers, full games and live deployment remain unverified.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 2b401abb..84a31a81 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -166,3 +166,49 @@ void test('an impossible saved all-passed offer rejects instead of waiting forev assert.throws(() => normalizeAutomaticGame(corrupt)); assert.equal(JSON.stringify(corrupt), before); }); + +void test('a completed paid Box discard can be claimed before resumption without repeating its search', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const box = take(g, 'richese-nullentropy-box'); + player(g, 'a').hand.push(box); + g.discard.push(g.deck.shift()!, g.deck.shift()!); + g.phase = 4; + g.active = null; + const original = inventory(g); + const spice = player(g, 'a').spice; + const paid = applyAction(g, 'a', { type: 'card', card: box.id }); + assert.ok(paid.pendingNullentropy); + assert.equal(player(paid, 'a').spice, spice - 2); + const target = paid.discard[0].id; + const pending = applyAction(paid, 'a', { + type: 'decision', event: paid.pendingNullentropy.event, card: target, + }); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const event = pending.pendingTreacheryDiscard!.batch.event; + const shuffled = pending.discard.filter(card => card.id !== box.id).map(card => card.id); + assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); + assert.equal(viewGame(pending, 'a').semutaReaction?.canCommit, false); + assert.deepEqual(viewGame(pending, 'r').semutaReaction?.candidates, []); + assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending)); + let declined = reload(pending); + for (const id of ['r', 'e', 'a']) declined = applyAction(declined, id, { type: 'semutaPass', event }); + assert.equal(declined.pendingTreacheryDiscard, null); + assert.deepEqual(declined.discard, pending.discard); + assert.equal(player(declined, 'a').spice, spice - 2); + assert.equal(player(declined, 'a').hand.filter(card => card.id === target).length, 1); + assert.deepEqual(inventory(declined), original); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(player(claimed, 'a').spice, spice - 2); + assert.equal(player(claimed, 'a').hand.filter(card => card.id === target).length, 1); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === box.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.deepEqual(claimed.discard.filter(card => card.id !== semuta.id).map(card => card.id), shuffled); + assert.deepEqual(inventory(claimed), original); + for (const p of claimed.players) + assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); + assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index f5f0ae98..d4a3ae20 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -16,7 +16,7 @@ function take(g: Game, identity: string) { throw new Error('The genuine setup lost a physical card.'); } -void test('authenticated fresh discard survives restart and duplicate Semuta commits transfer one physical card', async () => { +void test('authenticated ordinary and paid-Box discards preserve one Semuta claim through restart and room CAS', async () => { const f = unitStore(); try { const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']); @@ -93,5 +93,41 @@ void test('authenticated fresh discard survives restart and duplicate Semuta com assert.equal(new Set(cards.map(card => card.id)).size, cards.length); assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(done, auth.playerId)); + + const next = structuredClone(done); + const usedSemuta = take(next, SEMUTA_DRUG_ID); + const box = take(next, 'richese-nullentropy-box'); + seat(next, ids[0]).hand.push(usedSemuta); + seat(next, ids[1]).hand.push(box); + const older = [next.deck.shift(), next.deck.shift()]; + assert.ok(older[0] && older[1]); + next.discard.push(older[0], older[1]); + Object.assign(next, { turn: 3, phase: 4, active: null, ready: [], decision: null, + response: null, phaseOpening: null, stormPending: null }); + next.version = done.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(next), next.version, code, done.version).changes, 1); + const beforeSpice = seat(next, ids[1]).spice; + const paid = await act(1, { type: 'card', card: box.id }); + assert.ok(paid.pendingNullentropy); + const selected = paid.discard[0].id; + const offered = await act(1, { type: 'decision', event: paid.pendingNullentropy.event, card: selected }); + assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, offered.version); + const claimedBox = await act(0, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }); + assert.equal(claimedBox.pendingTreacheryDiscard, null); + assert.equal(seat(claimedBox, ids[1]).spice, beforeSpice - 2); + assert.equal(seat(claimedBox, ids[1]).hand.filter(card => card.id === selected).length, 1); + assert.equal(seat(claimedBox, ids[0]).hand.filter(card => card.id === box.id).length, 1); + assert.equal(claimedBox.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(claimedBox.discard.some(card => card.id === box.id), false); + const finalCards = [...claimedBox.deck, ...claimedBox.discard, ...claimedBox.richeseCache!, + ...claimedBox.players.flatMap(p => p.hand)]; + assert.equal(new Set(finalCards.map(card => card.id)).size, finalCards.length); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimedBox, auth.playerId)); } finally { f.sqlite.close(); } });