Add opt-in CHOAM market skip for the current turn
Verify and publish checkpoint container / container (push) Successful in 10m42s

This commit is contained in:
Quintine committed 2026-10-29 07:08:53 +11:00
1 parent 685bd8bdfc
commit 825f903cce
12 files changed
+536 -2

No files matched your search

+69
View File
@@ -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,
+1
View File
@@ -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.',