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
134 lines
8.5 KiB
TypeScript
134 lines
8.5 KiB
TypeScript
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;
|
||
}
|