Files
Dune/game/richese-cancellation.ts
T
Quintine ab0ab52849 Prototype Discovery free entry, Cistern income and carried flight
Connect next-turn optional entry before storm/HMS movement, with typed
multi-sector groups, normal source-storm/occupancy checks, legal bot and human
choices, signed saved arrivals and Bene Gesserit reaction continuation.
Cistern pays its sole occupant two bank spice through canonical Collection.
A carried Ornithopter grants fixed range three on a later-turn actual movement,
preserves pending responses and independent Treachery/No-Field custody, and is
removed only after the selected group moves.

Independent reviews bind pending entry destination baselines, typed custody and
exact reaction continuations. Browser QA caught and fixed an incorrect decision
heading. Orgiz remains disabled pending the stacked-Blow ruling; Jacurutu battle
income, Ecological Testing Station and Shrine remain missing. Mode and publication
gates stay closed; this checkpoint does not certify full Discovery compliance.

Verification: types/lint and 4,313 offline tests passed (49 added), production
build passed, and 40 HTTP tests passed. Final stable run durations: check
139145ms, build 6530ms, integration 24421ms. An earlier run found one stale
Discovery notice assertion, now updated; the next run was interrupted by a
user-confirmed power outage. No interrupted result is claimed as success.

Targeted browser room CA782FQE used genuine human Atreides and two-AI setup,
then physical QA positioning for partial free entry and a three-territory
Ornithopter move. Refresh preserved its private hand, forces, spice and token
removal. The power outage stopped both server and checks; the absent server was
restarted after a private backup. The same browser seat restored correctly and
all 157 pre-restart rooms were unchanged. Final preservation: all 156 opening
rooms unchanged, 31 added QA/integration rooms, total 187. No database reset.

Source fingerprint:
7b639d7f6559a88b00f3cbf913cfa7e6c400bfabb8c392e7b732e8ea77f6dcbc
Private report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-entry-flight/verification-recovered/report.json
Aggregate goal counter 61,819,586 -> 63,110,032 at completed verification:
1,290,446 tokens before final Git overhead for three connected functions,
including helper coordination, reviews, browser QA and interrupted checks.
2026-09-13 14:13:40 +10:00

422 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 = {
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,
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,
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),
};
}