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
This commit is contained in:
Quintine committed 2026-09-13 19:45:29 +10:00
1 parent af360c836b
commit 06f46c6f79
26 files changed
+1230 -122

No files matched your search

+8
View File
@@ -48,6 +48,8 @@ import { RicheseGift } from './richese-gift';
import { Distrans } from './distrans';
import { JuiceOfSapho } from './juice-of-sapho';
import { LeaderSkillsPanel } from './leader-skills';
import { LeaderSkillBattleGuide } from './leader-skill-battle-guide';
import { RihaniChoice, RihaniHistory } from './rihani-decipherer';
import { SukGraduatePanel } from './suk-graduate';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { OrnithopterMovement } from './ornithopter-movement';
@@ -1464,6 +1466,7 @@ export function GameTable({
<NexusTraitors game={g} act={act} busy={transportBusy || !!me.autopilot} />
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || !!me.autopilot} />
<RihaniHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
@@ -2081,6 +2084,8 @@ export function GameTable({
? 'Position your skilled leader'
: g.decision.kind === 'leaderSkillRevival'
? 'Choose a skill for the revived leader'
: g.decision.kind === 'rihani'
? g.decision.stage === 'offer' ? 'Rihani Decipherer — optional draw' : 'Choose your Traitor exchange'
: g.decision.kind === 'sukRescue'
? 'Save your battle casualties'
: g.decision.kind === 'homeworldRevivalDeployment'
@@ -2883,6 +2888,8 @@ export function GameTable({
</>
) : g.decision.kind === 'sukRescue' ? (
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'rihani' ? (
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'battleLosses' ? (
<>
<p className="muted">
@@ -4282,6 +4289,7 @@ export function GameTable({
</>
) : (
<>
<LeaderSkillBattleGuide game={g} leader={committed.leader ? String(committed.leader.value ?? '') : leader} />
{g.battle.insight && (
<p className="notice">
{committed[g.battle.insight.field]
+23
View File
@@ -0,0 +1,23 @@
import type { GameView } from '@/game/engine';
import { usesSurvivingSkilledLeader } from '@/game/leader-skill-combat';
import { leaderSkillStrongholdCount } from '@/game/leader-skill-battle-board';
/** Guidance uses only the viewer's selected disc and public board occupation. */
export function LeaderSkillBattleGuide({ game, leader }: { game: GameView; leader: string }) {
const battle = game.battle;
if (!battle || ![battle.attacker, battle.defender].includes(game.me)) return null;
const assignments = game.leaderSkills?.assignments.filter((a) => a.controller === game.me) ?? [];
const opponent = game.players.find((p) => p.id === (battle.attacker === game.me ? battle.defender : battle.attacker))!;
const mentat = usesSurvivingSkilledLeader(assignments, 'mentat', leader, true);
const bureaucrat = usesSurvivingSkilledLeader(assignments, 'bureaucrat', leader, true);
const sandmaster = usesSurvivingSkilledLeader(assignments, 'sandmaster', leader, true);
const rihani = usesSurvivingSkilledLeader(assignments, 'rihani-decipherer', leader, true);
if (!mentat && !bureaucrat && !sandmaster && !rihani) return null;
return <div className="notice" aria-label="Leader Skill battle effect">
{mentat && <p>Mentat adds 2 to this leader’s battle strength if the leader survives.</p>}
{bureaucrat && <p>Bureaucrat subtracts {leaderSkillStrongholdCount(game, opponent)} from your opponent’s battle total if your leader survives. Count each occupied stronghold once, including contested strongholds. Advisors do not count.</p>}
{sandmaster && <p>If this Sandmaster survives and wins, add 3 spice to the territory’s existing spice pile automatically. No spice is added when the territory has no spice.</p>}
{rihani && <p>If this Rihani Decipherer survives and wins, you may draw two Traitors and exchange an unused old card for one of them. A native Rihani also privately inspects two deck cards first.</p>}
{(mentat || bureaucrat) && <p className="fine">These score effects resolve automatically. They do not change the leader’s printed value or spice bounty.</p>}
</div>;
}
+2 -1
View File
@@ -112,7 +112,8 @@ export function LeaderSkillsPanel({
This development-only module currently connects the card-role strength
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
Special-card battle use and Suk Graduate force rescue. Other Leader Skill effects are
Special-card battle use, Suk Graduate force rescue, Mentat and Bureaucrat
battle scores, Sandmaster victory spice and Rihani inspections/exchanges. Other Leader Skill effects are
unfinished.
</p>
</div>
+65
View File
@@ -0,0 +1,65 @@
'use client';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { LeaderInspector, type LeaderDisplayIdentity } from './leader-inspector';
import { Button } from './ui/button';
function identity(game: GameView, id: string): LeaderDisplayIdentity | null {
if (id === CHEAP_HERO_TRAITOR) return { name: 'Cheap Hero / Heroine', factionName: 'Any faction', strength: 0, cheapHero: true };
const leader = game.allLeaders.find((l) => l.id === id);
return leader ? { id: leader.id, name: leader.name, factionName: faction(leader.faction).name, strength: leaderStrengthLabel(leader) } : null;
}
function Card({ game, id }: { game: GameView; id: string }) {
const card = identity(game, id);
return card ? <div><span>{card.name}</span> <LeaderInspector identity={card} kind="traitor" /></div> : <p>Card identity unavailable.</p>;
}
export function RihaniHistory({ game }: { game: GameView }) {
const history = game.rihani?.history;
if (!history?.length) return null;
return <details className="notice" aria-label="Your private Rihani history">
<summary>Your private Rihani inspections</summary>
<p className="fine">A peek records the deck at that moment. Later draws can change who holds those cards.</p>
{[...history].reverse().map((r) => <div key={r.event} className="space-y-2 border-t py-2">
<p>Turn {r.turn}</p>
{r.peeked.length > 0 && <><p>Normal inspection — returned to the shuffled deck</p>{r.peeked.map((id) => <Card key={id} game={game} id={id} />)}</>}
{r.drawn.length > 0 && <><p>Separate skilled draw</p>{r.drawn.map((id) => <Card key={id} game={game} id={id} />)}</>}
{r.kept && <p>Kept {identity(game, r.kept)?.name}; revealed and returned {identity(game, r.given!)?.name}.</p>}
</div>)}
</details>;
}
export function RihaniChoice({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
const [kept, setKept] = useState(''), [given, setGiven] = useState('');
const pending = game.rihani?.pending;
if (!pending || pending.owner !== game.me) return null;
const blocked = busy || !!game.truthtrance;
if (pending.stage === 'offer') return <section className="space-y-3" aria-label="Rihani draw offer">
<p>You may draw two Traitor Cards. Once drawn, you must keep one new card by publicly revealing and returning one unused card you already hold.</p>
<p className="fine">The other drawn card returns secretly. Your normal inspection, when available, has already returned its cards to the shuffled deck.</p>
<Button disabled={blocked} onClick={() => !blocked && act({ type: 'decision', event: pending.event, draw: true })}>Draw two Traitors</Button>
<Button variant="outline" disabled={blocked} onClick={() => !blocked && act({ type: 'decision', event: pending.event, draw: false })}>Decline exchange</Button>
</section>;
return <section className="space-y-3" aria-label="Rihani Traitor exchange">
<p>Keep one newly drawn card and reveal one unused card from your old hand. The revealed card and the other new card return to the shuffled deck.</p>
<fieldset><legend>Keep one new Traitor — private</legend>
{pending.drawn?.map((id) => <div key={id} className="space-y-2 py-2">
<label className="flex min-h-11 items-center gap-2"><input type="radio" name="rihani-keep" value={id} checked={kept === id} disabled={blocked} onChange={() => setKept(id)} />Keep {identity(game, id)?.name}</label>
<Card game={game} id={id} />
</div>)}
</fieldset>
<fieldset><legend>Reveal and return one unused old Traitor — public</legend>
{pending.eligible?.map((id) => <div key={id} className="space-y-2 py-2">
<label className="flex min-h-11 items-center gap-2"><input type="radio" name="rihani-give" value={id} checked={given === id} disabled={blocked} onChange={() => setGiven(id)} />Reveal {identity(game, id)?.name}</label>
<Card game={game} id={id} />
</div>)}
</fieldset>
<Button disabled={blocked || !pending.drawn?.includes(kept) || !pending.eligible?.includes(given)} onClick={() => {
if (!blocked && pending.drawn?.includes(kept) && pending.eligible?.includes(given)) act({ type: 'decision', event: pending.event, cards: [kept, given] });
}}>Keep and reveal selected cards</Button>
</section>;
}