Files
Dune/game/choam-market-ghola.ts
T
Quintine d1bf33d0bc Connect Basic CHOAM skills and preserve market Ghola choices
Connect ordinary Leader Skill paths to Basic CHOAM with base opponents,
the real 35-card Treachery deck and all fourteen skills. Retain wider
development initializer behavior and existing mode/publication gates.

Pause a declared sale until its Ghola revival and private skill choice
finish. Bind the actual revived leader, offer event and completion to the
saved interruption. Preserve nested Truthtrance disposal and exact sale
responses; canceling a resumed sale leaves the free revival completed.
Keep force-only and retained-skill revivals free of replacement offers.

Add genuine setup, native card-sale, mandatory disposal, private custody,
corruption and concurrent authenticated recovery regressions plus a
two-through-six-player complete-game sample profile. Record the unresolved
Sandmaster/native-HMS eligibility question without inventing a bonus.

Validation: types, lint, 5101 offline tests and production build passed.
Five genuine 2-6 player CHOAM Skills games completed with zero rejected
actions. Independent review and 50 focused compatibility tests passed.
Browser Y2UJGMCS exercised real setup, Ghola draw, exact private refresh,
mobile assignment and resumed sale paying two spice once. Screenshot
capture timed out; full visual acceptance remains open.
All 1,583 prior rooms, 3,351 seats and auxiliary records remain intact;
one QA room and human seat were added. No restart, reset or automation.

Full AI implementation and tuning remain deferred until all non-AI
features are complete, then target roughly 75% for each adjacent tier.
2026-09-21 15:10:29 +10:00

164 lines
8.1 KiB
TypeScript

import type { Game, ResponseWindow } from './engine';
import type { ChoamMarket } from './choam-market';
import { quoteChoamSaleCancellation } from './choam-sale-cancellation';
import { CHOAM_AUDITOR_ID } from './cards';
/** The sale is suspended, never replayed. Its physical card stays in its hand. */
export type ChoamMarketGhola = {
turn: number;
phase: number;
player: string;
card: string;
discardSequence: number;
event: string;
stage: 'discard' | 'income' | 'complete';
response: ResponseWindow;
market: ChoamMarket;
/** New Skills interruptions bind their committed revival through the optional choice. */
leaderSkill?: {
leader: string | null;
offer: string | null;
completed: boolean;
signature: string;
};
};
export function choamGholaEvent(pending: Pick<ChoamMarketGhola,
'turn' | 'phase' | 'player' | 'card' | 'discardSequence'>) {
return JSON.stringify(['choamGhola', pending.turn, pending.phase,
pending.player, pending.card, pending.discardSequence]);
}
export function choamGholaSkillSignature(pending: ChoamMarketGhola): string {
const skill = pending.leaderSkill;
return JSON.stringify(['choamGholaSkill', pending.event, skill?.leader,
skill?.offer, skill?.completed]);
}
export function choamGholaSkillOfferEvent(pending: ChoamMarketGhola, leader: string): string {
return JSON.stringify(['choamGholaSkillOffer', pending.event, leader]);
}
function skillRevivalError(
g: Game,
pending: ChoamMarketGhola,
decisions: readonly (Game['decision'] | undefined)[],
): string | null {
const record = pending.leaderSkill;
if (!g.leaderSkills && !record) return null;
if (!g.leaderSkills || !record ||
!(record.leader === null || (typeof record.leader === 'string' && record.leader)) ||
!(record.offer === null || (typeof record.offer === 'string' && record.offer)) ||
typeof record.completed !== 'boolean' || record.signature !== choamGholaSkillSignature(pending))
return 'The saved market Ghola lost its committed Leader Skill revival.';
const owner = g.players.find(player => player.id === pending.player)!;
const leader = owner.leaders.find(candidate => candidate.id === record.leader);
if (record.leader !== null && (!leader || leader.dead || leader.capturedBy || leader.gholaBy))
return 'The market Ghola no longer has its own revived leader.';
const offer = g.leaderSkills.offers[pending.player];
if (record.offer === null) {
if (!record.completed || offer || (record.leader !== null && record.leader !== CHOAM_AUDITOR_ID &&
!g.leaderSkills.assignments.some(assignment => assignment.owner === pending.player)))
return 'This market Ghola cannot omit its optional Leader Skill offer.';
return null;
}
if (record.leader === null)
return 'A market Ghola skill offer must name its revived leader.';
if (record.offer !== choamGholaSkillOfferEvent(pending, record.leader))
return 'The market Ghola skill event no longer matches its committed revival.';
if (record.completed) {
if (offer) return 'The completed market Ghola still has an unresolved skill offer.';
} else {
if (!offer || offer.event !== record.offer || offer.leader !== record.leader)
return 'The market Ghola lost its original leader or optional skill offer.';
// An ordinary-card/Truthtrance disposal may temporarily hold this owned
// decision in its continuation. Its exact event remains required there.
if (pending.stage === 'complete' &&
!decisions.some(decision => decision?.kind === 'leaderSkillRevival' &&
decision.player === pending.player && decision.event === record.offer))
return 'The completed Ghola is waiting for its owned Leader Skill decision.';
}
return null;
}
/** Public timing shared by the server and the hand's ordinary-card controls. */
export function choamSaleGholaTiming(state: {
status: string;
response?: object | null;
decision?: object | null;
choamMarket?: { owner: string } | null;
}) {
const response = state.response as { kind?: string; owner?: string } | null;
return state.status === 'playing' &&
response?.kind === 'choamSale' &&
response.owner === state.choamMarket?.owner && !state.decision;
}
/** No current hand or population requirement: a nested effect may legitimately
* move the sale card or change Tupile before the original declaration resumes. */
export function choamMarketGholaError(g: Game): string | null {
const pending = g.pendingChoamMarketGhola;
const marketOffers = Object.entries(g.leaderSkills?.offers ?? {})
.filter(([, offer]) => typeof offer?.event === 'string' && offer.event.startsWith('["choamGholaSkillOffer",'));
if (marketOffers.length && (!pending || marketOffers.length !== 1 || marketOffers[0][0] !== pending.player))
return 'The Ghola Leader Skill offer lost its suspended market.';
if (!pending) return null;
if (
g.status !== 'playing' || pending.turn !== g.turn || pending.phase !== g.phase ||
!g.players.some((p) => p.id === pending.player) ||
typeof pending.card !== 'string' || !pending.card ||
!Number.isSafeInteger(pending.discardSequence) || pending.discardSequence < 1 ||
pending.discardSequence > (g.treacheryDiscardSequence ?? 0) ||
pending.event !== choamGholaEvent(pending) ||
!['discard', 'income', 'complete'].includes(pending.stage) ||
pending.response?.kind !== 'choamSale' ||
!Array.isArray(pending.response.passed) ||
new Set(pending.response.passed).size !== pending.response.passed.length ||
pending.response.passed.some((id) => !g.players.some((p) => p.id === id)) ||
JSON.stringify(pending.market) !== JSON.stringify(g.choamMarket)
) return 'The saved Ghola interruption no longer matches its CHOAM sale.';
const discard = g.pendingTreacheryDiscard;
if (
(g.resolvedTreacheryDiscardSequence ?? 0) < pending.discardSequence &&
!(discard?.sequence === pending.discardSequence &&
discard.continuation.kind === 'ordinaryCardDiscard' &&
discard.continuation.effect === 'ghola' &&
discard.continuation.player === pending.player &&
discard.continuation.card === pending.card)
) return 'The saved market interruption has no matching committed Ghola discard.';
if (pending.stage === 'discard' && discard?.sequence !== pending.discardSequence)
return 'The saved Ghola discard stage has no matching continuation.';
if (pending.stage !== 'discard' &&
(g.resolvedTreacheryDiscardSequence ?? 0) < pending.discardSequence)
return 'The suspended market is waiting for its committed Ghola discard.';
const continuation = discard?.continuation;
const heldRevival = g.homeworldRevivalReturn;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null,
heldRevival && ['choice', 'arrival'].includes(heldRevival.stage)
? { response: heldRevival.resumeResponse } : null];
const incomes = contexts.flatMap((context) => {
if (!context) return [];
const karama = 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context.response, karama?.use?.kind === 'cancel' ? karama.use.response : null];
}).filter((response) => response?.kind === 'revivalIncome' && response.intent === pending.event);
if (pending.stage === 'income' && !incomes.length)
return 'The saved Ghola income has lost its suspended market receipt.';
if (pending.stage === 'complete' && incomes.length)
return 'Finish the Ghola income before restoring its sale.';
if (incomes.some((income) => income!.recipient !== pending.player || income!.amount !== 1 ||
!g.players.some((p) => p.id === income!.owner && p.faction === 'tleilaxu')))
return 'The saved Ghola income no longer matches the revived faction.';
const skillError = skillRevivalError(g, pending,
contexts.map(context => context && 'decision' in context ? context.decision : undefined));
if (skillError) return skillError;
try {
quoteChoamSaleCancellation(g, pending.response);
} catch (error) {
return error instanceof Error ? error.message : 'Invalid suspended CHOAM sale.';
}
return null;
}