diff --git a/docs/BATTLE_DISCARD_CONTINUATIONS.md b/docs/BATTLE_DISCARD_CONTINUATIONS.md index e5edcdf3..2acef1e6 100644 --- a/docs/BATTLE_DISCARD_CONTINUATIONS.md +++ b/docs/BATTLE_DISCARD_CONTINUATIONS.md @@ -23,6 +23,12 @@ private opportunity; native replacement runs once and does not undo a Semuta claim. Face Dancer cards remain their separate physical deck, never Treachery discard targets. Other pending reward exclusions are unchanged. +Quoted Advanced CHOAM force-payment income now also survives those offers. +The native quote remains unpaid through cleanup; its original allow/Karama +response credits once or redirects income to bank without a payer refund. +The original CHOAM market and following phase opening still run afterward. +Auditor, tech and other reward exclusions remain unchanged. + Sequence retirement precedes the remaining work. Ordinary public actions still drain these saved steps automatically. Test-only observation of unchanged private production dispatch captures real intermediate frames; this is not a public Semuta reaction window. ## Integrity, privacy and multiplayer diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 083ab8d3..0c436a36 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,21 @@ 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 / quoted CHOAM battle income — 11 October.** Mandatory and winner +discard offers now preserve the original Advanced force-payment quote unpaid +through cleanup, then restore its native allow/Karama response. Allow credits +once; cancellation redirects income without refunding paid support. +The regression fails before and passes after; types/lint and 119 affected unit +cases across eight files pass. Twenty-four runtime scenarios +cover both producers, claim/pass, four legal Semuta AI profiles and allow/cancel, +then finish the original market without another credit. +Real GameTable at 390px claims Crysknife, restores the actual one-spice response, +credits CHOAM2→3 and finishes the market to phase7's Ix opening with spice still3. +Two eligible paid support spice yield one income under the unchanged native +quote. No overflow; other reward exclusions remain. +Temporary QA scaffolds are removed; no saved store or deployed game was targeted. +See [the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Semuta / native Face Dance — 11 October.** Mandatory and winner battle discards now offer Semuta while native Tleilaxu replacement remains pending. Original casualties and card cleanup still precede the same private opportunity; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f5ac11db..10c83985 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,37 @@ # Dune implementation status +## 11 October 2026 — quoted CHOAM income after Semuta battle discards + +The existing battle receipt validates the native CHOAM owner and positive +income quote. Removing only that reward exclusion admits mandatory and winner +discard reactions without changing force payments, quote calculation or +aftermath ordering. Claim/pass preserves the unpaid quote through casualties +and winner cleanup, then opens its original allow/Karama response. Allow pays +once; cancellation redirects income to bank without a payer refund. + +The regression fails before and passes after, covering both producers, +claim/pass, exact quote preservation, allow/cancel, unchanged already-paid +balance and casualties, card conservation and retired reaction rejection. +Twenty-four isolated runtime scenarios cover both producers with claim/pass +and four legal Semuta AI profiles, followed by allowance and cancellation. +The native quote gives one income for the two eligible paid support spice; +the already-settled payer balance stays12. + +An additional suffix observation confirmed the original phase6 CHOAM market +remains after income; it is not a stall or immediate Collection. Every extended +scenario finishes that actual market to phase7's Ix opening with no second +credit. Real production GameTable at 390px claims Crysknife, restores the quoted +one-spice response and native Allow/Cancel controls, allows CHOAM2→3, then +finishes the original market with spice still3. Claimed custody persists and +there is no horizontal overflow. +Types/lint and 119 affected unit cases across eight files pass. + +Temporary scripts, isolated server and browser tab are removed/stopped; no +saved store or deployed game was targeted. Auditor, tech and other rewards +remain separate. Full CHOAM/Semuta and deployed acceptance remain open. +Contracts: [Semuta](SEMUTA_SOURCE_UPDATE_20260907.md), +[battle ordering](BATTLE_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — native Face Dance after Semuta battle discards The battle receipt already validates pending native Face Dance against the diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 6c1e41b9..41ad09a6 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -45,6 +45,12 @@ decline; original casualties and winner cleanup still precede it. Native replacement runs once, and the claimed Treachery card remains held afterward. Face Dancer cards are not Treachery cards and never enter the fresh batch. +Quoted Advanced CHOAM force-payment income may also remain pending across +mandatory/winner discard offers. The original owner/amount stays unpaid through +cleanup, then its native allow/Karama response resumes. Allow credits once; +cancellation redirects only the income and does not refund force payments. +The original post-battle CHOAM market still finishes before the next phase. + Completed ordinary Ghola disposal may now offer Semuta during battle as well. Revival has already committed; the receipt binds the returned leader/forces and original battle, including a sealed opposing plan. Claim or decline restores @@ -185,8 +191,8 @@ winning-card cleanup uses a separate `winnerMandatoryDiscard` batch, and an optional winner selection uses a third `battleCleanup` batch. Every supported stage has a neutral all-seat offer; committed holders select one fresh card, leave the others in the pile and resume exactly -once. Native Advanced Harkonnen capture and native Face Dance remain pending -across the offer; Auditor, tech and CHOAM rewards remain automatic without Semuta. +once. Native capture, Face Dance and quoted CHOAM force income remain pending +across the offer; Auditor, tech and other rewards remain automatic without Semuta. The explicit Richese preview also accepts a fresh physical Ix deck. Clean Thumper disposal stages `thumperDiscard` before `blowSpice` diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 0bdbcacc..bf882f2f 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -237,9 +237,9 @@ 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, -pending battle rewards other than native Harkonnen capture/Face Dance and other -typed producers retain automatic continuation; Semuta cannot claim those events -yet. +pending battle rewards other than native capture, Face Dance or quoted CHOAM +income and other typed producers retain automatic continuation; Semuta cannot +claim those 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 @@ -248,13 +248,42 @@ 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 — quoted CHOAM force-payment income.** Mandatory and +winner discard batches now offer Semuta while the native Advanced income quote +remains pending. Only that reward exclusion changed. The existing receipt +validates the original owner and positive amount; costs and income calculation +are untouched. Claim/pass leaves it unpaid through casualties and winner cleanup, +then restores the original allow/Karama response. Allow pays once; cancellation +sends that income to bank without refunding the original force payments. + +The regression fails before and passes after, covering both producers, +claim/pass, preserved quote, allow/cancel, payer/casualty/card conservation and +retired-event rejection. The original quote rounds down half of each eligible +payment: this fixture's two paid support spice yield one income, not two. +Twenty-four runtime scenarios cover both producers, claim/pass and four legal +Semuta AI profiles followed by both allowance and printed-Karama cancellation. +The already-settled payer balance stays12 in both branches. The native phase6 +CHOAM market finishes afterward; no second credit occurs before phase7's Ix +opening. + +Real GameTable at 390px claims Crysknife, shows the actual one-spice unpaid +income with Allow/Cancel controls, allows it once (CHOAM spice2→3), then finishes +the original market and reaches the phase7 opening with spice still3. Claimed +card custody persists; there is no horizontal overflow. Temporary scripts, +isolated server and browser are removed/stopped; no saved store or deployed +game was targeted. Auditor, tech and other rewards remain separate. This is +bounded income continuation proof, not complete CHOAM/Semuta or deployed +acceptance. +Types/lint and 119 affected unit cases across eight files pass. + **11 October 2026 — native Face Dance suffix.** Mandatory and winner battle discard batches now offer Semuta with native Tleilaxu Face Dance pending. Only that reward exclusion changed. The existing battle receipt already binds the pending native owner, original winner and territory. Claim/pass preserves casualties and winner-card cleanup before the same private opportunity; it cannot replace an army early, repeat replacement or move the claimed card back. -Auditor, tech, CHOAM income and other reward exclusions remain separate. +CHOAM income was separate at this checkpoint; its later continuation above now +admits it. Auditor, tech and other reward exclusions remain separate. The permanent regression fails before and passes after, covering both discard producers, claim/pass, one replacement, original leader death, force and card diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 3cb235d9..2fae7dcb 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. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture or Tleilaxu Face Dance; original cleanup and the same opportunity resume once. A Basic Richese/Moritani CHOAM/Ecaz profile reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture, Tleilaxu Face Dance or quoted CHOAM force income; original cleanup and the same opportunity resume once. A Basic Richese/Moritani CHOAM/Ecaz profile reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses 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, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture or Tleilaxu Face Dance) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.' + ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture, Tleilaxu Face Dance or quoted CHOAM force income) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses 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 fad597e9..0245babe 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6654,7 +6654,7 @@ function treacheryDiscardIntegrity(g: Game) { } else throw new RuleError('Unknown saved discard continuation.'); } /** Bounded public single-card reaction after completed effects. Battle cleanup - * may preserve native Harkonnen capture and Face Dance; other rewards remain gated. + * may preserve native capture, Face Dance and CHOAM income; other rewards remain gated. * Final flight arrival may resume native Intrusion, but other entry responses remain gated. */ function semutaOfferSupported( g: Game, @@ -6703,7 +6703,6 @@ function semutaOfferSupported( continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved') && (g.pendingAuditor || g.pendingTech || - g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; if (continuation.kind === 'terrorDiscard' && diff --git a/game/reference.ts b/game/reference.ts index ddc94e3e..fb130abc 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.', 'Mandatory and winner battle discards also pause before native Tleilaxu Face Dance. Claim or Continue preserves the original winner’s casualties and card cleanup, then restores the same private Face Dancer opportunity. Replacement, leader death and force sources remain native; the claimed Treachery card stays with its claimant, and no Face Dancer card becomes a Semuta target.', + 'Mandatory and winner battle discards also pause before quoted Advanced CHOAM force-payment income. Claim or Continue leaves the original amount unpaid through casualties and card cleanup, then opens the same allow/Karama response. Allow pays once; cancellation sends that income to the bank without refunding the original force payments. The original post-battle market still completes afterward.', '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.', '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.', diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 34946ade..ff3503b0 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -4,7 +4,7 @@ import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPla import { botActions } from '../game/bots'; import { fighterCount } from '../game/advisors'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; -import { spiceDeck } from '../game/cards'; +import { spiceDeck, isAuditorLeader } from '../game/cards'; import { saphoAggressorGame, aggressorAction, prepareAggressorPlans } from './fixture-sapho-aggressor'; import { takeSaphoBattleCard } from './fixture-sapho-battle-order'; import { createTerrorState, placeTerror } from '../game/moritani-terror'; @@ -53,6 +53,87 @@ function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emp return { g, semuta, hajr }; } +void test('Semuta preserves quoted CHOAM battle income through original allowance or cancellation', () => { + const { g, semuta, hajr } = fixture(true, false, true, 'choam'); + player(g, 'a').hand = []; + g.deck.push(hajr); + const weapon = take(g, g.deck.find(c => c.kind === 'projectile')!.id); + const defense = take(g, g.deck.find(c => c.kind === 'snooper')!.id); + const karama = take(g, 'karama'); + player(g, 'a').hand.push(weapon); + player(g, 'e').hand.push(defense); + player(g, 'r').hand.push(karama); + for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0 }); + for (const id of ['a', 'e']) Object.assign(player(g, id), { forces: { 'arrakeen:10': 5 }, reserves: 15 }); + Object.assign(g, { phase: 6, active: 'a', order: ['a', 'e', 'r'], + response: null, decision: null, phaseOpening: null, movementRemaining: [], ready: [] }); + const original = inventory(g), beforeIncome = player(g, 'e').spice; + let state = applyAction(g, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' }); + for (let step = 0; step < 40; step++) { + if (state.response) { + const actor = state.players.find(p => !state.response!.passed.includes(p.id))!; + state = applyAction(state, actor.id, { type: 'passResponse' }); + } else if (state.battle?.preparation) + state = applyAction(state, state.battle.preparation.owner, { type: 'declineBattlePower' }); + else if (state.decision?.kind === 'fullPlanOffer') + state = applyAction(state, state.decision.player, { type: 'decision', decline: true }); + else break; + } + for (const id of ['a', 'e']) + state = applyAction(state, id, { type: 'battlePreparationReady', event: state.battle!.preLeader!.event }); + state = applyAction(state, 'a', { type: 'battlePlan', dial: 2, support: 2, + leader: player(state, 'a').leaders[0].id, weapon: weapon.id }); + state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0, + leader: player(state, 'e').leaders.find(l => !isAuditorLeader(l))!.id, defense: defense.id }); + for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false }); + assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(state.pendingChoamBattleIncome, { owner: 'e', amount: 1 }); + assert.equal(player(state, 'e').spice, beforeIncome); + const paidBalance = player(state, 'a').spice; + const pass = (game: Game) => { + const event = game.pendingTreacheryDiscard!.batch.event; + for (const id of ['a', 'e', 'r']) game = applyAction(game, id, { type: 'semutaPass', event }); + return game; + }; + const winner = applyAction(pass(reload(state)), 'a', { type: 'decision', discard: [weapon.id] }); + assert.equal(winner.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + for (const pending of [state, winner]) { + const event = pending.pendingTreacheryDiscard!.batch.event; + const fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id; + const declined = pass(reload(pending)); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + for (let resumed of [declined, claimed]) { + const took = resumed === claimed; + if (resumed.decision?.kind === 'battleCards') + resumed = applyAction(resumed, 'a', { type: 'decision', discard: [] }); + assert.equal(resumed.response?.kind, 'choamBattleIncome'); + assert.deepEqual(resumed.pendingChoamBattleIncome, state.pendingChoamBattleIncome); + assert.equal(player(resumed, 'e').spice, beforeIncome); + for (const cancel of [false, true]) { + const settled = cancel + ? applyAction(reload(resumed), 'r', { type: 'card', mode: 'cancel', card: karama.id }) + : applyAction(reload(resumed), 'r', { type: 'passResponse' }); + assert.equal(settled.pendingChoamBattleIncome, null); + assert.equal(player(settled, 'e').spice, beforeIncome + Number(!cancel)); + assert.equal(player(settled, 'a').spice, paidBalance); + assert.equal(player(settled, 'a').tanks, 2); + assert.equal(player(settled, 'e').tanks, 5); + assert.equal(player(settled, 'r').hand.some(c => c.id === fresh), took); + assert.equal(settled.discard.some(c => c.id === semuta.id), took); + assert.deepEqual(inventory(settled), original); + assert.throws(() => applyAction(settled, 'r', { type: 'semutaCommit', event })); + assert.equal(settled.decision?.kind, 'choamMarket'); + const closed = applyAction(settled, 'e', { type: 'decision', done: true }); + assert.equal(closed.phase, 7); + assert.equal(player(closed, 'e').spice, beforeIncome + Number(!cancel)); + assert.equal(player(closed, 'a').spice, paidBalance); + assert.equal(player(closed, 'r').hand.some(c => c.id === fresh), took); + assert.deepEqual(inventory(closed), original); + } + } + } +}); + void test('Semuta settles mandatory and winner discards before one native Face Dance replacement', () => { const { g, semuta, hajr } = fixture(true, false, true, 'tleilaxu'); player(g, 'a').hand = [];