Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open. Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
303 lines
11 KiB
TypeScript
303 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,
|
|
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),
|
|
};
|
|
}
|