diff --git a/docs/BOX_DISCARD_CONTINUATIONS.md b/docs/BOX_DISCARD_CONTINUATIONS.md index 921efea6..c7f0d99e 100644 --- a/docs/BOX_DISCARD_CONTINUATIONS.md +++ b/docs/BOX_DISCARD_CONTINUATIONS.md @@ -10,6 +10,13 @@ The `nullentropyDiscard` continuation records the search owner/event, activating With one eligible target, staging and retirement occur in the original paid action without another acknowledgement. Multiple targets retain the existing owner-only paid decision. A restored Harkonnen bonus may legitimately refill the deck after the completed Box frame retires; that subsequent draw does not repeat the Box search. +11 October 2026 Semuta update: the explicit Richese preview offers the used Box +after a completed paid battle search, including automatic sole-target selection. +The recovered card, payment, shuffled pile and original battle already commit. +Claim or all-seat decline restores clean planning without another charge, +search or shuffle; a sealed opposing plan remains unchanged. Competing parents +retain their existing exclusions. + ## Integrity and private information Action, projection and automatic recovery validate the one public activating Box, exact former owner, canonical card identity, unique physical custody, top position, selected card in its owner's hand and the frozen final pile. Search identity and suspended controls are bound to the unchanged live pending obligations by a serialized parent signature. This detects changed saved records; it is not cryptographic authentication against wholesale rewriting of every receipt and signature. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f9e2d871..12ff69ef 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. +**Semuta / paid battle Box — 11 October.** A completed paid Nullentropy Box +search now offers only the used Box during battle, preserving the payment, +private acquired card, shuffled pile and opposing sealed plan. The regression +fails before and passes after; types/lint and 72 affected unit cases across seven +files pass. Twenty-four isolated runtime scenarios cover +Basic/Advanced, sole/multiple-target selection, claim/pass and all four legal AI +profiles; each uses the recovered weapon in a legal plan with one two-spice +payment. Competing parents remain separate. Temporary scripts are removed; no +saved store or deployed game was targeted. Existing human controls are reused; +no new browser evidence is claimed. See +[the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Semuta / ordinary battle Ghola — 11 October.** A completed ordinary Ghola now offers its consumed card during battle. Revival, the opposing sealed plan and existing competing-parent boundaries are unchanged. The regression fails diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 0f50daac..edca35e8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,28 @@ # Dune implementation status +## 11 October 2026 — Semuta after a paid battle Box search + +The completed `nullentropyDiscard` receipt already binds the acquired card, +ordered shuffled pile and original battle. The Semuta battle-presence guard now +admits that receipt without changing search/payment rules or existing +competing-parent exclusions. Only the used Box is fresh; claiming it does not +claim the searched card or replay the transaction. + +The permanent regression fails before and passes after, covering claim/pass, +exact two-spice payment, one recovered weapon, unchanged opposing sealed plan, +discard order and card conservation. Retired reaction and paid-search replay +reject. Twenty-four isolated runtime scenarios cover both rule modes, +automatic sole-target or explicit multiple-target selection, claim/pass and +all four legal AI profiles. Each uses the recovered weapon in the next legal +plan, preserving the original opposing plan and exactly one payment. +Types/lint and 72 affected unit cases across seven files pass. + +Temporary scripts are removed; no saved store or deployed game was targeted. +Existing human Box, Semuta and planning controls are reused; this is engine +runtime proof, not new browser/deployed acceptance. Full Semuta remains open. +Contracts: [Semuta](SEMUTA_SOURCE_UPDATE_20260907.md), +[paid search continuation](BOX_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — Semuta after ordinary battle Ghola revival The completed ordinary-card receipt already binds Ghola revival and the original diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 9f873034..5076c369 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -39,6 +39,11 @@ original battle, including a sealed opposing plan. Claim or decline restores planning without reviving again or changing that plan. This does not admit competing parents or market-specific Ghola interruptions. +Completed paid Box disposal may now offer Semuta during battle as well. Only the +used Box is fresh; payment, private acquisition, shuffled pile and original +battle remain committed. Claim or decline restores clean planning without +repeating the search, shuffle or payment. Competing parents remain separate. + Clean Once Around Sapho stages `saphoAuctionDiscard` after the open lot's finite bidder reorder, hand-slot release and public card disposal, but before settlement. The saved receipt binds the lot, prior funding, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index b4498569..e85b85e3 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -245,6 +245,24 @@ 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 — paid battle Box.** A completed paid Nullentropy Box +search now offers the used Box during battle. Only the battle-presence guard +gained that receipt exception; payment, private acquisition, shuffled pile and +original battle already commit and remain unchanged. Claim or all-seat decline +restores clean planning without another search, shuffle or payment. Competing +parents retain their existing exclusions. + +The permanent regression fails before and passes after, covering claim/pass, +exact two-spice payment, one recovered weapon, unchanged opposing sealed plan, +discard order, card conservation and retired-reaction/search replay rejection. +Twenty-four isolated engine scenarios cover Basic/Advanced, automatic sole-target +or explicit multiple-target selection, claim/pass and all four legal AI profiles. +Each commits the recovered weapon in the next legal plan while preserving the +original opposing plan and exactly one payment. Temporary scripts are removed; +no saved store or deployed game was targeted. Existing human search, Semuta and +plan panels are reused; no new browser evidence is claimed at this checkpoint. +Types/lint and 72 affected unit cases across seven files pass. + **11 October 2026 — ordinary battle Ghola.** The consumed Ghola now receives the same Semuta offer during battle after its revival commits. Only the battle-presence guard gained the completed ordinary Ghola exception; revival, diff --git a/game/engine.ts b/game/engine.ts index b1b007fd..df5c4845 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6765,6 +6765,7 @@ function semutaOfferSupported( (continuation.kind === 'saphoAggressorDiscard' || continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'truthtranceDiscard' || + continuation.kind === 'nullentropyDiscard' || (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'ghola') || !g.battle) && (continuation.kind !== 'choamKulonDiscard' || diff --git a/game/reference.ts b/game/reference.ts index 2f2165c7..e2710ee1 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1174,6 +1174,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ...(definition.card.effect === 'semutaDrug' ? [ 'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.', 'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.', + 'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.', ] : []), ], ...(definition.card.effect === 'semutaDrug' diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 03f152e7..4c3a0137 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -488,6 +488,55 @@ void test('a completed paid Box discard can be claimed before resumption without assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); }); +void test('Semuta claims a battle Box after its paid search without changing the recovered weapon or sealed plan', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const box = take(g, 'richese-nullentropy-box'); + const weapon = take(g, g.deck.find(c => c.kind === 'projectile')!.id); + player(g, 'a').hand.push(box); + g.discard.push(weapon, g.deck.shift()!); + Object.assign(g, { phase: 6, active: 'a', order: ['a', 'e', 'r'], + response: null, decision: null, phaseOpening: null, movementRemaining: [] }); + for (const id of ['a', 'e']) { + player(g, id).forces = { 'arrakeen:10': 4 }; + player(g, id).reserves = 16; + } + const original = inventory(g); + const spice = player(g, 'a').spice; + let state = applyAction(g, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' }); + while (state.battle?.preparation) + state = applyAction(state, state.battle.preparation.owner, { type: 'declineBattlePower' }); + for (const id of ['a', 'e']) + state = applyAction(state, id, { type: 'battlePreparationReady', event: state.battle!.preLeader!.event }); + state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0, + leader: player(state, 'e').leaders[0].id }); + const paid = applyAction(state, 'a', { type: 'card', card: box.id }); + const search = paid.pendingNullentropy!.event; + const pending = applyAction(paid, 'a', { type: 'decision', event: search, card: weapon.id }); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const event = pending.pendingTreacheryDiscard!.batch.event; + const shuffled = pending.discard.filter(c => c.id !== box.id).map(c => c.id); + 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(player(resumed, 'a').spice, spice - 2); + assert.equal(player(resumed, 'a').hand.filter(c => c.id === weapon.id).length, 1); + assert.deepEqual(resumed.battle, state.battle); + assert.deepEqual(resumed.discard.filter(c => c.id !== box.id && c.id !== semuta.id).map(c => c.id), shuffled); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event })); + assert.throws(() => applyAction(resumed, 'a', { type: 'decision', event: search, card: weapon.id })); + const planned = applyAction(resumed, 'a', { type: 'battlePlan', dial: 1, support: 0, + leader: player(resumed, 'a').leaders[0].id, weapon: weapon.id }); + assert.equal(planned.battle!.plans.a.weapon, weapon.id); + assert.deepEqual(planned.battle!.plans.e, state.battle!.plans.e); + } + assert.equal(player(claimed, 'r').hand.some(c => c.id === box.id), true); + assert.equal(claimed.discard.some(c => c.id === semuta.id), true); +}); + void test('a retired Ornithopter can be claimed after one flight group without replaying movement', () => { const { g, semuta, hajr } = fixture(); player(g, 'a').hand = [];