From 37acaeb9d2cab2c1c45c277c6fc5477afee71096 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:28:25 +1100 Subject: [PATCH] Support mixed Richese Kull bid answers --- components/game-table.tsx | 2 +- docs/CHOAM_KULL_DESIGN.md | 17 +- docs/CURRENT_STATUS.md | 17 +- docs/RULE_DECISIONS.md | 9 +- game/bid-promises.ts | 4 +- game/engine.ts | 2 +- game/faction-reference.ts | 2 +- game/reference.ts | 4 +- game/truthtrance.ts | 2 +- .../kull-richese-current-bid-runtime.test.ts | 2 +- tests/kull-richese-mixed-bid-runtime.test.ts | 178 ++++++++++++++++++ tests/public-choam-kull-controls.test.tsx | 2 +- 12 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 tests/kull-richese-mixed-bid-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 2aef6727..66f8c919 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2254,7 +2254,7 @@ export function GameTable({ : <> Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules. Combined turn-action Truthtrance questions are unavailable. - When Richese is seated, mixed current-lot bid, alliance, Revival and bribe commitments are also unavailable, + When Richese is seated, alliance, Revival and bribe commitments are also unavailable, and some Kull reactions may be unavailable in complex response windows. }{' '} Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules. diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index f6da470f..61f6149e 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -1,5 +1,16 @@ # Kull Wahad: source limits and Karama interruption design +## Basic mixed current-lot answers with Richese — 29 October 2026 + +**Prototyped / Partial.** Mixed current-fact and ordinary-lot bid questions +now use the existing bid commitment through Basic Kull and Richese's Semuta +reactions. A truthful answer retains the original mixed question and its +compiled bid obligation through Kull success; decline or a distinct counter +can settle the lot and release a no-longer-possible Yes. The ordinary No cap, +controls and minimal bot answers remain in force. Special-cache bid questions, +other structured commitment families and broader interactions remain separate. +The online timing remains provisional. [Focused evidence](../tests/kull-richese-mixed-bid-runtime.test.ts). + ## Basic current-lot bid answers with Richese — 29 October 2026 **Prototyped / Partial.** Simple Truthtrance questions about the current @@ -7,8 +18,8 @@ ordinary lot are available in the supported Basic Kull and Richese profile. The original lot and Yes/No answer survive Kull success and Semuta's claim or pass. If a distinct counter or a decline allows an opponent to buy that lot, an unfulfillable Yes is released by the normal current-lot continuation. -Controls and minimal bot answers use the existing bid protocol. Mixed bid -questions, Richese cache questions, Kull during the cache and other structured +Controls and minimal bot answers use the existing bid protocol. Richese cache +questions, Kull during the cache and other structured commitments remain separate. The timing remains the existing provisional online interpretation. [Focused evidence](../tests/kull-richese-current-bid-runtime.test.ts). @@ -20,7 +31,7 @@ made before Bidding remains binding through the real Richese cache auction, then through an ordinary-auction Kull response and Semuta's claim or pass. Kull success retains the original lot; a distinct counter can let it settle and leave a later ordinary lot for a Yes. The No cap and the non-CHOAM -unfunded-overbid fence remain in force. Mixed current-lot bid and other structured +unfunded-overbid fence remain in force. Other structured commitments with Richese, Kull during the cache auction and broader combinations remain separate. This is the existing provisional online timing, not a new publisher ruling. [Focused evidence](../tests/kull-richese-phase-bid-runtime.test.ts). diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 99e92626..698a7b9c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,11 +17,20 @@ open mode gates. ## Current checkpoint and work +**Basic Kull, Richese and mixed current-lot Truthtrance — 29 October. Prototyped / Partial.** +Mixed current-fact and ordinary-bid questions can continue through supported +Kull and Semuta responses. Special-cache questions, other structured +commitments and broader combinations remain unfinished. Eight related files +pass 55 cases, including genuine Basic setup, nested mixed questions, Kull and +Semuta decisions, minimal bot answers and rendered table controls. Typecheck, +lint and the production build pass. +[Scope](CHOAM_KULL_DESIGN.md#basic-mixed-current-lot-answers-with-richese--29-october-2026). + **Basic Kull, Richese and current-lot bid Truthtrance — 29 October. Prototyped / Partial.** Simple ordinary-lot answers now continue through Kull and Semuta in the supported Basic Richese profile. The same lot and commitment remain after -Kull success; an external purchase can release an impossible Yes. Mixed bid -questions, special-cache questions and broader combinations remain unfinished. +Kull success; an external purchase can release an impossible Yes. Special-cache +questions and broader combinations remain unfinished. Ten related files pass 67 cases, including real Basic setup, Kull and Semuta actions, minimal bot answers and rendered table controls. Typecheck, lint and the production build pass. @@ -29,8 +38,8 @@ the production build pass. **Basic Kull, Richese and phase-wide bid Truthtrance — 29 October. Prototyped / Partial.** The supported Basic profile now retains phase-wide bid answers across the -Richese cache auction, ordinary-auction Kull and Semuta claim/pass. Mixed -current-lot bid, other structured commitments with Richese and broader interactions remain +Richese cache auction, ordinary-auction Kull and Semuta claim/pass. Other +structured commitments with Richese and broader interactions remain unfinished. Twelve related files pass 59 cases. New cases start an actual Basic game, select unopened deal and Spice positions, buy Semuta through the cache, and exercise Kull success/counter, Semuta claim/pass, bot choices and the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index b75cf975..7e2108e9 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,10 +1,17 @@ # Rule decision index +## Basic Kull, Richese and mixed current-lot answers — 29 October 2026 + +Mixed ordinary-lot bid answers now continue through the supported Kull and +Semuta windows using the existing compiled bid commitment. The online timing +remains provisional; special-cache questions remain separately gated. +[Scope](CHOAM_KULL_DESIGN.md#basic-mixed-current-lot-answers-with-richese--29-october-2026). + ## Basic Kull, Richese and current-lot bid answers — 29 October 2026 Simple ordinary-lot bid answers now persist through supported Kull and Semuta continuations in the Basic Richese profile. Existing provisional -timing and mixed/special-auction fences remain. +timing and special-auction fences remain. [Scope](CHOAM_KULL_DESIGN.md#basic-current-lot-bid-answers-with-richese--29-october-2026). ## Basic Kull, Richese and phase-wide bid answers — 29 October 2026 diff --git a/game/bid-promises.ts b/game/bid-promises.ts index 4da1669b..c86b44b0 100644 --- a/game/bid-promises.ts +++ b/game/bid-promises.ts @@ -42,8 +42,8 @@ export function bidPromiseModeSupported(game: NativePromiseScope & { (!game.kullPreview || bribePromiseModeSupported(game, options)); } -/** Simple current-lot questions have their own Richese/Kull continuation. - * Do not use this admission for mixed questions or Richese cache auctions. */ +/** Current-lot questions have their own Richese/Kull continuation. + * Do not use this admission for Richese cache auctions. */ export function currentBidPromiseModeSupported(game: Parameters[0]): boolean { if (game.advanced === false && game.kullPreview === true && game.semutaPreview === true && game.players.some(player => player.faction === 'richese')) diff --git a/game/engine.ts b/game/engine.ts index 947a42f6..70cf0d4d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16953,7 +16953,7 @@ function bidQuestionWindow(g: Game): boolean { return currentBidPromiseModeSupported(g) && phaseBidCurrentWindow(g); } function mixedBidQuestionWindow(g: Game): boolean { - return bidPromiseModeSupported(g) && phaseBidCurrentWindow(g); + return currentBidPromiseModeSupported(g) && phaseBidCurrentWindow(g); } /** A future lot is a witness only after a real funded bid has committed the * current lot to sale. An unbid lot ends ordinary Bidding instead of opening diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 519f0e2d..d464f415 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; simple current-lot and phase-wide bid questions are supported, while mixed current-lot bid, Revival, alliance and bribe commitments are unavailable. Combined turn-action questions and 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; simple and mixed current-lot bid questions and phase-wide bid questions are supported, while Revival, alliance and bribe commitments are unavailable. Combined turn-action questions and 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 97b9b508..3900173e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4861,7 +4861,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 current-lot and phase-wide ordinary bid questions are available, including with Richese and Semuta. Without Richese, supported mixed bid, Revival, alliance and bribe questions are also available. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.', + 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid questions and phase-wide bid questions are available, including with Richese and Semuta. Without Richese, supported Revival, alliance and bribe questions are also available. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and 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.', @@ -4876,7 +4876,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 current-lot and phase-wide bid commitments can coexist with this preview including Richese; mixed current-lot bid, Revival, alliance and bribe commitments remain limited to the non-Richese profile. Deferred overbid recovery, whole-turn answers 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-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 commitments and phase-wide bid commitments can coexist with this preview including Richese; Revival, alliance and bribe commitments remain limited to the non-Richese profile. Deferred overbid recovery, whole-turn answers 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-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/game/truthtrance.ts b/game/truthtrance.ts index 6bfd18e8..8f27b2a1 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -419,7 +419,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { check(Object.keys(v).sort().join(',') === 'kind,lot,mixed,target,turn', 'Supply the current ordinary lot, turn, complete mixed bid question and respondent.'); const claim = parseBidClaim({ turn: v.turn, lot: v.lot, minimum: 1 }); - check(bidPromiseModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.auction && + check(currentBidPromiseModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.auction && !g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard && claim.turn === g.turn && claim.lot === g.auction.index + 1, 'Choose the current ordinary lot in a quiet supported window.'); diff --git a/tests/kull-richese-current-bid-runtime.test.ts b/tests/kull-richese-current-bid-runtime.test.ts index d72a5db7..cdeb010a 100644 --- a/tests/kull-richese-current-bid-runtime.test.ts +++ b/tests/kull-richese-current-bid-runtime.test.ts @@ -59,7 +59,7 @@ function fixture(answer: 'yes' | 'no') { game = act(game,actor.id,{type:'truthPass'}); } assert.ok(viewGame(game,'guild').truthBidTargets.includes('emperor')); - assert.deepEqual(viewGame(game,'guild').truthMixedBidTargets,[]); + assert.ok(viewGame(game,'guild').truthMixedBidTargets.includes('emperor')); game = act(game,'guild',{type:'truthAsk',question:{kind:'bid',target:'emperor',turn:game.turn,lot,minimum:4}}); assert.ok(viewGame(game,'emperor').truthBidAnswers?.includes(answer)); const html=renderToStaticMarkup(createElement(GameTable, { diff --git a/tests/kull-richese-mixed-bid-runtime.test.ts b/tests/kull-richese-mixed-bid-runtime.test.ts new file mode 100644 index 00000000..55ce08a0 --- /dev/null +++ b/tests/kull-richese-mixed-bid-runtime.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : + specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; + }, +}); +const {GameTable} = await import('../components/game-table'); +aliases.deregister(); + +/** Select only unopened deal/Spice positions. Setup, cache purchase, + * Truthtrance, Kull, Semuta and bidding are genuine game actions. */ +function fixture(answer: 'yes' | 'no') { + let game = createGame('KULLRICHLOT', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); + for (const id of ['richese', 'emperor', 'harkonnen', 'guild'] as const) + joinGame(game, newPlayer(id, id, id)); + game = act(game, 'choam', {type:'moduleOption', module:'kullPreview', enabled:true}); + for (const p of game.players) game = act(game, p.id, {type:'ready'}); + game = act(game, 'choam', {type:'start'}); + assert.equal(game.status, 'setup'); + assert.ok(game.kullPreview && game.semutaPreview); + const originalCards = inventory(game), selected: Game['deck'] = []; + for (const kind of ['kull', 'filler', 'karama', 'karama', 'filler', 'truth'] as const) { + const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : + kind === 'truth' ? card.effect === 'truthtrance' : kind === 'karama' ? card.effect === 'karama' : + card.kind === 'worthless' && card.id !== 'ix-kull-wahad'); + assert.ok(index >= 0); selected.push(game.deck.splice(index, 1)[0]); + } + game.deck.unshift(...selected); + game = advance(game, state => state.status === 'playing'); + const [kull,,karama,counter,,truth] = selected.map(card => card.id); + for (const [id, card] of [['choam',kull], ['emperor',karama], ['harkonnen',counter], ['guild',truth]] as const) + assert.ok(player(game,id).hand.some(row => row.id === card)); + const spice = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(spice >= 0); + game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]); + game = advance(game, state => player(state,'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) && + !!state.auction && state.auction.active === 'emperor' && clean(state)); + const lot = game.auction!.index + 1, lotCard = game.auction!.cards[game.auction!.index].id; + game = act(game,'guild',{type:'card',card:truth}); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(actor); + game = act(game,actor.id,{type:'truthPass'}); + } + assert.ok(viewGame(game,'guild').truthMixedBidTargets.includes('emperor')); + assert.ok(player(game,'emperor').spice >= 1); + const mixed = {kind:'and' as const, terms:[ + {kind:'fact' as const, fact:{kind:'spice' as const, compare:'gte' as const, value:1}}, + {kind:'or' as const, terms:[{kind:'action' as const, action:{minimum:4}}, + {kind:'action' as const, action:{minimum:6}}]}, + ]}; + game = act(game,'guild',{type:'truthAsk',question:{kind:'mixedBid',target:'emperor',turn:game.turn,lot,mixed}}); + assert.ok(viewGame(game,'emperor').truthMixedBidAnswers?.includes(answer)); + const html=renderToStaticMarkup(createElement(GameTable, { + game:viewGame(game,'emperor'),busy:false,send:async () => {},onExit() {}, + })); + assert.match(html,/Answer Yes/); assert.match(html,/Answer No/); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'emperor'); + view.players.find(seat => seat.id === 'emperor')!.bot = difficulty; + const action = botActions(view)[0]; + assert.equal(action?.type,'truthAnswer',difficulty); + assert.ok(view.truthMixedBidAnswers!.includes(action.answer as 'yes' | 'no' | 'unknown'),difficulty); + } + game = act(game,'emperor',{type:'truthAnswer',answer}); + const event = game.bidPromises!.find(row => row.player === 'emperor')!.event; + const projected = viewGame(game,'emperor').mixedBidPromises.find(row => row.event === event); + assert.ok(projected); assert.deepEqual(projected.mixed,mixed); assert.equal(projected.answer,answer === 'yes'); + // The spent Truthtrance is itself a fresh discard; pass the genuine Semuta + // opportunity before attempting another player's Karama purchase. + game = advance(game,clean); + assert.equal(game.auction!.cards[game.auction!.index].id,lotCard); + assert.deepEqual(inventory(game),originalCards); + return {game, originalCards, event, lotCard, kull, karama, counter}; +} + +function offered(game: Game, counter: string) { + const result = act(game,'harkonnen',{type:'card',mode:'purchase',card:counter}); + assert.equal(result.pendingKull?.stage,'offer'); + const reaction = viewGame(result,'choam').kullReaction; assert.ok(reaction); + return {game:result,reaction}; +} +function semuta(game: Game, card: string, claim: boolean): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + assert.equal(pending.reaction?.stage,'offer'); + assert.equal(pending.batch.entries[0].card.id,card); + const event = pending.batch.event; + if (claim) { + assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); + game = act(game,'richese',{type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game,'richese',{type:'semutaSelect',event,card}); + } else { + 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 actor=game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor); + game=act(game,actor.id,{type:'semutaPass',event}); + } + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); + return game; +} + +for (const claim of [false,true]) void test(`mixed current-lot Yes survives successful Kull and Semuta ${claim ? 'claim' : 'pass'}`,() => { + const f=fixture('yes'); + const pending=offered(f.game,f.counter); + let game=act(pending.game,'choam',{type:'kullDecision',event:pending.reaction.event,source:'printed',card:f.kull}); + game=advance(game,state => !!state.pendingTreacheryDiscard); + assert.ok(game.bidPromises?.some(row => row.event === f.event && !row.fulfilled && !row.released)); + game=semuta(game,f.kull,claim); + game=advance(game,clean); + assert.equal(game.auction!.cards[game.auction!.index].id,f.lotCard); + assert.ok(game.bidPromises?.some(row => row.event === f.event && !row.fulfilled && !row.released)); + const guide=viewGame(game,'emperor').bidPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type,'bid'); assert.equal(guide.action.amount,4); + game=act(game,'emperor',guide.action); + assert.ok(game.bidPromises?.some(row => row.event === f.event && row.fulfilled)); + assert.equal(player(game,'richese').hand.some(card => card.id === f.kull),claim); + assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.counter)); + assert.deepEqual(inventory(game),f.originalCards); +}); + +for (const claim of [false,true]) void test(`distinct counter and Semuta ${claim ? 'claim' : 'pass'} release the original mixed current-lot Yes`,() => { + const f=fixture('yes'); + const pending=offered(f.game,f.counter); + let game=act(pending.game,'choam',{type:'kullDecision',event:pending.reaction.event,source:'printed',card:f.kull}); + assert.ok(viewGame(game,'emperor').responseControls?.cancelCards?.includes(f.karama)); + game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + game=semuta(game,f.karama,claim); + game=advance(game,state => clean(state) && !!state.auction && state.auction.index > 0); + assert.ok(game.bidPromises?.some(row => row.event === f.event && row.released)); + assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.lotCard)); + assert.equal(player(game,'richese').hand.some(card => card.id === f.karama),claim); + assert.ok(player(game,'choam').hand.some(card => card.id === f.kull)); + assert.deepEqual(inventory(game),f.originalCards); +}); + +void test('declining Kull allows an external purchase and releases the mixed current-lot Yes',() => { + const f=fixture('yes'); + const pending=offered(f.game,f.counter); + let game=act(pending.game,'choam',{type:'kullDecision',event:pending.reaction.event,decline:true}); + game=advance(game,state => clean(state) && !!state.auction && state.auction.index > 0); + assert.ok(game.bidPromises?.some(row => row.event === f.event && row.released)); + assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.lotCard)); + assert.ok(player(game,'choam').hand.some(card => card.id === f.kull)); + assert.deepEqual(inventory(game),f.originalCards); +}); + +void test('mixed current-lot No still caps bidding after Kull and Semuta',() => { + const f=fixture('no'); + const pending=offered(f.game,f.counter); + let game=act(pending.game,'choam',{type:'kullDecision',event:pending.reaction.event,source:'printed',card:f.kull}); + game=advance(game,state => !!state.pendingTreacheryDiscard); + game=semuta(game,f.kull,false); + game=advance(game,clean); + const before=structuredClone(game); + assert.throws(() => act(game,'emperor',{type:'bid',amount:4,allyPayment:0})); + assert.deepEqual(game,before); + game=act(game,'emperor',{type:'bid',amount:3,allyPayment:0}); + assert.equal(game.auction?.bid,3); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/public-choam-kull-controls.test.tsx b/tests/public-choam-kull-controls.test.tsx index 44b03802..c44838f7 100644 --- a/tests/public-choam-kull-controls.test.tsx +++ b/tests/public-choam-kull-controls.test.tsx @@ -33,7 +33,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its assert.match(html,/Printed Kull is enabled by default/); assert.match(html,/Combined turn-action Truthtrance questions are unavailable/); assert.match(html,/Unfunded non-CHOAM Karama-dependent bids remain deferred/); - assert.match(html,/When Richese is seated, mixed current-lot bid, alliance, Revival and bribe commitments are also unavailable/); + assert.match(html,/When Richese is seated, alliance, Revival and bribe commitments are also unavailable/); const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false}); const unselected = viewGame(off,off.host); assert.equal(unselected.lobbyModules?.kullPreview,false);