Files
Dune/game/karama-context.ts
T
Quintine 9bce22abf5 Resolve advisor conversion after complete Face Dance replacement
Capture surviving alone BG groups at positive remote board sources and zero
replacement targets. Preserve returned armies, replacement, original leader
death, revelation and card cycle. Native Face Dance stays immune; consume the
publicly sourced parent before one original aftermath continuation.

Validation: 16 affected files201/201, types/lint/build, five native Face Dance
and GameTable SSR journeys, eight existing-profile response paths. Genuine
67/66-action sources earn original Ix47, native Face Dancers and actual battle.
Both conversion outcomes preserve nine returned survivors and one leader death;
actual BG city income is2/0. Correct a fixture's expected deck count43 to47
without changing cards or rules. Full combinations/modes remain unfinished.
Not deployed; no saved-room/D1, reset or restart operation. Runtime fingerprint
78239a9def5dd70ae404d5a614a21ea09ea615dd2a6cde7d05760715416d7401;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 21:00:58 +11:00

308 lines
11 KiB
TypeScript

import type { Game, ResponseWindow } from './engine';
/** Identity evidence only: this does not execute or prove a cancellation suffix. */
const battleSource = (g: Game) =>
g.battle
? {
event: g.battle.event,
territory: g.battle.territory,
attacker: g.battle.attacker,
defender: g.battle.defender,
revealed: g.battle.revealed,
}
: null;
const aftermathSource = (g: Game) => ({
lastBattle: g.lastBattle,
context: g.lastBattleContext ?? null,
});
const auctionSource = (g: Game) => ({
auction: g.auction,
sale: g.currentAuctionSale ?? null,
richeseBidding: g.richeseBidding ?? null,
richeseAuction: g.richeseAuction ?? null,
});
const wormSource = (g: Game) => ({
resolution: g.spiceResolution,
sequence: g.spiceSequence,
summoned: g.summonedWorm?.territory ?? null,
});
const movementSource = (g: Game) => ({
active: g.active,
remaining: g.movementRemaining,
});
type SourceSelector = (g: Game, response: ResponseWindow) => unknown;
/** Every supported response has an explicit parent selection. Do not replace
* this with all pending records: independent interruptions may create those. */
const cancellationSources = {
ecazCollection: (g) => ({ pending: g.ecazCollection ?? null }),
ecazOccupy: (g) => ({ battle: battleSource(g), occupy: g.battle?.ecazOccupy ?? null }),
moritaniPlacement: (g) => ({
pending: g.pendingMoritaniPlacement ?? null,
terror: g.moritaniTerror ?? null,
}),
ecazPlacement: (g) => ({
pending: g.pendingEcazPlacement ?? null,
ambassadors: g.ecazAmbassadors ?? null,
placedTurn: g.ecazPlacementTurn,
}),
moritaniAlliance: (g) => ({
entry: g.pendingTerrorEntry ?? null,
...(g.pendingTerrorEntry?.resume === 'ambassador'
? {
ambassador: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
}
: {}),
token: g.moritaniTerror?.tokens.find(
(t) => t.id === g.pendingTerrorEntry?.token,
),
}),
moritaniDuke: (g) => ({
duke: g.dukeVidal ?? null,
acquisitionTurn: g.dukeAcquisitionTurn,
}),
moritaniRetention: (g) => ({
...aftermathSource(g),
retention: g.moritaniRetention ?? null,
}),
voice: (g) => ({
battle: battleSource(g),
voice: g.battle?.voice ?? null,
preparation: g.battle?.preparation ?? null,
borrowedVoice: g.battle?.nexusBorrowedVoice ?? null,
}),
prescience: (g) => ({
battle: battleSource(g),
prescience: g.battle?.prescience ?? null,
preparation: g.battle?.preparation ?? null,
}),
nexusPrescience: (g) => ({
battle: battleSource(g),
inspection: g.battle?.nexusInspection ?? null,
preparation: g.battle?.preparation ?? null,
}),
nexusAdvisorFlip: (g) => ({
...movementSource(g),
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
}),
nexusFremenCunning: (g, response) => ({
...wormSource(g),
appearance: g.nexusFremenCunningRides?.find(ride =>
ride.stage === 'pending' && ride.occurrence.event === response.intent) ?? null,
}),
nexusEcazBetrayal: (g, response) => ({
...movementSource(g),
return: g.nexusEcazBetrayalHistory?.find(record =>
record.stage === 'pending' && record.snapshot.event === response.intent) ?? null,
}),
nexusGuildCunning: (g) => ({cunning:g.nexusGuildCunningHistory?.at(-1),active:g.active,remaining:g.movementRemaining}),
nexusSardaukar: (g) => ({
battle: battleSource(g),
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
}),
advisor: (g, response) =>
response.advisorResume === 'ambassador'
? {
entry: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
}
: null,
emperorIncome: (g, response) =>
response.source === 'ambassador'
? {
entry: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
}
: auctionSource(g),
richesePurchaseIncome: (g) => g.pendingRichesePurchaseIncome ?? null,
richeseGift: (g) => g.pendingRicheseGift ?? null,
richeseNoField: (g, response) => ({
...movementSource(g),
shipment: g.pendingShipment ?? null,
...(response.source === 'ambassadorNoField' ? { ambassador: g.pendingAmbassador ?? null } : {}),
}),
harkonnenBonus: (g, response) =>
response.source === 'ambassador'
? {
entry: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
}
: auctionSource(g),
harkonnenTraitor: (g) => ({
battle: battleSource(g),
plans: g.battle?.plans ?? null,
calls: g.battle?.traitorCalls ?? null,
}),
guildIncome: (g,response) => response.source === 'ambassadorNoFieldIncome' ? {ambassador:g.pendingAmbassador ?? null} : null,
guildTransportPermission: g => ({
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
...(g.pendingHomeworldShipment ? { homeworld: g.pendingHomeworldShipment } : {}),
active: g.active,
blocked: g.guildTransportBlocked ?? null,
purchasedRate: g.karamaShipping ?? null,
}),
guildRate: (g) => ({
pending: g.pendingShipment ?? null,
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
...(g.pendingHomeworldShipment ? { homeworld: g.pendingHomeworldShipment } : {}),
active: g.active,
discountedRateBlocked: g.guildRateBlocked ?? null,
purchasedRate: g.karamaShipping ?? null,
}),
emperorGift: () => null,
emperorRevival: () => null,
stormPeek: (g) => ({ card: g.stormCard, dialers: g.stormDialers }),
stormProtection: (g, response) =>
response.resume === 'storm' ? g.stormResolution : movementSource(g),
wormPlacement: wormSource,
wormSurvival: wormSource,
wormAllyProtection: wormSource,
atreidesAuction: auctionSource,
atreidesSpice: (g) => ({
sequence: g.spiceSequence,
resolution: g.spiceResolution,
}),
guildTiming: (g) => ({
...movementSource(g),
locked: g.guildTimingLocked,
granted: g.guildTimingGranted,
}),
kwisatz: (g) => ({ battle: battleSource(g), checks: g.battle?.powerChecks }),
capture: (g) => ({ ...aftermathSource(g), capture: g.pendingCapture }),
eliteStrength: (g) => ({
battle: battleSource(g),
checks: g.battle?.powerChecks,
}),
fremenSupport: (g) => ({
battle: battleSource(g),
checks: g.battle?.powerChecks,
}),
advisorFlip: (g, response) =>
response.automaticAdvisorFlip
? { source: response.automaticAdvisorFlip, pending: g.pendingAutomaticAdvisorFlips?.[0] ?? null,
transport: response.automaticAdvisorFlip.producer === 'guildTransport' ? g.pendingAutomaticAdvisorTransport ?? null : null,
gamont: response.automaticAdvisorFlip.producer === 'gamont' ? g.pendingAutomaticAdvisorGamont?.receipt ?? null : null,
battle: response.automaticAdvisorFlip.producer === 'battleLosses' ? g.pendingAutomaticAdvisorBattle?.receipt ?? null : null,
homeworld: response.automaticAdvisorFlip.producer === 'homeworldTransport' ? g.pendingAutomaticAdvisorHomeworldTransport?.receipt ?? null : null,
faceDance: response.automaticAdvisorFlip.producer === 'faceDance' ? g.pendingAutomaticAdvisorFaceDance ?? null : null,
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
: response.advisorPreparationSource
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
: response.advisorResume === 'ambassador'
? {
entry: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
}
: response.advisorResume === 'wormRide'
? wormSource(g)
: response.advisorResume === 'declaration'
? { battle: battleSource(g), active: g.active }
: movementSource(g),
fremenRevivalGrant: (g, response) => ({
source: response.fremenGrant ?? null,
ownerAlly: g.players.find(p => p.id === response.owner)?.ally ?? null,
recipientAlly: g.players.find(p => p.id === response.recipient)?.ally ?? null,
granted: g.freeRevival.includes(response.recipient ?? ''),
blocked: g.revivalRules?.fremenGrantBlocked?.includes(response.recipient ?? '') ?? false,
recruits: g.recruits?.turn === g.turn,
}),
bgCharity: () => null,
choamCharity: () => null,
choamInflation: (g) => ({
inflation: g.inflation,
attempted: g.inflationAttemptTurn,
}),
choamSale: (g) => g.choamMarket ?? null,
choamWorthless: (g) => ({
pending: g.pendingChoamWorthless ?? null,
movement: g.pendingChoamWorthless?.movement
? g.pendingChoamMove
: undefined,
revival: g.pendingChoamWorthless?.revival ? g.pendingRevival : undefined,
storm: g.pendingChoamWorthless?.storm ? g.stormResolution : undefined,
}),
choamBattleIncome: (g) => ({
...aftermathSource(g),
income: g.pendingChoamBattleIncome,
}),
choamAudit: (g) => ({ ...aftermathSource(g), audit: g.pendingAuditor }),
choamBattleAid: (g) => ({
battle: battleSource(g),
checks: g.battle?.powerChecks,
}),
// A direct second BG conversion is unavailable while this one is live.
// Printed cancellation restores this saved use; it must never execute it.
worthlessKarama: (g) => g.pendingKarama ?? null,
faceDancerReplacement: (g, response) => ({
replacement: g.players
.find((p) => p.id === response.owner)
?.faceDancers?.find((c) => c.leader === response.intent),
}),
choamRevival: (g) => g.pendingRevival ?? null,
revivalLimit: (g) => g.pendingRevival ?? null,
revivalDiscount: (g) => g.pendingRevival ?? null,
earlyRevival: (g) => g.pendingRevival ?? null,
revivalIncome: (g, response) => response.source === 'ambassadorRevival'
? g.pendingAmbassador?.revivalReceipt ?? null : null,
foreignGhola: (g) => g.pendingRevival ?? null,
ixSubstitution: (g) => ({
...aftermathSource(g),
substitution: g.pendingIxSubstitution,
}),
ixMovement: (g) => ({ ...movementSource(g), movement: g.pendingIxMove }),
fremenMovement: (g) => ({
...movementSource(g),
movement: g.pendingFremenMove,
}),
mobileStronghold: (g) => ({
move: g.pendingMobileMove,
stronghold: g.mobileStronghold,
}),
richeseAuction: auctionSource,
richeseBlackMarket: auctionSource,
ixAuction: (g) => ({ pending: g.ixAuction, round: g.richeseBidding }),
ixTechnology: (g) => ({
...auctionSource(g),
substitution: g.pendingIxTechnology,
}),
ixAllyCard: (g) => ({ ...auctionSource(g), replacement: g.pendingIxAlly }),
} satisfies Record<ResponseWindow['kind'], SourceSelector>;
export class KaramaContextError extends Error {}
export function canceledResponseSource(
g: Game,
response: ResponseWindow,
): unknown {
const select: SourceSelector | undefined = Object.hasOwn(
cancellationSources,
response.kind,
)
? cancellationSources[response.kind]
: undefined;
const owner = g.players.find((p) => p.id === response.owner);
const recipient =
response.recipient === undefined
? undefined
: g.players.find((p) => p.id === response.recipient);
if (
!select ||
!owner ||
(response.recipient !== undefined && !recipient) ||
!Array.isArray(response.passed) ||
new Set(response.passed).size !== response.passed.length ||
response.passed.some((id) => !g.players.some((p) => p.id === id))
)
throw new KaramaContextError(
'The canceled power does not have a valid saved source.',
);
return {
owner: { id: owner.id, faction: owner.faction },
recipient: recipient
? { id: recipient.id, faction: recipient.faction }
: null,
source: select(g, response),
};
}