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:
1 parent
af360c836b
commit
06f46c6f79
26 files changed
+1230
-122
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user