From 3d2470b7f8f22b01efdda22c93287bef262bab28 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:53:55 +1100 Subject: [PATCH] Continue Kull through paid auction bonuses --- docs/CHOAM_KULL_DESIGN.md | 22 ++ docs/CURRENT_STATUS.md | 11 + docs/RULE_DECISIONS.md | 8 + game/engine.ts | 26 ++- game/faction-reference.ts | 2 +- game/reference.ts | 4 +- .../kull-richese-cache-cancel-runtime.test.ts | 192 ++++++++++++++++++ .../kull-richese-normal-bonus-runtime.test.ts | 103 ++++++++++ 8 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 tests/kull-richese-cache-cancel-runtime.test.ts create mode 100644 tests/kull-richese-normal-bonus-runtime.test.ts diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index 33d84250..b992c0c2 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -1,5 +1,27 @@ # Kull Wahad: source limits and Karama interruption design +## Basic paid auction bonus and Kull — 29 October 2026 + +**Prototyped / Partial.** In the supported Basic Richese/Semuta and printed +Kull profile, a Karama cancellation of Harkonnen's bonus draw after a paid +ordinary or Richese cache purchase can be interrupted by Kull. The acquired +card and paid sale remain bound while CHOAM decides and while the successful +Kull or distinct counter is offered to Semuta. Decline or a successful counter +resumes the original cancellation once; Kull success preserves the Karama and +resumes the original bonus. Its original continuation owns the draw or lot +retirement, including any automatic advance. A newly stamped receipt binds the +sale and relevant auction state; older Kull frames retain their existing shape. + +The declaration-time Richese cache cancellation was already supported and is +also covered by the new native-game cases. The live-sale tests use actual paid +cache acquisition and physical Semuta, Kull, Karama and counter cards; only +unopened setup/deck positions are selected. Other live cache parents, Advanced, +Black Market, optional modules and live whole-turn answers that need an +unproved post-sale quotation remain separate. This does not certify full Kull +or auction rules. Online response ordering remains provisional. +[Cache declaration and bonus evidence](../tests/kull-richese-cache-cancel-runtime.test.ts), +[ordinary sale evidence](../tests/kull-richese-normal-bonus-runtime.test.ts). + ## Basic whole-answer cancellation continuations — 29 October 2026 **Prototyped / Partial.** In the supported Basic printed-Kull profile, a diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 8872dcfa..2a70fc90 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Basic paid-auction bonus with Kull and Semuta — 29 October. Prototyped / Partial.** +Kull can interrupt a Karama cancellation of Harkonnen's bonus after a supported +paid normal or Richese cache sale. The original sale and bought card remain +bound through the Kull and Semuta reactions, and the native bonus or cancellation +continues once. Ten related files pass 91 cases; a separate case confirms all +four existing bot profiles choose legal Kull responses. Typecheck, lint and the +production build pass. +Other live-sale parents and unproved whole-answer continuations remain fenced; +full modes are unfinished. +[Scope](CHOAM_KULL_DESIGN.md#basic-paid-auction-bonus-and-kull--29-october-2026). + **Basic Kull cancellations with whole-turn answers — 29 October. Prototyped / Partial.** Voice, Prescience, Basic Ixian cyborg strength and clean Guild income cancellations can now retain a binding whole-turn answer through the supported diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index eb7b0908..c44621f6 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Basic paid auction bonus and Kull — 29 October 2026 + +The supported Basic Richese/Semuta profile now retains a paid ordinary or +cache sale through a Kull interruption of Harkonnen bonus cancellation. The +existing online Kull and Semuta ordering remains provisional; other live-sale +and whole-answer continuations remain separately guarded. +[Scope](CHOAM_KULL_DESIGN.md#basic-paid-auction-bonus-and-kull--29-october-2026). + ## Basic whole-answer cancellation continuations — 29 October 2026 The supported Basic printed-Kull profile now quotes selected Voice, diff --git a/game/engine.ts b/game/engine.ts index f31f801f..b8742bc9 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1620,6 +1620,8 @@ export type Game = { allianceReceipt?: true; /** New Basic Kull attempts retain whole-answer order and progress. */ turnActionReceipt?: true; + /** A Basic bonus cancellation retains its already-paid auction parent. */ + auctionReceipt?: true; stage: 'offer' | 'counter'; selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string }; intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent }; @@ -6938,6 +6940,7 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable card.id), funding:g.richeseFunding, aid:g.aid} : {}), restrictions: g.kullRestrictions, discard: g.discard.map(card => card.id), ...(c.battleBinding ? {battle:g.battle} : {}), players: g.players.map(player => ({id:player.id, hand:player.hand.map(card => card.id), @@ -19354,6 +19365,9 @@ function kullSignature(g: Game, frame: KullFrame): string { alliancePromiseSequence:source.alliancePromiseSequence} : {}), ...(frame.turnActionReceipt ? {turnActionReceipt:frame.turnActionReceipt, turnActionPromises:source.turnActionPromises,travelPromiseOrder:source.travelPromiseOrder} : {}), + ...(frame.auctionReceipt ? {auctionReceipt:frame.auctionReceipt, sale:source.currentAuctionSale, + richeseAuction:source.richeseAuction, round:source.richeseBidding, + cache:source.richeseCache?.map(card => card.id), funding:source.richeseFunding, aid:source.aid} : {}), }); } /** Only the verified pre-source printed profile may bind its old held cost. */ @@ -19412,6 +19426,11 @@ function kullIntegrity(g: Game): void { requireRule(frame.turnActionReceipt === undefined || frame.turnActionReceipt === true && turnActionPromiseModeSupported(g) && !!g.turnActionPromises?.length, 'The Kull attempt has lost its original whole-answer ledger.'); + requireRule(frame.auctionReceipt === undefined || frame.auctionReceipt === true && + !g.advanced && g.semutaPreview === true && !!g.currentAuctionSale && + frame.intent.kind === 'ordinary' && frame.intent.use.kind === 'cancel' && + frame.intent.use.response.kind === 'harkonnenBonus', + 'The Kull attempt has lost its original paid auction bonus.'); validateKullAttemptStamp(g, frame, physicalTreacheryCards(g)); requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 && frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]), @@ -19504,6 +19523,9 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int ? { allianceReceipt: true as const } : {}), ...(turnActionPromiseModeSupported(g) && g.turnActionPromises?.length ? { turnActionReceipt: true as const } : {}), + ...(g.semutaPreview && !g.advanced && !!g.currentAuctionSale && intent.kind === 'ordinary' && + intent.use.kind === 'cancel' && intent.use.response.kind === 'harkonnenBonus' + ? { auctionReceipt: true as const } : {}), worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '', }; // Payment dispatch removed this control before entering its native branch. diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 02fee669..f6e02894 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -14,7 +14,7 @@ export const FACTION_RULES: Record< 'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.', 'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.', 'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.', - 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe, Revival and alliance, simple and mixed current-lot bid, and phase-wide bid questions are supported. Combined turn-action questions are available in the supported Basic profile, with some Karama uses still guarded. Unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.', + 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta, including when a paid ordinary or cache sale is waiting for Harkonnen’s bonus. Ordinary bribe, Revival and alliance, simple and mixed current-lot bid, and phase-wide bid questions are supported. Combined turn-action questions are available in the supported Basic profile, with some Karama uses still guarded. Unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.', ], advanced: [ 'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.', diff --git a/game/reference.ts b/game/reference.ts index 83eaee47..3920c9c2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4863,7 +4863,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.', steps: [ 'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.', - 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe, Revival and alliance questions are available, including with Richese and Semuta. Combined turn-action questions are also available in this profile. A pending Kull response holds their controls while the original transaction resolves; some Karama uses remain guarded. Wider interactions remain incomplete.', + 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe, Revival and alliance questions are available, including with Richese and Semuta. Combined turn-action questions are also available in this profile. A pending Kull response holds their controls while the original transaction resolves; some Karama uses remain guarded. Kull and Semuta also retain a paid ordinary or cache sale when Karama targets Harkonnen’s bonus. Wider interactions remain incomplete.', 'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.', 'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.', 'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.', @@ -4878,7 +4878,7 @@ export const RULE_TOPICS: RuleTopic[] = [ example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.', related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe, Revival and alliance commitments can coexist with this preview including Richese. Combined turn-action answers use guarded Kull continuation; some cancellation parents, deferred overbid recovery and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-richese-alliance-semuta.test.ts', 'tests/kull-richese-alliance-worm-ride.test.ts', 'tests/kull-richese-alliance-table-controls.test.tsx', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe, Revival and alliance commitments can coexist with this preview including Richese. Combined turn-action answers use guarded Kull continuation; some cancellation parents, deferred overbid recovery and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-richese-alliance-semuta.test.ts', 'tests/kull-richese-alliance-worm-ride.test.ts', 'tests/kull-richese-alliance-table-controls.test.tsx', 'tests/kull-richese-cache-cancel-runtime.test.ts', 'tests/kull-richese-normal-bonus-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] }, diff --git a/tests/kull-richese-cache-cancel-runtime.test.ts b/tests/kull-richese-cache-cancel-runtime.test.ts new file mode 100644 index 00000000..c852476a --- /dev/null +++ b/tests/kull-richese-cache-cancel-runtime.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; + +/** Uses the public Basic Kull/Richese setup and an actual paid first-turn + * cache acquisition of Semuta. The fixture selects only unopened initial + * Treachery/Spice positions; all subsequent phases and the next cache + * declaration use native actions. */ +function source(position: 'first' | 'last') { + const f = createKullRicheseSemutaFixture(); + let game = advance(f.game, state => state.turn === 2 && state.phase === 3 && + !state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration'); + const declaration = game.richeseBidding; assert.ok(declaration); + game = act(game, 'richese', {type:'decision', event:declaration.event, position}); + assert.equal(game.response?.kind, 'richeseAuction'); + assert.equal(game.response?.owner, 'richese'); + assert.equal(game.richeseBidding?.position, position); + assert.equal(game.richeseBidding?.cacheCanceled, false); + assert.ok(player(game, 'richese').hand.some(card => card.id === f.semuta)); + assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama)); + assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter)); + assert.ok(player(game, 'choam').hand.some(card => card.id === f.kull)); + assert.deepEqual(inventory(game), f.originalCards); + return {...f, game, cache:game.richeseCache!.map(card => card.id), + normalBefore:game.richeseBidding!.normalCountBeforeCache!, + normalCount:game.richeseBidding!.normalCount!}; +} + +function passDiscard(game: Game): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); + const event = pending.batch.event; + for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { + const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer'); + const seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, {type:'semutaPass', event}); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); + return game; +} + +function claimDiscard(game: Game, card: string): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); + assert.ok(pending.batch.entries.some(entry => entry.card.id === card)); + assert.equal(viewGame(game, 'richese').semutaReaction?.canCommit, true); + const event = pending.batch.event; + game = act(game, 'richese', {type:'semutaCommit', event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game, 'richese', {type:'semutaSelect', event, card}); + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); + return game; +} + +for (const position of ['first', 'last'] as const) + for (const outcome of ['decline', 'success', 'counter'] as const) + for (const claim of [false, true]) + void test(`Basic ${position} cache cancellation: Kull ${outcome}, Semuta ${claim ? 'claim' : 'pass'}`, () => { + const f = source(position); let game = f.game; + assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama)); + game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); + const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction); + assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull)); + game = outcome === 'decline' + ? act(game, 'choam', {type:'kullDecision', event:reaction.event, decline:true}) + : act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull}); + if (outcome === 'counter') { + assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter)); + game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter}); + game = passDiscard(game); + } + if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard); + const discarded = outcome === 'success' ? f.kull : f.karama; + assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded)); + game = claim ? claimDiscard(game, discarded) : passDiscard(game); + assert.ok(!game.pendingKull); + if (outcome === 'success') { + assert.equal(game.response?.kind, 'richeseAuction'); + assert.equal(game.richeseBidding?.cacheCanceled, false); + assert.equal(game.richeseBidding?.normalCount, f.normalCount); + assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama)); + } else { + game = advance(game, state => state.phase === 3 && state.richeseBidding?.stage === 'normal' && + !!state.auction && clean(state)); + assert.equal(game.richeseBidding?.cacheCanceled, true); + assert.equal(game.richeseBidding?.normalCount, f.normalBefore); + assert.equal(game.auction?.cards.length, f.normalBefore); + assert.ok(!player(game, 'emperor').hand.some(card => card.id === f.karama)); + } + assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim); + assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim); + assert.deepEqual(game.richeseCache?.map(card => card.id), f.cache); + assert.deepEqual(inventory(game), f.originalCards); + }); + +/** Atreides inspection of a concealed Richese lot belongs to Advanced Black + * Market, not Basic's face-up cache. This Basic parent instead reaches the + * native Harkonnen bonus response after a real paid cache purchase. */ +function bonusSource() { + const f = source('first'); let game = f.game; + for (let count = 0; game.response?.kind === 'richeseAuction' && count < game.players.length; count++) { + const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, {type:'passResponse'}); + } + assert.equal(game.decision?.kind, 'richeseCache'); + const card = game.richeseCache?.[0]; assert.ok(card); + game = act(game, 'richese', {type:'decision', event:game.richeseBidding!.event, + card:card.id, method:'silent'}); + const lot = game.richeseAuction; assert.ok(lot && lot.method === 'silent'); + for (let count = 0; game.richeseAuction?.event === lot.event && !game.response && count < game.players.length; count++) { + const current = game.richeseAuction!; + const actor = current.eligible.find(id => !Object.hasOwn(current.sealed,id)); assert.ok(actor); + game = act(game, actor, {type:'richeseBid', event:current.event, + amount:actor === 'harkonnen' ? 1 : 0, allyPayment:0}); + } + assert.equal(game.response?.kind, 'harkonnenBonus'); + assert.equal(game.response?.owner, 'harkonnen'); + assert.ok(game.richeseAuction); + assert.equal(game.currentAuctionSale?.origin, 'cache'); + assert.equal(game.currentAuctionSale?.winner, 'harkonnen'); + assert.ok(player(game, 'harkonnen').hand.some(held => held.id === card.id)); + assert.deepEqual(inventory(game), f.originalCards); + return {...f, game, purchased:card.id}; +} + +for (const outcome of ['decline', 'success', 'counter'] as const) + for (const claim of [false, true]) + void test(`Basic paid cache Harkonnen bonus cancellation: Kull ${outcome}, Semuta ${claim ? 'claim' : 'pass'}`, () => { + const f = bonusSource(); let game = f.game; + const harkonnenCards = player(game, 'harkonnen').hand.length; + assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama)); + game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); + const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction); + assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull)); + game = outcome === 'decline' + ? act(game, 'choam', {type:'kullDecision', event:reaction.event, decline:true}) + : act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull}); + if (outcome === 'counter') { + assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter)); + game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter}); + game = passDiscard(game); + } + if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard); + const discarded = outcome === 'success' ? f.kull : f.karama; + assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded)); + game = claim ? claimDiscard(game, discarded) : passDiscard(game); + assert.ok(!game.pendingKull); + assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.purchased)); + if (outcome === 'success') { + // With no other eligible cancellation remaining, the restored bonus + // resolves automatically and the winner draws its real extra card. + assert.ok(!game.currentAuctionSale); + assert.equal(player(game, 'harkonnen').hand.length, harkonnenCards + 1); + assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama)); + } else { + assert.ok(!game.currentAuctionSale); + assert.ok(!player(game, 'emperor').hand.some(card => card.id === f.karama)); + } + assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim); + assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim); + assert.deepEqual(inventory(game), f.originalCards); + }); + +void test('the paid cache sale and source remain bound while Kull and Semuta are pending', () => { + const f = bonusSource(); + let game = act(f.game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); + const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction); + const staleSale = structuredClone(game); staleSale.currentAuctionSale!.amount++; + assert.throws(() => act(staleSale, 'choam', {type:'kullDecision', event:reaction.event, decline:true})); + const staleCache = structuredClone(game); staleCache.richeseCache!.reverse(); + assert.throws(() => act(staleCache, 'choam', {type:'kullDecision', event:reaction.event, decline:true})); + game = act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull}); + game = advance(game, state => !!state.pendingTreacheryDiscard); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + const changed = structuredClone(game); changed.currentAuctionSale!.amount++; + assert.throws(() => act(changed, 'richese', {type:'semutaPass', event:pending.batch.event})); + game = passDiscard(game); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all existing bot profiles can answer the paid cache bonus Kull offer legally', () => { + const f = bonusSource(); + const game = act(f.game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'choam'); + view.players.find(seat => seat.id === 'choam')!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action, difficulty); + assert.equal(action.type, 'kullDecision', difficulty); + assert.doesNotThrow(() => act(game, 'choam', action), difficulty); + } +}); diff --git a/tests/kull-richese-normal-bonus-runtime.test.ts b/tests/kull-richese-normal-bonus-runtime.test.ts new file mode 100644 index 00000000..5470498b --- /dev/null +++ b/tests/kull-richese-normal-bonus-runtime.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { act, advance, clean, createKullRicheseSemutaFixture, player } from './fixture-kull-richese-semuta'; + +/** Account for the current sold card as a historical auction reference, not a + * second physical card. Unopened lots remain in the original auction custody. */ +function inventory(game: Game): string[] { + const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...game.players.flatMap(seat => seat.hand), + ...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])] + .map(card => card.id).sort(); + assert.equal(new Set(cards).size, cards.length); + return cards; +} + +/** Public Basic four-seat setup and actual first-turn paid Semuta acquisition. + * In turn two Richese announces its compulsory auction last, then Harkonnen + * purchases an actual normal lot with its own spice; no live purse, hand, + * auction, response or promise is staged. */ +function source() { + const f = createKullRicheseSemutaFixture(); + let game = advance(f.game, state => state.turn === 2 && state.phase === 3 && + !state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration'); + const round = game.richeseBidding; assert.ok(round); + game = act(game, 'richese', {type:'decision', event:round.event, position:'last'}); + game = advance(game, state => state.phase === 3 && !!state.auction && + state.auction.active === 'harkonnen' && state.auction.bid === 0 && clean(state)); + const index = game.auction!.index, lot = game.auction!.cards[index].id; + const beforeSpice = player(game,'harkonnen').spice; + game = act(game,'harkonnen',{type:'bid',amount:1,allyPayment:0}); + game = advance(game,state => state.response?.kind === 'harkonnenBonus'); + assert.equal(game.currentAuctionSale?.origin,'normal'); + assert.equal(game.currentAuctionSale?.winner,'harkonnen'); + assert.equal(game.auction?.index,index); + assert.equal(game.auction?.cards[index].id,lot); + assert.ok(player(game,'harkonnen').hand.some(card => card.id === lot)); + assert.equal(player(game,'harkonnen').spice,beforeSpice-1); + assert.ok(player(game,'choam').hand.some(card => card.id === f.kull)); + assert.ok(player(game,'emperor').hand.some(card => card.id === f.karama)); + assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.counter)); + assert.ok(player(game,'richese').hand.some(card => card.id === f.semuta)); + assert.deepEqual(inventory(game),f.originalCards); + return {...f,game,index,lot,paidSpice:player(game,'harkonnen').spice, + buyerCount:player(game,'harkonnen').hand.length}; +} + +function passDiscard(game: Game): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); + const event = pending.batch.event; + for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count !reaction.passed.includes(row.id)); assert.ok(seat); + game = act(game,seat.id,{type:'semutaPass',event}); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); + return game; +} +function claimDiscard(game: Game, card: string): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); + assert.ok(pending.batch.entries.some(entry => entry.card.id === card)); + assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); + const event=pending.batch.event; + game=act(game,'richese',{type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game=act(game,'richese',{type:'semutaSelect',event,card}); + assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); + return game; +} + +for (const outcome of ['decline','success','counter'] as const) + for (const claim of [false,true]) + void test(`normal Harkonnen paid bonus: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`,()=>{ + const f=source(); let game=f.game; + assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull)); + game=outcome==='decline' + ? act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}) + : act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + if (outcome==='counter') { + assert.ok(viewGame(game,'harkonnen').responseControls?.cancelCards.includes(f.counter)); + game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter}); + game=passDiscard(game); + } + if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard); + const discarded=outcome==='success'?f.kull:f.karama; + assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===discarded)); + game=claim?claimDiscard(game,discarded):passDiscard(game); + assert.equal(game.pendingKull ?? null,null); + assert.equal(game.currentAuctionSale ?? null,null); + assert.notEqual(game.response?.kind,'harkonnenBonus'); + assert.equal(game.auction?.index,f.index+1); + assert.equal(player(game,'harkonnen').spice,f.paidSpice); + assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.lot)); + assert.equal(player(game,'harkonnen').hand.length, + f.buyerCount+Number(outcome==='success')-Number(outcome==='counter')); + assert.equal(player(game,'emperor').hand.some(card=>card.id===f.karama),outcome==='success'); + assert.equal(player(game,'richese').hand.some(card=>card.id===discarded),claim); + assert.equal(player(game,'richese').hand.some(card=>card.id===f.semuta),!claim); + assert.deepEqual(inventory(game),f.originalCards); + });