Files
Dune/game/rihani-decipherer.ts
T
Quintine 06f46c6f79 Connect Leader Skill battle effects and private Rihani exchanges
Prototype Mentat/Bureaucrat score effects, automatic Sandmaster board spice, and Rihani inspection plus committed exchange. Preserve private views, physical custody, saved winner cleanup and captive/Suk sequencing. Remaining normal bands, missing skill families and combined modules remain gated.

Validation: 45 focused tests; typecheck, lint, 4465 offline tests, production build and 43 HTTP tests passed. Independent rules/privacy/persistence review completed. New isolated browser setup exercised inspection, draw, pending refresh, exchange and completed refresh. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not trigger Rihani.

Preserved all 393 opening rooms and original seat/recovery rows (427 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported outage; no restart or reset.

Verified source tree: c64ce9537269de94c0d785bc08b17346e826c5797352a636771e1dc6ce5e4859
2026-09-13 19:45:29 +10:00

134 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { BattleLeaderSkill } from './leader-skill-combat';
import { usesSurvivingSkilledLeader } from './leader-skill-combat';
import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
export type RihaniSkill = { leader: string; normal: boolean; skilled: boolean };
export type RihaniReceipt = {
event: string; turn: number; owner: string; skill: RihaniSkill;
stage: 'offer' | 'return' | 'complete';
before: NexusTraitorSnapshot; state: NexusTraitorSnapshot;
deckAfterPeek: string[];
peeked: string[]; drawn: string[]; eligible: string[]; used: string[];
kept: string | null; given: string | null;
signature: string;
};
function requireRihani(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const members = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && same([...a].sort(), [...b].sort());
function shuffled<T>(values: readonly T[], rng: () => number): T[] {
const result = [...values];
for (let i = result.length - 1; i > 0; i--) {
const value = rng();
requireRihani(value >= 0 && value < 1, 'Invalid Traitor Deck shuffle.');
const j = Math.floor(value * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function rihaniSignature(receipt: RihaniReceipt): string {
const { signature: _signature, ...body } = receipt;
return JSON.stringify(body);
}
function signed(receipt: RihaniReceipt): RihaniReceipt {
receipt.signature = rihaniSignature(receipt);
return receipt;
}
export function rihaniVictorySkill(assignments: readonly BattleLeaderSkill[], leader: string | undefined, survives: boolean): RihaniSkill | null {
const a = assignments.find((skill) => skill.skill === 'rihani-decipherer');
if (!a) return null;
const skilled = usesSurvivingSkilledLeader(assignments, 'rihani-decipherer', leader, survives);
const normal = !a.captured && (a.faceUp || skilled) && (a.leader !== leader || survives);
return normal || skilled ? { leader: a.leader, normal, skilled } : null;
}
/** Complete the compulsory private peek before offering the separately optional draw. */
export function beginRihani(snapshot: NexusTraitorSnapshot, universe: readonly string[],
identity: Pick<RihaniReceipt, 'event' | 'turn' | 'owner' | 'skill'>, used: readonly string[], rng: () => number): RihaniReceipt {
validateNexusTraitorSnapshot(snapshot, universe);
const owner = snapshot.players.find((p) => p.id === identity.owner);
requireRihani(owner && owner.faction !== 'tleilaxu', 'Rihani Face Dancer exchanges are still being integrated.');
const eligible = owner.traitors.filter((id) => !used.includes(id));
const optional = identity.skill.skilled && eligible.length > 0;
requireRihani(!(identity.skill.normal || optional) || snapshot.reserve.length >= 2,
'Rihani requires two physical cards in the Traitor Deck.');
const state = structuredClone(snapshot);
const peeked = identity.skill.normal ? shuffled(snapshot.reserve, rng).slice(0, 2) : [];
if (identity.skill.normal) state.reserve = shuffled(state.reserve, rng);
return signed({ ...identity, stage: optional ? 'offer' : 'complete', before: structuredClone(snapshot), state,
deckAfterPeek: [...state.reserve], peeked, eligible, used: [...used], drawn: [], kept: null, given: null, signature: '' });
}
/** Validate recorded physical transitions, then bind a pending choice to current custody. */
export function validateRihani(receipt: RihaniReceipt, universe: readonly string[], current?: NexusTraitorSnapshot) {
requireRihani(receipt.signature === rihaniSignature(receipt) &&
['offer', 'return', 'complete'].includes(receipt.stage) &&
Number.isSafeInteger(receipt.turn) && receipt.turn > 0 && receipt.event &&
typeof receipt.skill.normal === 'boolean' && typeof receipt.skill.skilled === 'boolean' &&
(receipt.skill.normal || receipt.skill.skilled), 'The saved Rihani receipt is invalid.');
validateNexusTraitorSnapshot(receipt.before, universe);
validateNexusTraitorSnapshot(receipt.state, universe);
const old = receipt.before.players.find((p) => p.id === receipt.owner);
const owner = receipt.state.players.find((p) => p.id === receipt.owner);
requireRihani(old && owner && old.faction !== 'tleilaxu' && owner.faction === old.faction &&
same(receipt.before.players.filter((p) => p.id !== receipt.owner), receipt.state.players.filter((p) => p.id !== receipt.owner)),
'Rihani changed another player’s physical Traitors.');
requireRihani(Array.isArray(receipt.used) && new Set(receipt.used).size === receipt.used.length &&
members(receipt.deckAfterPeek, receipt.before.reserve) &&
(receipt.skill.normal || same(receipt.deckAfterPeek, receipt.before.reserve)) &&
same(receipt.eligible, old.traitors.filter((id) => !receipt.used.includes(id))) &&
receipt.peeked.length === (receipt.skill.normal ? 2 : 0) &&
new Set(receipt.peeked).size === receipt.peeked.length &&
receipt.peeked.every((id) => receipt.before.reserve.includes(id)), 'Rihani lost its original inspection or unused cards.');
requireRihani((receipt.drawn.length === 0 || receipt.drawn.length === 2) &&
new Set(receipt.drawn).size === receipt.drawn.length && receipt.drawn.every((id) => receipt.before.reserve.includes(id)),
'Rihani must draw two distinct physical cards.');
if (!receipt.drawn.length) {
requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && same(owner.traitors, old.traitors) &&
same(receipt.state.reserve, receipt.deckAfterPeek), 'The undrawn Rihani offer changed hand custody.');
} else {
requireRihani(receipt.skill.skilled && receipt.eligible.length > 0, 'This Rihani cannot exchange an unused card.');
requireRihani(same(receipt.drawn, receipt.deckAfterPeek.slice(0, 2)), 'Rihani did not draw from the saved deck order.');
const remaining = receipt.deckAfterPeek.slice(2);
if (receipt.stage === 'return') requireRihani(!receipt.kept && !receipt.given &&
same(owner.traitors, [...old.traitors, ...receipt.drawn]) && same(receipt.state.reserve, remaining),
'The pending Rihani return lost its drawn cards.');
else requireRihani(receipt.stage === 'complete' && receipt.kept && receipt.drawn.includes(receipt.kept) &&
receipt.given && receipt.eligible.includes(receipt.given) &&
members(owner.traitors, [...old.traitors.filter((id) => id !== receipt.given), receipt.kept]) &&
members(receipt.state.reserve, [...remaining, receipt.given, ...receipt.drawn.filter((id) => id !== receipt.kept)]),
'The completed Rihani exchange lost its exact new or returned card.');
}
if (receipt.stage === 'offer') requireRihani(receipt.skill.skilled && receipt.eligible.length > 0 && !receipt.drawn.length,
'The Rihani draw offer is no longer available.');
if (current) requireRihani(same(current, receipt.state), 'The Rihani choice no longer matches current Traitor custody.');
}
export function chooseRihaniDraw(receipt: RihaniReceipt, universe: readonly string[], current: NexusTraitorSnapshot, draw: boolean): RihaniReceipt {
validateRihani(receipt, universe, current);
requireRihani(receipt.stage === 'offer', 'This Rihani draw has already been answered.');
const next = structuredClone(receipt);
if (!draw) next.stage = 'complete';
else {
requireRihani(next.state.reserve.length >= 2, 'Rihani requires two physical cards in the Traitor Deck.');
next.drawn = next.state.reserve.splice(0, 2);
next.state.players.find((p) => p.id === next.owner)!.traitors.push(...next.drawn);
next.stage = 'return';
}
signed(next); validateRihani(next, universe); return next;
}
export function finishRihani(receipt: RihaniReceipt, universe: readonly string[], current: NexusTraitorSnapshot,
kept: string, given: string, rng: () => number): RihaniReceipt {
validateRihani(receipt, universe, current);
requireRihani(receipt.stage === 'return' && receipt.drawn.includes(kept) && receipt.eligible.includes(given),
'Keep one newly drawn Traitor and reveal one unused Traitor held before the draw.');
const next = structuredClone(receipt), other = next.drawn.find((id) => id !== kept)!;
const owner = next.state.players.find((p) => p.id === next.owner)!;
owner.traitors = owner.traitors.filter((id) => id !== given && id !== other);
next.state.reserve = shuffled([...next.state.reserve, given, other], rng);
next.kept = kept; next.given = given; next.stage = 'complete';
signed(next); validateRihani(next, universe); return next;
}