Files
Dune/game/richese-cancellation.ts
T
Quintine 58ae84f8e2 Prototype Moritani Assassinate Leaders with saved Mentat replacement
Connect a qualifying native Advanced Moritani battle loss to private reveal/continue controls, live or already-dead opposing-faction targets, printed-strength bank bounty, and automatic Mentat replacement. Keep the revealed physical card held until Mentat, then set it aside as a public per-faction use marker and draw one private card. All four AI profiles use the owner-only legal quote. New local preview profile supports genuine setup without opening public starts.

Keep unresolved normal traitor-call duration and uniform private no-choice UX explicit. Record normal calls and guard later use without claiming a settled permanent loss. Exclude exceptional captured/foreign leader custody, other expansion factions and combined optional modules from this bounded preview. No mode or publication gate opens.

Independent source/privacy/persistence review and 32 focused cases pass. Battle obligations independently bind cleanup, sealed normal-call logs wait for public revelation, physical replacement custody rejects missing cards and forged same-turn chains, and invalid actions remain immutable. Five authenticated SQLite cases cover pending/resolved restoration, Truthtrance interruption, competing submissions, stale/duplicate actions and corrupt saves. The local writer preserves seats and unrelated rooms.

Validation: types, lint, 4690 offline tests, production build and 43 HTTP tests passed with unchanged source. Two genuine Advanced four-profile games finish in 296 and 101 actions with zero rejected candidates, ten periodic JSON rounds and eight restored final seat views. All physical cards, forces and elites remain conserved. Two observed assassination opportunities are declined; these complete games do not establish natural reveal/replacement use.

The separate browser exercise explicitly stages conserved battle forces and one physical traitor swap after genuine setup. Actual battle actions open the choice. Master Bewt dies once and pays three spice; the card remains held through cleanup and is automatically replaced at Mentat with a public set-aside marker. Pending and resolved refresh, readable context-correct inspectors, physical custody and private projection pass. Guild independently collects two spice from Arrakeen. The saved QA game remains at human Terror placement.

After the reported outage, all 801 opening games and original seat/recovery records are preserved (835 after HTTP checks). All 802 rooms and original records immediately before verification also remain unchanged. Database integrity and foreign keys pass. The healthy server was reused without restart, reset or recurring automation.

Verified source tree: ad64f6775fb4c481002cc5eccf6d31e490085d11e10b7b19fb7df4947bcf0ccd
2026-09-14 02:16:58 +10:00

432 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,
moritaniAssassinate: 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),
};
}