Connect the optional named-weapon question before skill posture and faction powers. Every accepted question waits for the same target-owned private card-show action, preserving held/absent privacy; matching cards are compulsory, otherwise the owner selects a fallback. Keep cards in hand and retain historical observations only for the questioner and target, without binding later plans. Support actual controls, inspectors, all four AI profiles and saved JSON/SQLite continuation. Normal activation remains disabled pending the user's uniform-response UX exception. The private flag is neither projected nor writable through actions. Legacy battles remain unchanged. Empty hands skip with an explicit unavailable receipt and no invented answer. Source and unresolved boundaries are documented, with a compact current-status dashboard preserving all goal, Git, subagent, usage and saved-game requirements. Independent review passed privacy, custody, sequencing and preview gating. Twenty-two focused tests cover canonical card roles, exact-match/fallback symmetry, target-only controls, captured lower-only behavior, stale/duplicate requests and concurrent SQLite writes. Typecheck, lint, 4605 offline tests, production build and 43 HTTP tests passed with stable source. The first broad run retained one outdated Mentat card-copy assertion; update it to verify private disclosure, custody, optional question and subsequent concealment. Gameplay source was unchanged by that test-only correction. Two genuine-setup sample games deliberately deal Mentat via a controlled initial skill shuffle. Basic: 100 actions, 3 JSON restores, turn-one Harkonnen win. Advanced: 507 actions, 14 restores, turn-five Harkonnen win. Each has one private observation, subsequent Atreides Prescience and no rejected candidate. These bounded force/skill custody samples do not establish general card-custody coverage, calibration or full module acceptance. After the reported outage all 665 opening rooms and original seat/recovery/receipt records were preserved (699 after integration). A fresh 666-room backup preceded checks; database integrity and foreign keys pass. The existing localhost server was reused; its live lock prevented a duplicate start. Existing Ecaz and Ixian browser seats restored their same hands and pending choices. A separate conserved staged Mentat preview restored its private history and posture, then legally concealed its leader before subsequent faction powers. Human/browser paths are distinguished from genuine-setup simulations. No database reset, recurring automation, public activation or publication. Verified source tree: 297ae06e0764d7cad525a802fe383a84971c3e9c5d6362c8cd0b79c5c3977caf Final source tree: 177c98415098ea42174a860d8b1fac4f7c7dd6c3e67c93b18330d1d0fc4edf0e The only post-verification edit removed one surplus newline at the end of the new developer document; its exact before/after content and fingerprints are retained privately. Staged diff checking caught this whitespace issue before commit. No application or test code changed.
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
'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 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>
|
||
);
|
||
}
|