+ Your remaining markets this turn will be skipped, including other sales
+ and trades and opportunities to receive gifts. A current-turn
+ Truthtrance answer or pending paid deal keeps the market open. Cancel
+ this choice to use a future market; you can enable it again only from a
+ future market.
+
+ Skip your remaining markets this turn, including other sales and
+ trades and opportunities to receive gifts. You can cancel this
+ choice later; after canceling, you can enable it again only from a
+ future market.
+
+
+
+ )}
>
);
}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 1f886955..756569f5 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -184,6 +184,7 @@ import { ChoamBattleFunding } from './choam-battle-funding';
import { AuditorDecision, AuditorInsight } from './choam-auditor';
import { ChoamCashIn } from './choam-cash-in';
import { ChoamMarket } from './choam-market';
+import { ChoamMarketAuto } from './choam-market-auto';
import { BattlePromises } from './battle-promises';
import { Truthtrance, TruthHistory } from './truthtrance';
import { TruthCharityPromise } from './truth-charity-promise';
@@ -6051,6 +6052,8 @@ export function GameTable({
: ' · Flips at the next Mentat Pause'}
)}
+ { void send(action); }}
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
{!reactionOwnsControls && }
diff --git a/docs/CHOAM_MARKET_AUTO_AUDIT.md b/docs/CHOAM_MARKET_AUTO_AUDIT.md
index cd92fa88..f85d4d76 100644
--- a/docs/CHOAM_MARKET_AUTO_AUDIT.md
+++ b/docs/CHOAM_MARKET_AUTO_AUDIT.md
@@ -1,5 +1,24 @@
# CHOAM empty-market automatic continuation
+## Human opt-in for remaining markets — 29 October 2026
+
+In a supported Basic CHOAM/classic game, a human CHOAM can explicitly finish
+one market and choose to skip the rest of that turn's closing markets. The
+persistent owner control cancels that choice before a future market. The choice
+expires when the turn changes and is cleared on autopilot handover. A pending
+sale, trade or response is never closed by it. A current-turn Truthtrance
+record or pending paid deal conservatively keeps the market open; this does not
+reveal whether a private answer has been fulfilled. Shared Bidding, optional
+modules, other faction profiles and automated CHOAM seats retain their existing
+behavior.
+
+This is a revocable player preference to decline future optional actions, not a
+publisher timing rule or a default skip. The decision does not inspect hand
+contents, so the separate Bidding-only automatic-empty policy below is unchanged.
+[Runtime](../tests/choam-market-auto-waiver.test.ts),
+[continuation guards](../tests/choam-market-auto-waiver-guards.test.ts) and
+[controls](../tests/choam-market-auto-waiver-controls.test.tsx).
+
## Final integrated verification
Final implementation is **Bidding-only**. The coordinator read the corrected nine tests and independent review `/tmp/dune-market-recovery-review.md`. Three production-room tests in `tests/automatic-response-recovery.test.ts` cover pure private reads, authoritative completion, exactly-once allied escrow refund and notification, racing CAS workers, and preserving a possible incoming card choice. `db/rooms.ts` now schedules market recovery; the engine still decides whether closure is legal. It also schedules existing empty Auditor offers and unaffordable audit payments, covered by a new production recovery case rather than a view mutation.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index f0e67380..cd409bf7 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -17,6 +17,19 @@ open mode gates.
## Current checkpoint and work
+**Opt-in CHOAM market continuation — 29 October. Prototyped / Partial.**
+In supported Basic CHOAM/classic games, a human can finish a market and choose
+to skip later markets for that turn; an owner-only persistent control revokes
+the choice. It expires next turn and clears on AI takeover. The skip ignores
+private hands, waits for a current-turn Truthtrance record or pending paid deal,
+and never interrupts a sale, trade or response. Genuine setup and Storm, later
+phase and Mentat continuations, hidden-hand parity, commitment and sale guards,
+autopilot, rendered controls and the actual table pass. The six-file batch
+passes 43 cases; type checking, lint and the production build pass. An older
+sale-cancellation test now handles the already-supported Semuta response to
+the physical Karama cost. Other profiles and deployed acceptance remain open.
+[Scope](CHOAM_MARKET_AUTO_AUDIT.md#human-opt-in-for-remaining-markets--29-october-2026).
+
**Early Basic leaderless announcement — 29 October. Prototyped / Partial.**
Fresh classic Basic battles without optional modules now announce before either
plan when all of the combatant's controlled leaders are alive but unavailable
diff --git a/game/engine.ts b/game/engine.ts
index 6d2d4dba..a12e1fcc 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -2385,6 +2385,8 @@ export type Game = {
card: Card;
} | null;
choamMarket?: ChoamMarket | null;
+ /** A human CHOAM may waive future markets for this turn, and can revoke it. */
+ choamMarketSkip?: { owner: string; turn: number };
biddingEnd?: BiddingEnd | null;
ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[];
pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null;
@@ -20549,6 +20551,41 @@ function finishHarkonnenExchange(
log(g, `${owner.name} returned ${decision.count} cards to ${target.name}${automatic ? ' automatically; every held card was required' : ''}.`,
{ faction: 'harkonnen', name: 'Harkonnen exchange' });
}
+/** Explicit human consent, independent of every private hand. The first
+ * version excludes optional rules and live commitments needing a combined
+ * completion planner; no future market is forfeited by a hidden-card test. */
+function choamMarketSkipProfile(g: Game, p: Player): boolean {
+ return g.status === 'playing' && !g.advanced && g.expansions.length === 1 &&
+ g.expansions[0] === 'choam' && p.faction === 'choam' && !p.bot && !p.autopilot &&
+ g.players.every(seat => ['choam', 'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) &&
+ !g.ecazTreachery && !g.techTokens && !g.strongholdCards && !g.leaderSkills && !g.homeworlds &&
+ !g.nexusCards && !g.discoveryEnabled && !g.sandtrout && !g.coalitionState;
+}
+function choamMarketSkipCommitments(g: Game): boolean {
+ // Treat the entire turn consistently after any recorded answer, including
+ // already completed answers. Timing must not reveal private fulfillment.
+ const recorded = (rows: readonly {turn: number}[] | undefined) =>
+ !!rows?.some(row => row.turn === g.turn);
+ return !!g.truthHistory?.some(row => row.turn === g.turn) ||
+ recorded(g.travelPromises) || recorded(g.turnActionPromises) || recorded(g.shipmentPromises) ||
+ recorded(g.bidPromises) || recorded(g.phaseBidPromises) || recorded(g.revivalPromises) ||
+ recorded(g.leaderRevivalPromises) || recorded(g.revivalExpressionPromises) || recorded(g.bribePromises) ||
+ recorded(g.bribeExpressionPromises) || recorded(g.pledgePromises) || recorded(g.alliancePromises) ||
+ recorded(g.allianceExpressionPromises) || recorded(g.movementPromises) || recorded(g.movementExpressionPromises) ||
+ recorded(g.futureBattleLeaderPromises) ||
+ !!g.deals?.some(row => row.status === 'active' && row.paymentStatus === 'pending');
+}
+function choamMarketSkipAvailable(g: Game, p: Player): boolean {
+ return choamMarketSkipProfile(g, p) && g.decision?.kind === 'choamMarket' &&
+ g.decision.player === p.id && g.choamMarket?.owner === p.id && !g.biddingEnd &&
+ !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingNullentropy &&
+ !g.pendingRicheseGift && !g.pendingKarama && !g.pendingKull && !g.pendingExchange &&
+ !g.pendingTreacheryDiscard && !g.choamMarket.sale && !g.choamMarket.trade &&
+ !choamMarketSkipCommitments(g);
+}
+function choamMarketSkipActive(g: Game, p: Player): boolean {
+ return choamMarketSkipProfile(g, p) && g.choamMarketSkip?.owner === p.id && g.choamMarketSkip.turn === g.turn;
+}
function finishAutomaticDecision(g: Game): boolean {
if (g.pendingEmperorAuctionGift) return false;
if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g);
@@ -20568,6 +20605,15 @@ function finishAutomaticDecision(g: Game): boolean {
return true;
}
const market = g.choamMarket;
+ if (decision?.kind === 'choamMarket' && market?.owner === decision.player) {
+ const owner = getPlayer(g, market.owner);
+ if (choamMarketSkipActive(g, owner) && choamMarketSkipAvailable(g, owner)) {
+ log(g, `${owner.name} finished the market using their chosen skip for this turn.`,
+ { faction: 'choam', name: 'Market complete' });
+ finishChoamMarket(g);
+ return true;
+ }
+ }
if (
decision?.kind === 'ecazAmbassador' &&
g.pendingAmbassador?.stage === 'ship' &&
@@ -29774,6 +29820,14 @@ function decideChoamMarket(g: Game, decision: Extract void };
+function findButton(node: ReactNode, label: string): ButtonProps | undefined {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement(child)) continue;
+ if (child.props.children === label && child.props.onClick) return child.props;
+ const nested = findButton(child.props.children, label);
+ if (nested) return nested;
+ }
+ return undefined;
+}
+
+void test('the market owner can finish this phase or explicitly waive later markets with informed consent', () => {
+ const game = projected();
+ const actions: Action[] = [];
+ const props = { game, busy: false, act(action: Action) { actions.push(action); } };
+ const html = renderToStaticMarkup(createElement(ChoamMarket, props));
+ assert.match(html, /Finish this phase/);
+ assert.match(html, /Finish and skip remaining markets this turn/);
+ assert.match(html, /other sales and trades and opportunities to receive gifts/);
+ assert.match(html, /enable it again only from a future market/);
+ const tree = ChoamMarket(props);
+ const finish = findButton(tree, 'Finish this phase');
+ const skip = findButton(tree, 'Finish and skip remaining markets this turn');
+ assert.ok(finish && skip);
+ assert.equal(skip.disabled, false);
+ finish.onClick!();
+ skip.onClick!();
+ assert.deepEqual(actions, [
+ { type: 'decision', done: true },
+ { type: 'decision', done: true, skipMarketsThisTurn: true },
+ ]);
+ const busy = findButton(ChoamMarket({ ...props, busy: true }), 'Finish and skip remaining markets this turn');
+ assert.ok(busy?.disabled);
+ busy.onClick!();
+ assert.equal(actions.length, 2);
+});
+
+void test('the skip control is absent without the owner quote or in another market decision', () => {
+ for (const game of [projected('e'), { ...projected(), choamMarketSkipAvailable: false }]) {
+ assert.doesNotMatch(renderToStaticMarkup(createElement(ChoamMarket, { game, busy: false, act() {} })),
+ /Finish and skip remaining markets this turn/);
+ }
+ const reply = projected();
+ reply.decision = { kind: 'choamTradeReply', player: 'c' };
+ assert.doesNotMatch(renderToStaticMarkup(createElement(ChoamMarket, { game: reply, busy: false, act() {} })),
+ /Finish and skip remaining markets this turn/);
+});
+
+void test('the active preference is persistent owner-facing guidance with a busy-safe cancel action', () => {
+ const game = projected();
+ game.choamMarket = null;
+ game.decision = null;
+ game.choamMarketSkipAvailable = false;
+ game.choamMarketSkipActive = true;
+ const actions: Action[] = [];
+ const props = { game, busy: false, act(action: Action) { actions.push(action); } };
+ const html = renderToStaticMarkup(createElement(ChoamMarketAuto, props));
+ assert.match(html, /remaining markets this turn will be skipped/);
+ assert.match(html, /other sales and trades and opportunities to receive gifts/);
+ assert.match(html, /enable it again only from a future market/);
+ const cancel = findButton(ChoamMarketAuto(props), 'Cancel skipping remaining markets');
+ assert.ok(cancel);
+ assert.equal(cancel.disabled, false);
+ cancel.onClick!();
+ assert.deepEqual(actions, [{ type: 'cancelChoamMarketSkip' }]);
+ const busy = findButton(ChoamMarketAuto({ ...props, busy: true }), 'Cancel skipping remaining markets');
+ assert.ok(busy?.disabled);
+ busy.onClick!();
+ assert.equal(actions.length, 1);
+ game.choamMarketSkipActive = false;
+ assert.equal(renderToStaticMarkup(createElement(ChoamMarketAuto, props)), '');
+});
diff --git a/tests/choam-market-auto-waiver-guards.test.ts b/tests/choam-market-auto-waiver-guards.test.ts
new file mode 100644
index 00000000..ac95d0f3
--- /dev/null
+++ b/tests/choam-market-auto-waiver-guards.test.ts
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
+import { actChoamCharityFunding, advanceChoamCharityFunding, choamCharityFundingInventory,
+ createChoamCharityFundingFixture, passChoamCharityFundingResponses } from './fixture-choam-charity-funding';
+import { actTruthtranceChoamPromises, advanceTruthtranceChoamPromises,
+ allowTruthtranceChoamPromisesIncome, createTruthtranceChoamPromisesFixture,
+ finishTruthtranceChoamPromises, truthtranceChoamPromisesInventory } from './fixture-truthtrance-choam-promises';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+/** Genuine public Basic setup and first Storm closing. */
+function firstMarket() {
+ const source = createChoamCharityFundingFixture();
+ return advanceChoamCharityFunding(structuredClone(source.afterSetup), state =>
+ state.turn === 1 && state.phase === 0 && state.decision?.kind === 'choamMarket');
+}
+
+void test('a pending sale and its Karama response finish exactly once before a standing waiver can close the market', () => {
+ const game = firstMarket();
+ const owner = game.players.find(player => player.id === 'choam'); assert.ok(owner);
+ // Preserve all 35 physical cards while staging a saleable original card in
+ // place of CHOAM's original deal. Atreides already genuinely holds Karama.
+ const worthless = game.deck.findIndex(card => card.kind === 'worthless'); assert.ok(worthless >= 0);
+ [owner.hand[0], game.deck[worthless]] = [game.deck[worthless], owner.hand[0]];
+ const card = owner.hand[0], beforeCards = choamCharityFundingInventory(game), beforeSpice = owner.spice;
+ const pending = actChoamCharityFunding(game, owner.id, {type:'decision', mode:'sell', card:card.id});
+ assert.equal(pending.response?.kind, 'choamSale');
+ assert.equal(pending.choamMarket?.sale?.card, card.id);
+ // The sole later-state staging is a previously accepted same-turn preference.
+ pending.choamMarketSkip = {owner:owner.id, turn:pending.turn};
+ const normalized = normalizeAutomaticGame(structuredClone(pending));
+ assert.equal(normalized.response?.kind, 'choamSale');
+ assert.equal(normalized.choamMarket?.sale?.card, card.id);
+ assert.equal(normalized.players.find(player => player.id === owner.id)!.spice, beforeSpice);
+ assert.equal(viewGame(normalized, owner.id).choamMarketSkipAvailable, false);
+ reject(normalized, owner.id, {type:'decision', done:true, skipMarketsThisTurn:true});
+ reject(normalized, owner.id, {type:'cancelChoamMarketSkip'});
+ const settled = passChoamCharityFundingResponses(normalized);
+ assert.equal(settled.response, null);
+ assert.equal(settled.choamMarket, null);
+ assert.equal(settled.phase, 1);
+ assert.equal(settled.players.find(player => player.id === owner.id)!.spice, beforeSpice + 2);
+ assert.equal(settled.discard.filter(item => item.id === card.id).length, 1);
+ assert.deepEqual(choamCharityFundingInventory(settled), beforeCards);
+});
+
+void test('a real binding Truthtrance shipment prevents market skipping until the commitment can proceed', () => {
+ const f = createTruthtranceChoamPromisesFixture({advanced:false, kind:'shipment'});
+ let game = actTruthtranceChoamPromises(structuredClone(f.game), f.asker, f.declarationStep.action);
+ game = advanceTruthtranceChoamPromises(game, state => state.truthtrance?.stage === 'ask');
+ game = actTruthtranceChoamPromises(game, f.asker, f.askStep.action);
+ game = actTruthtranceChoamPromises(game, f.target, {type:'truthAnswer', answer:'yes'});
+ assert.equal(game.shipmentPromises?.[0]?.answer, true);
+ assert.equal(game.shipmentPromises?.[0]?.fulfilled, undefined);
+ // Only the previously accepted same-turn preference is staged; the card,
+ // question and binding answer all came from actual native actions above.
+ game.choamMarketSkip = {owner:f.target, turn:game.turn};
+ game = advanceTruthtranceChoamPromises(game, state => state.phase === 0 && state.decision?.kind === 'choamMarket');
+ const commitment = structuredClone(game.shipmentPromises);
+ const cards = truthtranceChoamPromisesInventory(game);
+ assert.equal(viewGame(game, f.target).choamMarketSkipActive, true);
+ assert.equal(viewGame(game, f.target).choamMarketSkipAvailable, false);
+ const normalized = normalizeAutomaticGame(structuredClone(game));
+ assert.equal(normalized.decision?.kind, 'choamMarket');
+ assert.deepEqual(normalized.shipmentPromises, commitment);
+ reject(normalized, f.target, {type:'decision', done:true, skipMarketsThisTurn:true});
+ game = actTruthtranceChoamPromises(normalized, f.target, {type:'decision', done:true});
+ assert.deepEqual(game.shipmentPromises, commitment);
+ game = advanceTruthtranceChoamPromises(game, state => state.phase === 2 && state.response?.kind === 'choamCharity');
+ game = allowTruthtranceChoamPromisesIncome(game);
+ const completed = finishTruthtranceChoamPromises(f, game);
+ assert.equal(completed.delivered.shipmentPromises?.[0]?.fulfilled, true);
+ assert.equal(completed.delivered.shipmentPromises?.[0]?.released, undefined);
+ assert.deepEqual(truthtranceChoamPromisesInventory(completed.delivered), cards);
+});
+
+void test('malformed opt-in packets cannot waive a market; switching to autopilot cancels an accepted waiver', () => {
+ const market = firstMarket();
+ for (const action of [
+ {type:'decision', done:true, skipMarketsThisTurn:false},
+ {type:'decision', done:true, skipMarketsThisTurn:'true'},
+ {type:'decision', done:true, skipMarketsThisTurn:true, extra:true},
+ ]) reject(market, 'choam', action);
+ let game = actChoamCharityFunding(market, 'choam', {type:'decision', done:true, skipMarketsThisTurn:true});
+ assert.equal(viewGame(game, 'choam').choamMarketSkipActive, true);
+ game = actChoamCharityFunding(game, 'choam', {type:'setAutopilot', difficulty:'Easy'});
+ assert.equal(game.choamMarketSkip, undefined);
+ game = actChoamCharityFunding(game, 'choam', {type:'setAutopilot', difficulty:null});
+ assert.equal(viewGame(game, 'choam').choamMarketSkipActive, false);
+});
diff --git a/tests/choam-market-auto-waiver-table.test.tsx b/tests/choam-market-auto-waiver-table.test.tsx
new file mode 100644
index 00000000..c8ab3bcb
--- /dev/null
+++ b/tests/choam-market-auto-waiver-table.test.tsx
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+import { registerHooks } from 'node:module';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { viewGame, type Game } from '../game/engine';
+import { actChoamCharityFunding, advanceChoamCharityFunding,
+ createChoamCharityFundingFixture } from './fixture-choam-charity-funding';
+
+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();
+
+const render = (game: Game, actor: string, busy = false) =>
+ renderToStaticMarkup(createElement(GameTable, {game:viewGame(game, actor), busy,
+ send:async () => { assert.fail('Rendering must not submit an action.'); }, onExit() {}}));
+
+/** The starting deal, Storm and first market are genuine public Basic actions;
+ * the only chosen positions are the source fixture's unopened starting cards. */
+function firstMarket() {
+ const source = createChoamCharityFundingFixture();
+ return advanceChoamCharityFunding(structuredClone(source.afterSetup), state =>
+ state.turn === 1 && state.phase === 0 && state.decision?.kind === 'choamMarket');
+}
+
+void test('the real CHOAM table offers opt-in, then shows a persistent owner-only cancel control until canceled', () => {
+ const market = firstMarket();
+ assert.deepEqual(market.expansions, ['choam']);
+ assert.equal(market.advanced, false);
+ assert.equal(market.decision?.player, 'choam');
+ const ownerHtml = render(market, 'choam');
+ assert.match(ownerHtml, /Finish and skip remaining markets this turn/);
+ assert.match(ownerHtml, /other sales and trades and opportunities to receive gifts/);
+ assert.doesNotMatch(render(market, 'atreides'), /Finish and skip remaining markets this turn/);
+
+ let game = actChoamCharityFunding(market, 'choam', {type:'decision', done:true, skipMarketsThisTurn:true});
+ assert.equal(game.phase, 1);
+ assert.equal(game.choamMarket, null);
+ assert.equal(viewGame(game, 'choam').choamMarketSkipActive, true);
+ assert.equal(viewGame(game, 'atreides').choamMarketSkipActive, false);
+ for (const state of [game, JSON.parse(JSON.stringify(game)) as Game]) {
+ const html = render(state, 'choam');
+ assert.match(html, /Your remaining markets this turn will be skipped/);
+ assert.match(html, /Cancel skipping remaining markets/);
+ assert.match(html, /enable it again only from a future market/);
+ assert.doesNotMatch(render(state, 'atreides'), /Cancel skipping remaining markets/);
+ assert.doesNotMatch(render(state, 'guild'), /Cancel skipping remaining markets/);
+ }
+ game = actChoamCharityFunding(game, 'choam', {type:'cancelChoamMarketSkip'});
+ assert.equal(viewGame(game, 'choam').choamMarketSkipActive, false);
+ assert.doesNotMatch(render(game, 'choam'), /Cancel skipping remaining markets/);
+});
diff --git a/tests/choam-market-auto-waiver.test.ts b/tests/choam-market-auto-waiver.test.ts
new file mode 100644
index 00000000..1abc550a
--- /dev/null
+++ b/tests/choam-market-auto-waiver.test.ts
@@ -0,0 +1,113 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { actChoamCharityFunding, advanceChoamCharityFunding, createChoamCharityFundingFixture,
+ nextChoamCharityFundingStep } from './fixture-choam-charity-funding';
+
+let fixture: ReturnType | undefined;
+const source = () => fixture ??= createChoamCharityFundingFixture();
+const owner = 'choam', observer = 'atreides';
+const seat = (g: Game, id: string) => {
+ const player = g.players.find(p => p.id === id); assert.ok(player); return player;
+};
+const step = (g: Game) => {
+ const next = nextChoamCharityFundingStep(g); assert.ok(next);
+ return actChoamCharityFunding(g, next.actor, next.action);
+};
+const rejected = (g: Game, id: string, action: {type:string; [key:string]:unknown}) => {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, id, action));
+ assert.deepEqual(g, before);
+};
+
+/** Public Basic CHOAM/Atreides/Guild game, with the fixture's original deal
+ * and genuine setup and Storm actions; stop at the actual first closing market. */
+function firstMarket() {
+ const g = advanceChoamCharityFunding(structuredClone(source().afterSetup), state =>
+ state.turn === 1 && state.phase === 0 && state.decision?.kind === 'choamMarket');
+ assert.deepEqual(g.expansions, ['choam']);
+ assert.equal(g.advanced, false);
+ assert.equal(g.choamMarket?.owner, owner);
+ assert.equal(g.choamMarket.resume, 'storm');
+ return g;
+}
+function optIn(g: Game) {
+ return actChoamCharityFunding(g, owner, {type:'decision', done:true, skipMarketsThisTurn:true});
+}
+
+void test('a human CHOAM can waive remaining markets this turn and the native Storm continuation proceeds', () => {
+ const before = firstMarket();
+ assert.equal(viewGame(before, owner).choamMarketSkipAvailable, true);
+ assert.equal(viewGame(before, owner).choamMarketSkipActive, false);
+ assert.equal(viewGame(before, observer).choamMarketSkipAvailable, false);
+ assert.equal(viewGame(before, observer).choamMarketSkipActive, false);
+ const game = optIn(before);
+ assert.equal(game.turn, 1);
+ assert.equal(game.phase, 1);
+ assert.equal(game.choamMarket, null);
+ assert.equal(viewGame(game, owner).choamMarketSkipActive, true);
+ assert.equal(viewGame(game, observer).choamMarketSkipActive, false);
+ let next = JSON.parse(JSON.stringify(game)) as Game;
+ for (let count = 0; next.turn === 1 && next.phase < 7 && count < 800; count++) {
+ assert.notEqual(next.decision?.kind, 'choamMarket', `Unexpected market in phase ${next.phase}`);
+ next = step(next);
+ }
+ assert.equal(next.turn, 1);
+ assert.equal(next.phase, 7);
+ assert.equal(viewGame(next, owner).choamMarketSkipActive, true);
+ assert.notEqual(next.decision?.kind, 'choamMarket');
+});
+
+void test('waiver continuation does not inspect or depend on CHOAM’s hidden card faces', () => {
+ const original = optIn(firstMarket());
+ const changed = structuredClone(original);
+ const choam = seat(changed, owner), guild = seat(changed, 'guild');
+ assert.ok(choam.hand.length && guild.hand.length);
+ // Swap two actual held cards, preserving all public counts and custody.
+ [choam.hand[0], guild.hand[0]] = [guild.hand[0], choam.hand[0]];
+ assert.deepEqual(viewGame(original, observer), viewGame(changed, observer));
+ for (let game of [original, changed]) {
+ for (let count = 0; game.phase === 1 && count < 100; count++) {
+ assert.notEqual(game.decision?.kind, 'choamMarket');
+ game = step(game);
+ }
+ assert.equal(game.phase, 2);
+ assert.notEqual(game.decision?.kind, 'choamMarket');
+ }
+});
+
+void test('only CHOAM can opt in or revoke, and revocation restores the next real market', () => {
+ const market = firstMarket();
+ rejected(market, observer, {type:'decision', done:true, skipMarketsThisTurn:true});
+ rejected(market, owner, {type:'cancelChoamMarketSkip'});
+ let game = optIn(market);
+ rejected(game, observer, {type:'cancelChoamMarketSkip'});
+ game = actChoamCharityFunding(game, owner, {type:'cancelChoamMarketSkip'});
+ assert.equal(viewGame(game, owner).choamMarketSkipActive, false);
+ rejected(game, owner, {type:'cancelChoamMarketSkip'});
+ game = advanceChoamCharityFunding(game, state => state.phase === 1 && state.decision?.kind === 'choamMarket');
+ assert.equal(game.choamMarket?.owner, owner);
+ assert.equal(viewGame(game, owner).choamMarketSkipAvailable, true);
+ game = actChoamCharityFunding(game, owner, {type:'decision', done:true});
+ assert.equal(viewGame(game, owner).choamMarketSkipActive, false);
+});
+
+for (const mode of ['bot','autopilot'] as const) void test(
+ `the standing waiver does not skip a market when CHOAM is under ${mode} control`, () => {
+ let game = optIn(firstMarket());
+ // A labelled staged controller handover; all game actions before it were native.
+ seat(game, owner)[mode] = 'Easy';
+ game = advanceChoamCharityFunding(game, state => state.phase === 1 && state.decision?.kind === 'choamMarket');
+ assert.equal(game.choamMarket?.owner, owner);
+ },
+);
+
+void test('the waiver expires at the next turn and preserves CHOAM’s final Mentat decision', () => {
+ let game = optIn(firstMarket());
+ game = advanceChoamCharityFunding(game, state => state.turn === 1 && state.decision?.kind === 'choamMentat');
+ assert.equal(game.phase, 8);
+ assert.equal(game.decision?.player, owner);
+ game = advanceChoamCharityFunding(game, state => state.turn === 2 && state.phase === 0 && state.decision?.kind === 'choamMarket');
+ assert.equal(viewGame(game, owner).choamMarketSkipActive, false);
+ assert.equal(viewGame(game, owner).choamMarketSkipAvailable, true);
+});
diff --git a/tests/choam-market.test.ts b/tests/choam-market.test.ts
index b6d2a1c8..148bfef5 100644
--- a/tests/choam-market.test.ts
+++ b/tests/choam-market.test.ts
@@ -224,7 +224,7 @@ void test('CHOAM duplicate sale retains its witness and three spice through Semu
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.decision?.kind, 'choamMarket');
});
-void test('canceled CHOAM sale creates no Semuta event or bank income', () => {
+void test('canceled CHOAM sale offers the spent Karama but creates no sale discard or bank income', () => {
const g = fixture();
g.players[1] = newPlayer('e', 'Richese', 'richese');
g.richeseCache = richeseCards();
@@ -235,8 +235,17 @@ void test('canceled CHOAM sale creates no Semuta event or bank income', () => {
const sold = hold(g, 'c', 'Baliset');
const declaration = sell(ready(g), sold.id);
const karama = declaration.players[2].hand.find(card => card.effect === 'karama')!;
- const canceled = applyAction(declaration, 'b',
+ let canceled = applyAction(declaration, 'b',
{ type: 'card', card: karama.id, mode: 'cancel' });
+ assert.equal(canceled.pendingTreacheryDiscard?.continuation.kind, 'karamaMarketDiscard');
+ const event = canceled.pendingTreacheryDiscard!.batch.event;
+ for (let i = 0; canceled.pendingTreacheryDiscard?.batch.event === event && i < canceled.players.length; i++) {
+ const reaction: NonNullable['reaction'] = canceled.pendingTreacheryDiscard!.reaction;
+ assert.equal(reaction?.stage, 'offer');
+ if (reaction?.stage !== 'offer') throw Error('The Semuta offer is missing.');
+ const player = canceled.players.find(seat => !reaction.passed.includes(seat.id)); assert.ok(player);
+ canceled = applyAction(canceled, player.id, {type:'semutaPass', event});
+ }
assert.ok(!canceled.pendingTreacheryDiscard);
assert.equal(canceled.players[0].spice, 20);
assert.equal(canceled.players[0].hand.filter(card => card.id === sold.id).length, 1);