Files
Dune/components/leader-skills.tsx
T
Quintine d0dc7007d9 Connect Bureaucrat payment redirection and saved continuation
Add an optional owner-controlled two-spice diversion from eligible single-payer ordinary auctions, Guild shipment income, Richese auction proceeds and actual bribes in the bounded base/CHOAM Leader Skills profile. Debit the payer once, credit the recipient once, retain bribes until Mentat Pause and restore the original response/auction/advisor callback. Decline preserves later phase opportunities; successful physical-card usage persists ownership changes. Keep qualifying allied funding and unfinished payment families explicitly guarded or outside the connected batch. Record source authority and the out-of-battle Karama composition and physical-card usage inferences; no public mode or publication gate opens.

Connect readable owner controls and all four AI profiles using public payment/alliance information. Independent review found and fixed duplicate usage-event aliases and an overbroad guard on Bank-bound independent Karama shipments. Focused tests cover native/hidden/captured/dead eligibility, unchanged costs and card/force custody, normal and Richese auction callbacks, real Bene Gesserit advisor continuation, private views, JSON and production SQLite restore, stale/duplicate rejection and same-version competing writes.

Validation: typecheck, lint, 4631 offline tests, production build and 43 HTTP tests passed with unchanged source. Four controlled-shuffle genuine-setup samples completed: base Basic 718 actions/20 restores, base Advanced 551/15, CHOAM Basic 681/19 and CHOAM Advanced 174/5. All had zero rejected candidates and preserved full physical Treachery, force and skill custody. None reached a Bureaucrat payment decision; these are regression-continuation samples, not natural full-game redirection acceptance. The separate conserved staged browser exercise allowed one five-spice bribe and redirected two from a second; the payer spent ten, the payee retained eight deferred spice, and one skill use was recorded. Pending and resolved states survived refresh with unchanged physical custody and private projection.

After the reported outage, all 699 opening games and original seat/recovery records were preserved (733 after HTTP checks); database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation.

Verified source tree: a30615deed306e06ab4c153b9a7eabdc92d646ed79474e348a3500d525bcb8c8
2026-09-14 00:34:35 +10:00

314 lines
11 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.
'use client';
import { useState } from 'react';
import type { Action } from '@/game/engine';
import { leaderSkillCard, type LeaderSkillId } from '@/game/leader-skill-cards';
import type { LeaderSkillsView } from '@/game/leader-skills';
import { Button } from '@/components/ui/button';
type NamedIdentity = Readonly<{ id: string; name: string }>;
export type LeaderSkillsPanelProps = {
skills: LeaderSkillsView | null;
leaders: readonly NamedIdentity[];
players: readonly NamedIdentity[];
act: (action: Action) => void;
busy: boolean;
};
function LeaderSkillCard({
skill,
context,
}: {
skill: LeaderSkillId;
context?: string;
}) {
const card = leaderSkillCard(skill);
return (
<article
aria-label={`${card.name} Leader Skill card`}
className="min-w-0 overflow-hidden rounded-xl border border-[#a88b60] bg-[#20271f] text-[#f2e8d3] shadow-lg"
>
<header className="border-b border-[#a88b60]/40 px-4 py-3">
<p className="m-0 text-xs uppercase tracking-widest">Leader Skill</p>
<h4 className="m-0 break-words font-serif text-xl">{card.name}</h4>
{context && (
<p className="m-0 mt-1 break-words text-sm text-[#edcc8c]">
{context}
</p>
)}
</header>
<details className="group px-4 py-3">
<summary className="min-h-11 cursor-pointer py-2 font-semibold">
Inspect {card.name}
</summary>
<div className="space-y-4 pb-2">
<section
aria-label={`${card.name} normal effect`}
className="space-y-2"
>
<h5 className="m-0 font-serif text-lg">Normal effect</h5>
{card.normal.map((line) => (
<p key={line} className="m-0 leading-7">
{line}
</p>
))}
</section>
<section
aria-label={`${card.name} battle effect`}
className="space-y-2"
>
<h5 className="m-0 font-serif text-lg">Skilled leader in battle</h5>
{card.battle.map((line) => (
<p key={line} className="m-0 leading-7">
{line}
</p>
))}
</section>
</div>
</details>
</article>
);
}
export function LeaderSkillsPanel({
skills,
leaders,
players,
act,
busy,
}: LeaderSkillsPanelProps) {
const [selectedSkill, setSelectedSkill] = useState<LeaderSkillId | null>(
null,
);
const [selectedLeader, setSelectedLeader] = useState<string | null>(null);
if (!skills) return null;
const offer = skills.offer;
const availableSkills =
offer?.cards.filter((skill) => !skills.unavailableSkills?.[skill]) ?? [];
const activeSkill =
availableSkills.find((skill) => skill === selectedSkill) ??
availableSkills[0] ??
null;
const activeLeader =
offer?.leader ??
skills.eligibleLeaders.find((leader) => leader.id === selectedLeader)?.id ??
skills.eligibleLeaders[0]?.id ??
null;
const leaderName = (id: string) =>
leaders.find((leader) => leader.id === id)?.name ?? id;
const playerName = (id: string) =>
players.find((player) => player.id === id)?.name ?? id;
return (
<section
aria-label="Leader Skills"
className="space-y-4 rounded-lg border border-[#a88b60]/50 p-3"
>
<div className="space-y-2">
<h3>Leader Skills</h3>
<p className="fine">
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, Suk Graduate force rescue, Mentat’s private
weapon-question preview and battle bonus, Bureaucrat payment redirection and battle scores, Sandmaster ground-route collection and
victory spice, Rihani inspections and exchanges, optional Smuggler
reserve shipping, Spice Banker battle spending, and Diplomat copying
an opposing base defense with a committed Worthless card after plans
are revealed. Other Leader Skill effects are unfinished.
</p>
</div>
{!!skills.assignments.length && (
<div aria-label="Public Leader Skill assignments" className="space-y-3">
<h4>Public assignments</h4>
<div className="grid gap-3">
{skills.assignments.map((assignment) => (
<LeaderSkillCard
key={`${assignment.owner}:${assignment.leader}`}
skill={assignment.skill}
context={`${playerName(assignment.controller)} · ${leaderName(assignment.leader)} · ${assignment.captured ? 'Captured · battle effect only' : assignment.faceUp ? 'Face up' : 'Behind shield'}`}
/>
))}
</div>
</div>
)}
{offer && offer.cards.length === 0 && offer.leader && (
<section
aria-label="Revived leader skill choice"
className="notice space-y-3"
>
<div>
<h4>Skill for {leaderName(offer.leader)}</h4>
<p>
Decide whether to draw before seeing either private card. Drawing
two requires you to keep one.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="game-action min-h-11 whitespace-normal"
disabled={busy}
onClick={() =>
act({ type: 'leaderSkill', event: offer.event, mode: 'draw' })
}
>
Draw two skills
</Button>
<Button
variant="outline"
className="game-action min-h-11 whitespace-normal"
disabled={busy}
onClick={() =>
act({
type: 'leaderSkill',
event: offer.event,
mode: 'decline',
})
}
>
Continue without a skill
</Button>
</div>
</section>
)}
{offer && offer.cards.length > 0 && (
<form
aria-label="Choose a Leader Skill"
className="notice space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!busy && activeSkill && activeLeader)
act({
type: 'leaderSkill',
event: offer.event,
skill: activeSkill,
leader: activeLeader,
});
}}
>
<div>
<h4>Choose one private skill</h4>
<p>
Keep one physical card and return the other unseen card to the
skill deck.
</p>
</div>
<fieldset className="space-y-3">
<legend className="font-semibold">Choose a skill card</legend>
<div className="grid gap-3">
{offer.cards.map((skill) => {
const card = leaderSkillCard(skill);
const unavailable = skills.unavailableSkills?.[skill];
return (
<div key={skill} className="space-y-2">
<label className="flex min-h-11 items-center gap-2">
<input
type="radio"
name="leader-skill-card"
value={skill}
checked={activeSkill === skill}
disabled={busy || !!unavailable}
onChange={() => setSelectedSkill(skill)}
/>
Keep {card.name}
</label>
{unavailable && <p className="fine">{unavailable}</p>}
<LeaderSkillCard
skill={skill}
context={unavailable ? 'Your private offer · Unavailable' : 'Your private offer'}
/>
</div>
);
})}
</div>
</fieldset>
{offer.leader ? (
<p>
Assigned leader: <strong>{leaderName(offer.leader)}</strong>
</p>
) : (
<label className="block max-w-xl space-y-1">
<span>Assign to an eligible leader</span>
<select
aria-label="Eligible Leader Skill leader"
className="min-h-11 w-full"
value={activeLeader ?? ''}
disabled={busy || !skills.eligibleLeaders.length}
onChange={(event) => setSelectedLeader(event.target.value)}
>
{skills.eligibleLeaders.map((leader) => (
<option key={leader.id} value={leader.id}>
{leader.name}
</option>
))}
</select>
</label>
)}
<Button
type="submit"
className="game-action min-h-11 whitespace-normal"
disabled={busy || !activeSkill || !activeLeader}
>
Assign selected skill
</Button>
<p className="fine">
Once these cards are shown, choosing one is mandatory; there is no
decline action.
</p>
</form>
)}
{skills.battleChoice && (
<section
aria-label="Leader Skill battle visibility"
className="notice space-y-3"
>
<div>
<h4>{leaderName(skills.battleChoice.leader)} in this battle</h4>
<p>
Choose publicly whether the skilled leader remains face up or
moves behind your shield before selecting a Battle Plan leader.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="game-action min-h-11 whitespace-normal"
disabled={busy}
onClick={() =>
act({
type: 'leaderSkillVisibility',
event: skills.battleChoice!.event,
hide: false,
})
}
>
Keep leader face up
</Button>
<Button
className="game-action min-h-11 whitespace-normal"
disabled={busy}
onClick={() =>
act({
type: 'leaderSkillVisibility',
event: skills.battleChoice!.event,
hide: true,
})
}
>
Move leader behind shield
</Button>
</div>
</section>
)}
</section>
);
}