diff --git a/components/choam-market-auto.tsx b/components/choam-market-auto.tsx new file mode 100644 index 00000000..6f10d8fa --- /dev/null +++ b/components/choam-market-auto.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { Button } from '@/components/ui/button'; + +/** The projection is owner-only and remains visible outside the market. */ +export function ChoamMarketAuto({ game, act, busy }: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + if (!game.choamMarketSkipActive) return null; + return ( +
+

+ 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. +

+ +
+ ); +} diff --git a/components/choam-market.tsx b/components/choam-market.tsx index e30d1817..dfe75137 100644 --- a/components/choam-market.tsx +++ b/components/choam-market.tsx @@ -129,6 +129,26 @@ export function ChoamMarket({ > Finish this phase + {g.choamMarketSkipAvailable && g.choamMarket?.owner === g.me && ( +
+

+ 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);