From 9efa17b2b7fbdc3274a9178b4a980950510cd8e6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:03:04 +1100 Subject: [PATCH] Offer Semuta before native Black Market replacement draws --- docs/CURRENT_STATUS.md | 12 ++++++ docs/IMPLEMENTATION_STATUS.md | 30 ++++++++++++++ docs/RULE_DECISIONS.md | 1 - docs/SEMUTA_DISCARD_CONTINUATIONS.md | 12 +++--- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 20 ++++++++-- game/engine.ts | 10 +++-- game/reference.ts | 6 +-- tests/semuta-engine.test.ts | 56 ++++++++++++++++++++++++++- 8 files changed, 129 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index afdab05d..2f4d5f86 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 / Black Market replacement — 11 October.** A clean native Ixian +allied replacement now offers Semuta before drawing from a Black Market +purchase, as already supported for normal auctions. The discarded card belongs +to the buyer and stays private; payment, seller income and one replacement +resume once before Richese Declaration. Cache purchases retain their printed +allied-replacement prohibition. The regression fails before/passes after; +types/lint and 63 affected cases pass. Twelve isolated runtime scenarios cover +three methods, claim/pass, four legal bots, empty-deck refill and full hand. +Real 390px controls prove neutral ally visibility, private Commit and buyer +hand 0→1 with Declaration restored. No saved store or deployed game was targeted. +See [the current source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Approved skill decisions — 11 October.** Normal Mentat now uses the approved uniform private card-show step throughout the existing supported Leader Skills envelope; the separate flag and CLI option are removed. Newly opened battles diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a6944121..65b83185 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 11 October 2026 — Semuta before Black Market allied replacement draw + +The native Black Market purchase already offers Ixian allied replacement and +has a typed discard-before-draw continuation. Its Semuta admission still limited +that receipt to normal auctions. The connected path now admits the original +Black Market sale without replaying payment or delivery. Every seat gets the +neutral offer; the discarded face remains private until a committed holder +claims it. Claim or all-seat decline draws one native replacement, credits +the original seller once, marks the original sale and restores Declaration. +The printed cache-purchase exclusion is unchanged; no cache replacement was +implemented or inferred from the broader Semuta effect. + +The permanent regression fails before the admission change and passes after, +covering draw suspension, private candidates, claim/decline, buyer/seller spice, +exact card custody and rejection of a retired event. Types/lint and 63 cases in +the affected Semuta/Richese union pass. Twelve isolated CLI scenarios cover +three Black Market methods × claim/pass, all four legal AI profiles, claiming +before an empty-deck refill and an eligible full-hand Semuta exchange. The +full-hand Ixian is correctly excluded from bidding and may still claim its +ally's fresh discard afterward. + +At 390px, the real `SemutaReaction` component initially shows the owner Commit +button and only neutral Continue for the uncommitted ally. Actual Commit +recovers Hajr, completes buyer hand 0→1 and restores `richeseDeclaration`, with +no horizontal overflow. The isolated fixture uses the real engine and no store. +Temporary scripts, server and browser tab were removed/closed. This is local +changed-path proof, not complete Semuta, combined-mode or deployed acceptance. +The current contracts are [source/runtime](SEMUTA_SOURCE_UPDATE_20260907.md) +and [discard continuations](SEMUTA_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — approved normal Mentat and Emperor gift classification The user approved normal Mentat's uniform private card-show step in the existing diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 84295137..41b2ede7 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -401,7 +401,6 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Multiple-pile and concealed-relocation collection remain guarded. | [Ground/direct relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) | | Diplomat defense | A committed Worthless copies the opponent's canonical printed defensive role: base/Ix Shield/Snooper, hybrid, defensive Weirding/Chemistry or sealed Portable. Named combat distinctions remain. Copying the used role, not activation or own-slot prerequisites, is a visibly qualified first version. Reveal-before-traitor timing and mandatory disposal remain implementation interpretations; existing base-only battle windows are not reopened. Late/mirror/numeric modifications remain separate. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) | | Bureaucrat payments | Once per phase, optionally divert two from an actual third-party payment of at least five; payer cost is unchanged. Bribes trigger at transfer to the recipient's shield, not later collection. Native income is offered after Karama allowance as an explicit outcome-based inference; split contributions, split recipients and remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) | -| Mentat question preview | The physical card says to show the card to the questioner, privately; it stays in hand and does not bind a plan. Ask before posture/faction powers. A uniform target response protects held/absent privacy but awaits approval as an exception to skipping forced confirmations; standard activation stays disabled. Empty hands skip with no fabricated answer. | [Source and preview](MENTAT_QUESTION.md) | | Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) | | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 05eb578c..69adde7f 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -64,14 +64,14 @@ a neutral claim or decline completes the same free settlement once. Richese lots, Worthless conversion and nested responses retain their previous automatic paths. -A clean private normal-auction Ixian ally replacement now stages the -existing `ixAllyCard` continuation as a neutral Semuta offer before +A clean private normal-auction or Black Market Ixian ally replacement now stages +the existing `ixAllyCard` continuation as a neutral Semuta offer before `draw(g)`. The original buyer owns the discarded card. Its face stays unknown to rivals and uncommitted holders; a sole committed claim -moves that exact card before one saved replacement draw and ordinary -sale continuation. Richese cache and Black Market sales retain the -automatic path until their distinct custody and offer windows are -integrated. +moves that exact card before one saved replacement draw and original +sale continuation. The 11 October Black Market extension preserves the original +paid amount, seller income and return to Richese Declaration. Cache purchases +retain their printed prohibition on native Ixian allied replacement. A clean completed CHOAM market sale stages `choamSaleDiscard` only after the accepted public card disposal and its quoted two- or three-spice diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 1acb2b46..c9a4bd16 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -85,7 +85,7 @@ Gesserit Charity or settling a clean normal-auction lot, a completed CHOAM sale or clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless discard, accepted moving-storm Jubba Cloak, battle and Ix-deck Thumper/Amal discards**, plus -a clean **private** normal-auction Ixian ally replacement discard +a clean **private** normal-auction or Black Market Ixian ally replacement discard before its draw. Other than the one explicitly suspended charity/Inflation response, the parent transaction has no competing response. Every seat sees the @@ -96,8 +96,22 @@ The Ixian ally discard belongs to the original buyer, not the Ixians. Every seat sees the neutral offer without its face or a named claimant; only a committed eligible holder takes the exact fresh card. A claim removes it before the saved single replacement draw and subsequent -normal-auction settlement. Richese cache and Black Market replacement -discards, competing parents and other private producers remain separate. +normal or Black Market settlement. The original buyer pays once, Richese receives +its original Black Market income once, and the sold card stays private until +an entitled committed claim. The native cache exclusion remains: the Richese +FAQ prohibits Ixian allied replacement of a cache purchase. Competing parents +and other private producers remain separate. + +**11 October 2026 — Black Market composition.** The existing typed +`ixAllyCard` receipt now admits the clean Black Market Semuta offer before +replacement draw. Claim or all-seat decline completes one original replacement +and resumes Richese Declaration without replaying the sale. The focused +regression fails before and passes after; types/lint and 63 affected cases pass. +Twelve isolated engine scenarios cover all three auction methods, claim/pass, +four legal AI profiles, empty-deck refill and a full-hand exchange. At 390px, +the real private Commit control claims Hajr, gives the buyer one replacement +and restores Declaration; an uncommitted ally sees only the neutral Continue. +This is local changed-path evidence, not complete Semuta or deployed acceptance. A clean CHOAM card sale commits its one named public discard and the quoted two- or three-spice bank payment before the neutral offer. diff --git a/game/engine.ts b/game/engine.ts index 859febae..70146685 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6750,13 +6750,17 @@ function semutaOfferSupported( : !continuation.resume.phaseOpening)))) && !g.karamaShipping && (continuation.kind === 'ixAllyCard' - ? !!g.auction && g.currentAuctionSale?.origin === 'normal' && - continuation.sale.origin === 'normal' && !g.richeseAuction + ? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin && + (continuation.sale.origin === 'normal' + ? !!g.auction && !g.richeseAuction + : continuation.sale.origin === 'blackMarket' && + g.richeseAuction?.source === 'blackMarket' && !g.auction) : continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard' ? !!g.auction && !g.currentAuctionSale && !g.richeseAuction : !g.auction) && - (continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) && + (continuation.kind === 'saphoAuctionDiscard' || + continuation.kind === 'ixAllyCard' || !g.richeseAuction) && (continuation.kind === 'saphoAggressorDiscard' || continuation.kind === 'residualPoisonDiscard' || !g.battle) && (continuation.kind !== 'choamKulonDiscard' || diff --git a/game/reference.ts b/game/reference.ts index 87d032ad..155b69c8 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, 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 Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. 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, 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. 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 Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. 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. 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, 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 Ixian ally replacement, 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, 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/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 22729299..bf85f402 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -22,8 +22,8 @@ function inventory(g: Game) { assert.equal(new Set(ids).size, ids.length); return ids; } -function fixture(ix = false, ixianAlly = false) { - let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ix ? ['choam', 'ix'] : ['choam']); +function fixture(ix = false, ixianAlly = false, advanced = false) { + let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, ix ? ['choam', 'ix'] : ['choam']); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : 'Emperor', ixianAlly ? 'ixians' : 'emperor')); @@ -117,6 +117,58 @@ void test('private Ixian ally discard offers Semuta before replacement without r assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); }); +void test('Black Market Ixian replacement offers the private fresh card before drawing and pays the seller once', () => { + const { g, semuta, hajr } = fixture(true, true, true); + player(g, 'a').hand = []; + player(g, 'r').hand.push(hajr); + player(g, 'a').ally = 'e'; + player(g, 'e').ally = 'a'; + Object.assign(g, { phase: 2, active: 'a', order: ['a', 'e', 'r'], movementRemaining: [] }); + const buyerSpice = player(g, 'a').spice; + const sellerSpice = player(g, 'r').spice; + let state = g; + for (const id of ['a', 'e', 'r']) state = applyAction(state, id, { type: 'ready' }); + for (const id of ['a', 'e', 'r']) state = applyAction(state, id, { type: 'ready' }); + assert.equal(state.decision?.kind, 'richeseBlackMarket'); + state = applyAction(state, 'r', { type: 'decision', event: state.richeseBidding!.event, + card: hajr.id, method: 'silent' }); + for (const id of ['a', 'e', 'r']) + state = applyAction(state, id, { type: 'richeseBid', event: state.richeseAuction!.event, + amount: id === 'a' ? 3 : 0 }); + assert.equal(state.decision?.kind, 'ixAllyCard'); + const replacement = state.deck[0].id; + state = applyAction(state, 'a', { type: 'decision', accept: true }); + while (state.response?.kind === 'ixAllyCard') { + const actor = state.players.find(p => !state.response!.passed.includes(p.id))!; + state = applyAction(state, actor.id, { type: 'passResponse' }); + } + assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(state.pendingTreacheryDiscard?.continuation.kind, 'ixAllyCard'); + assert.equal(state.pendingTreacheryDiscard?.batch.entries[0].publicFace, false); + assert.equal(state.deck[0].id, replacement); + assert.equal(player(state, 'a').hand.length, 0); + assert.equal(player(state, 'a').spice, buyerSpice - 3); + assert.equal(viewGame(state, 'e').semutaReaction?.candidates.length, 0); + const event = state.pendingTreacheryDiscard!.batch.event; + const beforeInventory = inventory(state); + let declined = reload(state); + for (const id of ['a', 'e', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(state), 'r', { type: 'semutaCommit', event }); + for (const finished of [declined, claimed]) { + assert.equal(finished.pendingTreacheryDiscard, null); + assert.equal(finished.decision?.kind, 'richeseDeclaration'); + assert.equal(finished.richeseBidding?.blackMarketSold, true); + assert.equal(player(finished, 'a').spice, buyerSpice - 3); + assert.equal(player(finished, 'r').spice, sellerSpice + 3); + assert.deepEqual(player(finished, 'a').hand.map(card => card.id), [replacement]); + assert.deepEqual(inventory(finished), beforeInventory); + assert.throws(() => applyAction(finished, 'r', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.some(card => card.id === hajr.id), true); + assert.equal(player(claimed, 'r').hand.some(card => card.id === hajr.id), true); + assert.equal(claimed.discard.some(card => card.id === semuta.id), true); +}); + void test('printed Karama auction payment offers its card before the winning lot settles', () => { const { g, semuta, hajr } = fixture(); player(g, 'a').hand = [take(g, 'karama')];