From 92d5c0ac2e3e97b639517754db8f23ce389e5fc1 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:04:37 +1100 Subject: [PATCH] Clarify Bene Gesserit cache cancellation --- docs/CURRENT_STATUS.md | 8 ++ docs/RICHESE_AUCTION_RULES.md | 12 ++ game/engine.ts | 2 +- game/reference.ts | 2 +- tests/bg-karama-cancel-recovery.test.ts | 6 +- tests/bg-richese-cache-cancel.test.ts | 170 ++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 tests/bg-richese-cache-cancel.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ce8b6dbe..71479d4e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,14 @@ open mode gates. ## Current checkpoint and work +**Bene Gesserit cache cancellation guidance — 29 October. Prototyped / Partial.** +The rules guide now describes the already supported Worthless-card cancellation +of Richese's compulsory cache auction and its counter. Staged Bidding journeys +cover first/last placement, both outcomes, ordinary-lot continuation and legal +bot responses. Forty-three related cases, quick type/lint checks and the +production build pass. Full auction modes and deployed acceptance remain open. +[Scope](RICHESE_AUCTION_RULES.md#bene-gesserit-cache-cancellation-guidance--29-october-2026). + **Native zero-marker battle continuation — 29 October. Prototyped / Partial.** The existing staged zero-reserve native No-Field journey now completes the actual Traitor decision and final battle opportunity, preserving the physical diff --git a/docs/RICHESE_AUCTION_RULES.md b/docs/RICHESE_AUCTION_RULES.md index a0a8a501..1326d58e 100644 --- a/docs/RICHESE_AUCTION_RULES.md +++ b/docs/RICHESE_AUCTION_RULES.md @@ -1,5 +1,17 @@ # Richese auction rules and integration contract +## Bene Gesserit cache cancellation guidance — 29 October 2026 + +**Prototyped / Partial.** Advanced Bene Gesserit can use an eligible Worthless +card as Karama to cancel Richese's compulsory cache auction. The existing +user ruling restores the frozen ordinary-lot count. A printed Karama counter +instead restores the original Richese response and lets that auction proceed. +The [focused action journeys](../tests/bg-richese-cache-cancel.test.ts) exercise +first/last placement, both outcomes, actual lot continuation and minimal legal +bot responses from a staged pre-Bidding opportunity. This corrects older +unsupported-cancellation guidance; it does not certify full auction modes. + + Audit: 2026-09-06. This document covers the complete core bidding pipeline: the compulsory cache lot, both alternative methods, advanced Black Market using all three methods, associated acquisition/payment effects, and the separate special Karama purchase. It is a source audit and implementation contract, not certification that Richese or a combined expansion mode is playable. Existing implementation-status gates remain in force. ## Evidence and limits diff --git a/game/engine.ts b/game/engine.ts index faeb4b07..f8ce9990 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -19297,7 +19297,7 @@ function validateKaramaUse( if (use.response.kind === 'ecazCollection') currentEcazCollectionQuote(g, use.response, true); // Canceling a Worthless conversion restores its old use; it does not execute - // that use. In particular, a legacy unsupported count attempt is cancelable. + // that use. The original Richese count response is preserved as well. } else if (use.kind === 'shipment') { const recipient = getPlayer(g, use.recipient); requireRule( diff --git a/game/reference.ts b/game/reference.ts index 0c0b5323..af5fd61c 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5189,7 +5189,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'If a Worthless cancellation is itself canceled, the original faction response resumes with its prior confirmations intact. Private details of that suspended response remain private. A real Karama resolves directly.', 'Holding a Worthless card permits an overbid without spending it when outbid. A full hand still prevents direct acquisition. Sealed battle cards and cards committed to prescience cannot also be spent as Karama.', 'While an auction conversion waits, its incoming card reserves one hand slot. A gift may still be accepted if room remains for that card and any cards due back from a hand exchange. Supported Box, gift, Truthtrance and hand-exchange interruptions restore the original conversion without repeating its cancellation, purchase or shipment benefit. A separately summoned worm can add its own ride before the original response resumes.', - 'An unsupported cancellation must be rejected before beginning the conversion. Canceling Richese’s normal auction count remains unavailable pending its ruling; canceling an older saved Worthless attempt at that power instead restores the original Richese response.', + 'Unsupported cancellations are rejected before beginning conversion. A supported Worthless cancellation of Richese’s compulsory cache auction restores the original ordinary-lot count; countering the conversion restores the original Richese response.', 'An unfunded winning bid after a canceled conversion currently restarts bidding for the same card as provisional recovery. This exact auction edge case still requires primary-source confirmation before advanced play is enabled.', ], related: ['charity', 'card-karama', 'bidding', 'faction-beneGesserit'], diff --git a/tests/bg-karama-cancel-recovery.test.ts b/tests/bg-karama-cancel-recovery.test.ts index 6555f199..bbde2256 100644 --- a/tests/bg-karama-cancel-recovery.test.ts +++ b/tests/bg-karama-cancel-recovery.test.ts @@ -635,13 +635,13 @@ void test('invalid actual Moritani cleanup custody rejects before BG pays its Wo f.sqlite.close(); } }); -void test('a historical unstamped unsupported Richese-count cancellation remains recoverable by printed counter-cancellation after restart', async () => { +void test('a historical unstamped Richese-count cancellation remains recoverable by printed counter-cancellation after restart', async () => { const f = await fixture('legacy', false); try { const legacy = structuredClone(f.pending); const at = legacy.players[0].hand.findIndex((c) => c.id === f.worthless.id); - // Historical persistence compatibility only: current actions refuse this - // conversion before cost. Its actual original count declaration is retained. + // This stages an older unstamped receipt; current fresh conversions are + // supported separately. Its original count declaration is retained. legacy.discard.push(legacy.players[0].hand.splice(at, 1)[0]); legacy.pendingKarama = { owner: f.ids[0], diff --git a/tests/bg-richese-cache-cancel.test.ts b/tests/bg-richese-cache-cancel.test.ts new file mode 100644 index 00000000..22da6a3c --- /dev/null +++ b/tests/bg-richese-cache-cancel.test.ts @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { richeseCards } from '../game/richese-cards'; + +const player = (game: Game, id: string) => game.players.find((seat) => seat.id === id)!; + +function inventory(game: Game) { + const cards = [ + ...game.deck, + ...game.discard, + ...game.players.flatMap((seat) => seat.hand), + ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? []), + ].map((card) => card.id).sort(); + assert.equal(new Set(cards).size, cards.length); + return cards; +} + +void test('all four minimal bot profiles can respond to the real BG cache conversion', () => { + for (const difficulty of DIFFICULTIES) { + const source = declaration('first'); + let game = applyAction(source.game, 'b', { + type: 'card', card: source.worthless, mode: 'cancel', + }); + assert.equal(game.response?.kind, 'worthlessKarama'); + const view = viewGame(game, 'e'); + view.players.find((seat) => seat.id === 'e')!.bot = difficulty; + const before = structuredClone(view); + const action = botActions(view)[0]; + assert.ok(action, `${difficulty} provides a response`); + assert.deepEqual(view, before); + game = applyAction(game, 'e', action); + assert.ok(game.response?.kind !== 'worthlessKarama' || game.response.passed.includes('e')); + assert.deepEqual(inventory(game), source.cards); + } +}); + +/** Set up pre-Bidding stock, then reach the actual declaration through the + * normal ready and Richese decision actions. No pending response is staged. */ +function declaration(position: 'first' | 'last') { + let game = createGame('BGRICH', newPlayer('r', 'Richese', 'richese'), true, ['choam']); + joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit')); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + Object.assign(game, { + status: 'playing', phase: 2, turn: 2, order: ['r', 'b', 'e'], ready: [], + deck: baseDeck(), richeseCache: richeseCards(), richeseRemoved: [], + }); + for (const seat of game.players) { + seat.hand = []; + seat.spice = 20; + } + const take = (id: string, predicate: (card: Game['deck'][number]) => boolean) => { + const index = game.deck.findIndex(predicate); + assert.ok(index >= 0); + const card = game.deck.splice(index, 1)[0]; + player(game, id).hand.push(card); + return card.id; + }; + const worthless = take('b', (card) => card.kind === 'worthless'); + const printed = take('e', (card) => card.effect === 'karama'); + // The spare keeps the restored Richese response observable after a counter. + const spare = take('e', (card) => card.effect === 'karama'); + const cards = inventory(game); + for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' }); + assert.equal(game.decision?.kind, 'richeseDeclaration'); + assert.equal(game.richeseBidding?.normalCount, 2); + assert.equal(game.richeseBidding?.normalCountBeforeCache, 3); + game = applyAction(game, 'r', { + type: 'decision', event: game.richeseBidding!.event, position, + }); + assert.equal(game.response?.kind, 'richeseAuction'); + assert.equal(game.response.owner, 'r'); + assert.deepEqual(inventory(game), cards); + return { game, cards, worthless, printed, spare, cache: game.richeseCache!.map((card) => card.id) }; +} + +function passWindow(game: Game, kind: 'worthlessKarama' | 'richeseAuction') { + for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) { + const seat = game.players.find((candidate) => !game.response!.passed.includes(candidate.id)); + assert.ok(seat); + game = applyAction(game, seat.id, { type: 'passResponse' }); + } + assert.notEqual(game.response?.kind, kind); + return game; +} + +/** Purchase the first ordinary lot using genuine bids and passes. */ +function buyOrdinaryLot(game: Game) { + const auction = game.auction; + assert.ok(auction); + const card = auction.cards[auction.index].id; + const buyer = auction.active; + game = applyAction(game, buyer, { type: 'bid', amount: 1 }); + for (let count = 0; game.auction?.cards[game.auction.index]?.id === card && count < game.players.length; count++) { + game = applyAction(game, game.auction.active, { type: 'passBid' }); + } + assert.ok(player(game, buyer).hand.some((held) => held.id === card)); + assert.notEqual(game.auction?.cards[game.auction.index]?.id, card); + return game; +} + +for (const position of ['first', 'last'] as const) { + void test(`Advanced BG Worthless cancels the ${position} Richese cache declaration and restores the ordinary lot`, () => { + const source = declaration(position); + let game = source.game; + assert.ok(viewGame(game, 'b').responseControls?.cancelCards.includes(source.worthless)); + game = applyAction(game, 'b', { type: 'card', card: source.worthless, mode: 'cancel' }); + assert.equal(game.response?.kind, 'worthlessKarama'); + assert.equal(game.pendingKarama?.use.kind, 'cancel'); + assert.equal(game.richeseBidding?.cacheCanceled, false); + assert.equal(game.discard.filter((card) => card.id === source.worthless).length, 1); + game = passWindow(game, 'worthlessKarama'); + assert.equal(game.pendingKarama, null); + assert.equal(game.richeseBidding?.cacheCanceled, true); + assert.equal(game.richeseBidding?.normalCount, 3); + assert.equal(game.richeseBidding?.stage, 'normal'); + assert.equal(game.auction?.cards.length, 3); + assert.equal(game.richeseAuction, null); + assert.deepEqual(game.richeseCache?.map((card) => card.id), source.cache); + assert.deepEqual(inventory(game), source.cards); + game = buyOrdinaryLot(game); + assert.equal(game.auction?.index, 1); + assert.deepEqual(game.richeseCache?.map((card) => card.id), source.cache); + assert.deepEqual(inventory(game), source.cards); + }); + + void test(`A printed Karama counters BG's ${position} Richese conversion and restores the original cache declaration`, () => { + const source = declaration(position); + let game = source.game; + const response = structuredClone(game.response); + game = applyAction(game, 'b', { type: 'card', card: source.worthless, mode: 'cancel' }); + assert.equal(game.response?.kind, 'worthlessKarama'); + assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(source.printed)); + game = applyAction(game, 'e', { type: 'card', card: source.printed, mode: 'cancel' }); + assert.equal(game.pendingKarama, null); + assert.deepEqual(game.response, response); + assert.equal(game.richeseBidding?.cacheCanceled, false); + assert.equal(game.richeseBidding?.normalCount, 2); + assert.equal(game.discard.filter((card) => card.id === source.worthless).length, 1); + assert.equal(game.discard.filter((card) => card.id === source.printed).length, 1); + assert.ok(player(game, 'e').hand.some((card) => card.id === source.spare)); + assert.deepEqual(inventory(game), source.cards); + game = passWindow(game, 'richeseAuction'); + if (position === 'first') { + assert.equal(game.decision?.kind, 'richeseCache'); + game = applyAction(game, 'r', { + type: 'decision', event: game.richeseBidding!.event, + card: game.richeseCache![0].id, method: 'silent', + }); + for (const id of ['r', 'b', 'e']) + game = applyAction(game, id, { + type: 'richeseBid', event: game.richeseAuction!.event, amount: 0, allyPayment: 0, + }); + assert.equal(game.decision?.kind, 'richeseUnbid'); + game = applyAction(game, 'r', { type: 'decision', event: game.richeseBidding!.event, keep: false }); + assert.equal(game.richeseRemoved?.length, 1); + } + assert.equal(game.richeseBidding?.stage, 'normal'); + assert.equal(game.richeseBidding?.normalCount, 2); + assert.equal(game.auction?.cards.length, 2); + game = buyOrdinaryLot(game); + assert.equal(game.auction?.index, 1); + assert.deepEqual(inventory(game), source.cards); + }); +}