Files
Dune/game/richese-cancellation.ts
T
Quintine d0dc7007d9 Connect Bureaucrat payment redirection and saved continuation
Add an optional owner-controlled two-spice diversion from eligible single-payer ordinary auctions, Guild shipment income, Richese auction proceeds and actual bribes in the bounded base/CHOAM Leader Skills profile. Debit the payer once, credit the recipient once, retain bribes until Mentat Pause and restore the original response/auction/advisor callback. Decline preserves later phase opportunities; successful physical-card usage persists ownership changes. Keep qualifying allied funding and unfinished payment families explicitly guarded or outside the connected batch. Record source authority and the out-of-battle Karama composition and physical-card usage inferences; no public mode or publication gate opens.

Connect readable owner controls and all four AI profiles using public payment/alliance information. Independent review found and fixed duplicate usage-event aliases and an overbroad guard on Bank-bound independent Karama shipments. Focused tests cover native/hidden/captured/dead eligibility, unchanged costs and card/force custody, normal and Richese auction callbacks, real Bene Gesserit advisor continuation, private views, JSON and production SQLite restore, stale/duplicate rejection and same-version competing writes.

Validation: typecheck, lint, 4631 offline tests, production build and 43 HTTP tests passed with unchanged source. Four controlled-shuffle genuine-setup samples completed: base Basic 718 actions/20 restores, base Advanced 551/15, CHOAM Basic 681/19 and CHOAM Advanced 174/5. All had zero rejected candidates and preserved full physical Treachery, force and skill custody. None reached a Bureaucrat payment decision; these are regression-continuation samples, not natural full-game redirection acceptance. The separate conserved staged browser exercise allowed one five-spice bribe and redirected two from a second; the payer spent ten, the payee retained eight deferred spice, and one skill use was recorded. Pending and resolved states survived refresh with unchanged physical custody and private projection.

After the reported outage, all 699 opening games and original seat/recovery records were preserved (733 after HTTP checks); database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation.

Verified source tree: a30615deed306e06ab4c153b9a7eabdc92d646ed79474e348a3500d525bcb8c8
2026-09-14 00:34:35 +10:00

431 lines
13 KiB
TypeScript

import type { Decision, Game, ResponseWindow } from './engine';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
// Exhaustive typed discriminants for the existing engine control unions. These
// are envelope checks, not a duplicate implementation of each future choice.
const DECISIONS = {
leaderSkillVisibility: true,
leaderSkillRevival: true,
mentatQuestion: true,
bureaucratPayment: true,
sukRescue: true,
rihani: true,
diplomatDefense: true,
choamAudit: true,
choamAuditPayment: true,
strongholdCopy: true,
richeseBlackMarket: true,
richeseDeclaration: true,
richeseCache: true,
richeseUnbid: true,
richeseAllyShipment: true,
richeseAllyOpportunity: true,
ecazAmbassador: true,
moritaniTerror: true,
moritaniSetup: true,
moritaniPlacement: true,
ecazPlacement: true,
ecazSpice: true,
homeworldRevivalDeployment: true,
caladanReinforcement: true,
grummanCollection: true,
choamStorm: true,
choamMovement: true,
choamMentat: true,
choamFreeRevival: true,
choamBattleFunding: true,
choamMarket: true,
choamTradeReply: true,
choamTradeConfirm: true,
ixSetup: true,
ixAuction: true,
ixTechnology: true,
ixRicheseTechnology: true,
ixAllyCard: true,
mobileStronghold: true,
ixSubstitution: true,
revivalStop: true,
faceDance: true,
techToken: true,
poisonTooth: true,
stoneBurner: true,
fullPlanOffer: true,
fullPlanRead: true,
guildShipment: true,
homeworldShipmentGuild: true,
nullentropy: true,
handExchange: true,
battleLosses: true,
homeworldExplosion: true,
homeworldDefense: true,
captureOffer: true,
capturedLeader: true,
guildTiming: true,
auctionPayment: true,
advisor: true,
intrusion: true,
advisorBattle: true,
stormLosses: true,
wormPlacement: true,
wormProtection: true,
discoveryDiscard: true,
discoveryEntry: true,
ecologicalStorm: true,
greatMakerVote: true,
greatMakerRide: true,
wormRide: true,
moritaniRetention: true,
battleCards: true,
} satisfies Record<Decision['kind'], true>;
const RESPONSES = {
moritaniPlacement: true,
ecazPlacement: true,
ecazCollection: true,
moritaniAlliance: true,
moritaniDuke: true,
moritaniRetention: true,
voice: true,
prescience: true,
nexusPrescience: true,
nexusAdvisorFlip: true,
nexusSardaukar: true,
nexusGuildCunning: true,
advisor: true,
emperorIncome: true,
richesePurchaseIncome: true,
richeseGift: true,
richeseNoField: true,
harkonnenBonus: true,
harkonnenTraitor: true,
guildIncome: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,
stormProtection: true,
wormPlacement: true,
wormSurvival: true,
wormAllyProtection: true,
atreidesAuction: true,
atreidesSpice: true,
guildTiming: true,
kwisatz: true,
capture: true,
eliteStrength: true,
fremenSupport: true,
advisorFlip: true,
bgCharity: true,
choamCharity: true,
choamInflation: true,
choamSale: true,
choamWorthless: true,
choamBattleIncome: true,
choamAudit: true,
choamBattleAid: true,
worthlessKarama: true,
faceDancerReplacement: true,
choamRevival: true,
revivalLimit: true,
revivalDiscount: true,
earlyRevival: true,
revivalIncome: true,
foreignGhola: true,
ixSubstitution: true,
ixMovement: true,
fremenMovement: true,
mobileStronghold: true,
richeseAuction: true,
richeseBlackMarket: true,
ixAuction: true,
ixTechnology: true,
ixAllyCard: true,
} satisfies Record<ResponseWindow['kind'], true>;
export type RicheseCancellationContext = Pick<
Game,
| 'status'
| 'turn'
| 'phase'
| 'advanced'
| 'players'
| 'pendingRicheseGift'
| 'richeseGiftBlocked'
| 'pendingRichesePurchaseIncome'
| 'pendingExchange'
| 'pendingNullentropy'
>;
type GiftResume = NonNullable<Game['pendingRicheseGift']>['resume'];
type IncomeResume = NonNullable<Game['pendingRichesePurchaseIncome']>['resume'];
export type RicheseCancellationQuote =
| {
kind: 'richeseGift';
blocked: NonNullable<Game['richeseGiftBlocked']>;
resume: GiftResume;
}
| { kind: 'richesePurchaseIncome'; resume: IncomeResume };
export class RicheseCancellationError extends Error {
constructor(message: string) {
super(message);
this.name = 'RicheseCancellationError';
}
}
function requireRichese(value: unknown, message: string): asserts value {
if (!value) throw new RicheseCancellationError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function string(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function uniqueStrings(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(string) &&
new Set(value).size === value.length
);
}
/** Pure declaration and saved-control envelope only. The engine validates the
* restored shadow's existing parent integrities; future auto-allowing, gifts,
* purchase payments, draws and decisions are not executed by this quote. */
export function quoteRicheseCancellation(
g: RicheseCancellationContext,
response: ResponseWindow,
): RicheseCancellationQuote | null {
if (
response.kind !== 'richeseGift' &&
response.kind !== 'richesePurchaseIncome'
)
return null;
try {
return calculate(g, response);
} catch (error) {
if (error instanceof RicheseCancellationError) throw error;
throw new RicheseCancellationError(
error instanceof Error
? error.message
: 'Malformed Richese cancellation.',
);
}
}
function calculate(
g: RicheseCancellationContext,
response: ResponseWindow,
): RicheseCancellationQuote {
requireRichese(
g.status === 'playing' &&
Number.isSafeInteger(g.turn) &&
g.turn > 0 &&
Number.isSafeInteger(g.phase) &&
g.phase >= 0 &&
g.phase <= 8,
'Richese cancellation needs a current active turn and phase.',
);
requireRichese(
Array.isArray(g.players) && uniqueStrings(g.players.map((p) => p.id)),
'Richese cancellation seats are malformed.',
);
const seated = (id: unknown) =>
string(id) && g.players.some((p) => p.id === id);
const passes = (value: unknown) =>
uniqueStrings(value) && value.every(seated);
function responseEnvelope(value: unknown): asserts value is ResponseWindow {
requireRichese(
object(value) &&
string(value.kind) &&
Object.hasOwn(RESPONSES, value.kind) &&
seated(value.owner) &&
passes(value.passed) &&
(value.recipient === undefined || seated(value.recipient)),
'A suspended response has an invalid kind, owner, recipient or passes.',
);
}
responseEnvelope(response);
const pending =
response.kind === 'richeseGift'
? g.pendingRicheseGift
: g.pendingRichesePurchaseIncome;
requireRichese(
object(pending) && pending.turn === g.turn && pending.phase === g.phase,
'This Richese cancellation receipt is no longer current.',
);
const root = pending;
const receipt = (r: ResponseWindow, value: unknown) => {
requireRichese(
object(value) && value.turn === g.turn && value.phase === g.phase,
'A restored Richese parent belongs to an earlier turn or phase.',
);
if (r.kind === 'richeseGift') {
const intent = value.intent;
requireRichese(
string(value.event) &&
object(intent) &&
intent.owner === r.owner &&
intent.recipient === r.recipient &&
seated(intent.recipient) &&
intent.recipient !== intent.owner &&
g.players.find((p) => p.id === intent.owner)?.faction === 'richese' &&
RICHESE_CARD_DEFINITIONS.some((d) => d.card.id === intent.cardId),
'The canceled gift has an invalid Richese owner, recipient, event or canonical card.',
);
} else {
requireRichese(
g.advanced &&
value.owner === r.owner &&
r.amount === 3 &&
g.players.find((p) => p.id === r.owner)?.faction === 'emperor' &&
g.players.some((p) => p.faction === 'richese'),
'The canceled purchase income needs its original Emperor and three-spice receipt.',
);
}
};
receipt(response, pending);
// Follow only actual control parent edges. A restored parent must not point
// back to the receipt that this cancellation clears, even after JSON reload.
const controls = (
value: unknown,
includePhase: boolean,
path: Set<object>,
inheritedKarama?: unknown,
) => {
requireRichese(
object(value) && !path.has(value),
'Richese suspended controls contain a recursive parent.',
);
const nextPath = new Set(path);
nextPath.add(value);
requireRichese(
Object.keys(value).every((k) =>
[
'response',
'decision',
'pendingKarama',
...(includePhase ? ['phaseOpening'] : []),
].includes(k),
),
'The Richese receipt contains unrelated restored state.',
);
const followResponse = (r: unknown) => {
if (r === undefined || r === null) return;
responseEnvelope(r);
const parent =
r.kind === 'richeseGift'
? g.pendingRicheseGift
: r.kind === 'richesePurchaseIncome'
? g.pendingRichesePurchaseIncome
: null;
if (r.kind === 'richeseGift' || r.kind === 'richesePurchaseIncome') {
requireRichese(
parent && parent !== root && !nextPath.has(parent),
'Richese suspended controls refer to a cleared or recursive parent.',
);
receipt(r, parent);
const nested = new Set(nextPath);
nested.add(parent);
controls(parent.resume, r.kind === 'richeseGift', nested);
}
};
followResponse(value.response);
if (value.decision !== undefined && value.decision !== null) {
const d = value.decision;
requireRichese(
object(d) &&
string(d.kind) &&
Object.hasOwn(DECISIONS, d.kind) &&
seated(d.player) &&
d.kind !== 'nullentropy',
'A suspended Richese decision has an invalid kind or owner.',
);
if (d.kind === 'handExchange') {
requireRichese(
seated(d.target) &&
d.target !== d.player &&
Number.isSafeInteger(d.count) &&
(d.count as number) > 0 &&
object(g.pendingExchange),
'The suspended hand exchange has no valid original parent.',
);
controls(
g.pendingExchange,
false,
nextPath,
value.pendingKarama ?? inheritedKarama,
);
}
}
if (value.phaseOpening !== undefined && value.phaseOpening !== null) {
const phase = value.phaseOpening;
requireRichese(
object(phase) &&
typeof phase.initialize === 'boolean' &&
passes(phase.passed),
'The suspended phase opening is malformed.',
);
}
if (value.pendingKarama !== undefined && value.pendingKarama !== null) {
const k = value.pendingKarama;
requireRichese(
object(k) &&
seated(k.owner) &&
object(k.use) &&
['cancel', 'shipment', 'purchase', 'auctionPayment'].includes(
String(k.use.kind),
),
'The suspended Karama use is malformed.',
);
if (k.use.kind === 'cancel') followResponse(k.use.response);
if (k.use.kind === 'shipment')
requireRichese(
seated(k.use.recipient) &&
(k.use.card === undefined || string(k.use.card)),
'The suspended Karama shipment is malformed.',
);
if (k.opportunity !== undefined)
requireRichese(
object(k.opportunity) &&
['cancel', 'shipment', 'auction'].includes(
String(k.opportunity.kind),
) &&
string(k.opportunity.signature),
'The suspended Karama source stamp is malformed.',
);
}
requireRichese(
!object(value.response) ||
value.response.kind !== 'worthlessKarama' ||
!!(value.pendingKarama ?? inheritedKarama),
'The suspended BG response has no conversion declaration.',
);
};
controls(pending.resume, response.kind === 'richeseGift', new Set([pending]));
if (response.kind === 'richeseGift') {
const gift = g.pendingRicheseGift!;
const current =
g.richeseGiftBlocked?.turn === g.turn &&
g.richeseGiftBlocked.phase === g.phase
? g.richeseGiftBlocked
: null;
requireRichese(
!current ||
(uniqueStrings(current.cards) &&
current.cards.every((id) =>
RICHESE_CARD_DEFINITIONS.some((d) => d.card.id === id),
) &&
!current.cards.includes(gift.intent.cardId)),
'The canceled gift card was already blocked or its current blocked list is malformed.',
);
return {
kind: 'richeseGift',
blocked: {
turn: g.turn,
phase: g.phase,
cards: [...(current?.cards ?? []), gift.intent.cardId],
},
resume: structuredClone(gift.resume),
};
}
const income = g.pendingRichesePurchaseIncome!;
return {
kind: 'richesePurchaseIncome',
resume: structuredClone(income.resume),
};
}