Connect the optional named-weapon question before skill posture and faction powers. Every accepted question waits for the same target-owned private card-show action, preserving held/absent privacy; matching cards are compulsory, otherwise the owner selects a fallback. Keep cards in hand and retain historical observations only for the questioner and target, without binding later plans. Support actual controls, inspectors, all four AI profiles and saved JSON/SQLite continuation. Normal activation remains disabled pending the user's uniform-response UX exception. The private flag is neither projected nor writable through actions. Legacy battles remain unchanged. Empty hands skip with an explicit unavailable receipt and no invented answer. Source and unresolved boundaries are documented, with a compact current-status dashboard preserving all goal, Git, subagent, usage and saved-game requirements. Independent review passed privacy, custody, sequencing and preview gating. Twenty-two focused tests cover canonical card roles, exact-match/fallback symmetry, target-only controls, captured lower-only behavior, stale/duplicate requests and concurrent SQLite writes. Typecheck, lint, 4605 offline tests, production build and 43 HTTP tests passed with stable source. The first broad run retained one outdated Mentat card-copy assertion; update it to verify private disclosure, custody, optional question and subsequent concealment. Gameplay source was unchanged by that test-only correction. Two genuine-setup sample games deliberately deal Mentat via a controlled initial skill shuffle. Basic: 100 actions, 3 JSON restores, turn-one Harkonnen win. Advanced: 507 actions, 14 restores, turn-five Harkonnen win. Each has one private observation, subsequent Atreides Prescience and no rejected candidate. These bounded force/skill custody samples do not establish general card-custody coverage, calibration or full module acceptance. After the reported outage all 665 opening rooms and original seat/recovery/receipt records were preserved (699 after integration). A fresh 666-room backup preceded checks; database integrity and foreign keys pass. The existing localhost server was reused; its live lock prevented a duplicate start. Existing Ecaz and Ixian browser seats restored their same hands and pending choices. A separate conserved staged Mentat preview restored its private history and posture, then legally concealed its leader before subsequent faction powers. Human/browser paths are distinguished from genuine-setup simulations. No database reset, recurring automation, public activation or publication. Verified source tree: 297ae06e0764d7cad525a802fe383a84971c3e9c5d6362c8cd0b79c5c3977caf Final source tree: 177c98415098ea42174a860d8b1fac4f7c7dd6c3e67c93b18330d1d0fc4edf0e The only post-verification edit removed one surplus newline at the end of the new developer document; its exact before/after content and fingerprints are retained privately. Staged diff checking caught this whitespace issue before commit. No application or test code changed.
430 lines
13 KiB
TypeScript
430 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,
|
|
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),
|
|
};
|
|
}
|