Add opt-in CHOAM market skip for the current turn
Verify and publish checkpoint container / container (push) Successful in 10m42s
Verify and publish checkpoint container / container (push) Successful in 10m42s
This commit is contained in:
1 parent
685bd8bdfc
commit
825f903cce
12 files changed
+536
-2
No files matched your search
@@ -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 (
|
||||
<section className="notice" aria-label="CHOAM market preference">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => { if (!busy) act({ type: 'cancelChoamMarketSkip' }); }}
|
||||
>
|
||||
Cancel skipping remaining markets
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,26 @@ export function ChoamMarket({
|
||||
>
|
||||
Finish this phase
|
||||
</Button>
|
||||
{g.choamMarketSkipAvailable && g.choamMarket?.owner === g.me && (
|
||||
<div className="notice">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'decision', done: true, skipMarketsThisTurn: true });
|
||||
}}
|
||||
>
|
||||
Finish and skip remaining markets this turn
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
</p>
|
||||
)}
|
||||
<ChoamMarketAuto game={g} act={act} busy={busy || !!g.response || !!g.truthtrance || !!g.phaseOpening ||
|
||||
(!!g.decision && (g.decision.kind !== 'choamMarket' || g.decision.player !== g.me))} />
|
||||
<PublicDeals game={g} act={(action) => { void send(action); }}
|
||||
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
{!reactionOwnsControls && <Bribes game={g} act={act} busy={busy} />}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Decision, {kind: 'choamMar
|
||||
const owner = getPlayer(g, market.owner);
|
||||
if (decision.kind === 'choamMarket') {
|
||||
if (action.done === true) {
|
||||
if (action.skipMarketsThisTurn !== undefined) {
|
||||
requireRule(action.skipMarketsThisTurn === true &&
|
||||
Object.keys(action).sort().join(',') === 'done,skipMarketsThisTurn,type' &&
|
||||
choamMarketSkipAvailable({...g, decision}, owner),
|
||||
'The opt-in market skip is unavailable during this opportunity.');
|
||||
g.choamMarketSkip = {owner: owner.id, turn: g.turn};
|
||||
log(g, `${owner.name} chose to skip remaining market opportunities this turn.`);
|
||||
}
|
||||
finishChoamMarket(g);
|
||||
} else if (action.mode === 'sell') {
|
||||
const sale = quoteSale(
|
||||
@@ -42237,6 +42291,18 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
ecazAllianceIntegrity(state);
|
||||
if (action?.type === 'cancelChoamMarketSkip') {
|
||||
const player = getPlayer(state, id);
|
||||
requireRule(Object.keys(action).sort().join(',') === 'type' && choamMarketSkipActive(state, player) &&
|
||||
!state.response && !state.truthtrance && !state.phaseOpening && !state.pendingKarama &&
|
||||
!state.pendingKull && !state.pendingTreacheryDiscard && !state.pendingNullentropy &&
|
||||
(!state.decision || state.decision.kind === 'choamMarket' && state.decision.player === id),
|
||||
'Cancel your current market skip when no other interaction is pending.');
|
||||
const g = structuredClone(state);
|
||||
delete g.choamMarketSkip;
|
||||
log(g, `${player.name} canceled their market skip; later markets this turn remain available.`);
|
||||
return g;
|
||||
}
|
||||
if (action?.type === 'dealPay') {
|
||||
requireRule(state.status === 'playing' && Object.keys(action).sort().join(',') === 'deal,type' &&
|
||||
typeof action.deal === 'string', 'Choose your pending deal payment.');
|
||||
@@ -42349,6 +42415,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
const g = structuredClone(state);
|
||||
const owner = getPlayer(g, id);
|
||||
if ((owner.autopilot ?? null) === action.difficulty) return g;
|
||||
if (action.difficulty !== null && g.choamMarketSkip?.owner === id) delete g.choamMarketSkip;
|
||||
if (action.difficulty === null) {
|
||||
delete owner.autopilot;
|
||||
log(g, `${owner.name} took back control from AI.`);
|
||||
@@ -48475,6 +48542,8 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
),
|
||||
}
|
||||
: null,
|
||||
choamMarketSkipAvailable: choamMarketSkipAvailable(g, me),
|
||||
choamMarketSkipActive: choamMarketSkipActive(g, me),
|
||||
choamMarket: g.choamMarket
|
||||
? {
|
||||
owner: g.choamMarket.owner,
|
||||
|
||||
@@ -4962,6 +4962,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'At the end of every phase, CHOAM has a closing opportunity for sales and trades. The next phase waits, including automatic auction, revival and collection effects. Choose Finish this phase when done. At the end of Bidding, an empty hand finishes automatically when public card counts also rule out an incoming gift; a short CHOAM notice explains the continuation.',
|
||||
'Hand counts may remain secret outside Bidding, so those closing windows are preserved. A nonempty hand keeps its opportunity even without an immediately eligible sale: a card effect may prepare one. An empty hand also waits when another player could give a card. These checks preserve private counts and card identities.',
|
||||
'In supported Basic CHOAM/classic games, you may explicitly finish the current market and skip later markets this turn. This waives later sales, trades and gift opportunities; cancel the choice from the persistent table control to use a future market. Current-turn Truthtrance answers or a pending paid deal can keep a market open. The choice expires next turn and is cleared when you delegate to AI.',
|
||||
'Sell a Worthless card for two spice, or reveal two cards with the exact same printed name and sell one surplus copy for three. You retain the matching copy. Repeat to sell further surplus cards; cards sharing only a role do not qualify.',
|
||||
'The sale reveals its card before a Karama response. If allowed, the card is discarded and CHOAM receives the spice. A canceled card stays in hand and cannot be offered for another sale during that closing window. Other eligible cards remain available.',
|
||||
'Once per turn, offer one of your cards to your ally. The ally chooses a card to return or declines. CHOAM then confirms or declines the proposed exchange. Confirmation moves both cards together and spends the turn’s trade; a decline moves neither card.',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ChoamMarket } from '../components/choam-market';
|
||||
import { ChoamMarketAuto } from '../components/choam-market-auto';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
|
||||
// Staged projections isolate the controls; the engine suite exercises actual
|
||||
// market eligibility, turn expiry and the owner-only view.
|
||||
function projected(id = 'c'): GameView {
|
||||
const game = createGame('MARKETCONTROLS', newPlayer('c', 'CHOAM', 'choam'), false, ['choam']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
game.status = 'playing';
|
||||
game.phase = 4;
|
||||
game.turn = 2;
|
||||
game.choamMarket = { owner: 'c', resume: 'phase', blocked: [] };
|
||||
game.decision = { kind: 'choamMarket', player: 'c' };
|
||||
const view = viewGame(game, id);
|
||||
view.choamMarketSkipAvailable = id === 'c';
|
||||
view.choamMarketSkipActive = false;
|
||||
return view;
|
||||
}
|
||||
|
||||
type ButtonProps = { children?: ReactNode; disabled?: boolean; onClick?: () => void };
|
||||
function findButton(node: ReactNode, label: string): ButtonProps | undefined {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<ButtonProps>(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)), '');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<typeof createChoamCharityFundingFixture> | 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);
|
||||
});
|
||||
@@ -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<Game['pendingTreacheryDiscard']>['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);
|
||||
|
||||
Reference in new issue
Block a user