Files
Dune/game/choam-market-ghola.ts
T
Quintine bcca08cac5
Verify and publish checkpoint container / container (push) Successful in 11m25s
Support printed Kull during market Ghola
2026-10-29 08:37:50 +11:00

166 lines
8.3 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, g.pendingKull?.resume,
continuation && (continuation.kind === 'kullSuccessDiscard' ||
continuation.kind === 'kullCounterDiscard') ? continuation.frame.resume : null,
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;
}