Connect capped extra range, two-origin gathering, Fremen cancellation and saved replacement choices. Add human controls and actual first-choice AI paths for all four profiles. Admit the exact nine base green Specials in the actual skilled leader's weapon slot for +2 on survival, with mandatory physical discard and no native utility effect. Reject stale dead-disc plans. Independent movement/battle review closed after the saved-dead-disc fix. 84 focused tests passed. Two four-profile Basic/Advanced samples completed (194/186 accepted actions, six JSON restorations each, zero rejected actions). Basic used both movement branches; Advanced used added range. Browser genuine setup/gather/refresh and a separate targeted Special/traitor-loss fixture passed. Required verification: types, lint, 4435 offline tests, build and 43 HTTP tests passed. First attempt stopped at missing explicit test promise markers; corrected before the successful broad run. Final check/build/HTTP durations: 144157/6638/25048 ms. Source fingerprint (1260 files): bb7be69cf3e925bf7338ea444f77279ed7b37faadc3c44ed7db01b941f27b861 Private report: checkpoints/20260913-planetologist/checks-final/report.json. Preserved all 325 opening games and all original application/seat/recovery records; 359 rooms after 34 new QA/HTTP rooms. SQLite quick_check ok and zero foreign-key violations. Healthy existing server reused; no reset/restart. Boundaries: eight other Leader Skills, enemy-only mixed BG gather response, expansion roster/green-card/optional-module combinations remain unfinished. Winning traitor caller discard remains a documented card-specific inference. No mode or publication gates opened. Efficiency measure: aggregate goal counter 67321978 -> 68710948 (1388970), including context and coordination; not billing tokens or a savings claim. One skill's three connected effects, selective implementation/research/review, one interrupted lint attempt followed by the required complete verification.
306 lines
9.8 KiB
TypeScript
306 lines
9.8 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 activeSkill =
|
|
offer?.cards.find((skill) => skill === selectedSkill) ??
|
|
offer?.cards[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. 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);
|
|
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}
|
|
onChange={() => setSelectedSkill(skill)}
|
|
/>
|
|
Keep {card.name}
|
|
</label>
|
|
<LeaderSkillCard
|
|
skill={skill}
|
|
context="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>
|
|
);
|
|
}
|