Clarify Bene Gesserit cache cancellation
Verify and publish checkpoint container / container (push) Successful in 11m6s
Verify and publish checkpoint container / container (push) Successful in 11m6s
This commit is contained in:
1 parent
4aecf8eb9c
commit
92d5c0ac2e
6 files changed
+195
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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'],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user