Prototype Leader Skills setup, battle disciplines and capture
Connect all fourteen physical skills and private setup to five card-role battle modifiers, public posture, Harkonnen skilled capture, death return and optional own-leader revival through controls, legal AI and persisted room actions. Nine skill effects and combined-mode/publication acceptance remain unfinished. Captured replacement entitlement and foreign ghola integration stay guarded. Fresh publisher/designer research supports skilled-capture publicity through its unique known physical card; this is recorded as a component inference. Independent review found and fixed Voice/must-use-leader and invalid saved assignment/offer cases. A pre-existing Discovery privacy assertion confused card ID prefixes; the corrected deterministic physical-pair scenario was independently reviewed. The first broad run was 4410/4411; corrected final verification passes types/lint, all 4411 offline tests, build and 43 HTTP tests. Final durations: check 149741ms, build 6659ms, integration 28274ms. Source fingerprint: aef655ac1ebc0eb2f64294e13d3d27337167312cf8ab17568a166ee9c39f6a85. Private final report: 20260913-leader-skills/checks-final/report.json. Three seeded prototype games completed with no rejected bot candidates. Browser setup/card inspection/refresh passed. Another user-reported power outage stopped the server; backup and necessary restart restored the same private seat. All 292 games at restart and their original application records remain unchanged; 33 isolated HTTP-test rooms bring the current total to 325. No database reset. Efficiency: one shared setup/lifecycle implementation supports five connected role effects; selective existing agents handled catalog/UI/tests/research and independent reviews. One repeated broad check was required by the reproduced ID-prefix assertion failure. Aggregate goal counter rose from 65529766 to 67313673 through this checkpoint (1783907), including inherited context and coordination; this is not a billing or savings estimate. Full goal remains active.
This commit is contained in:
1 parent
369371671f
commit
6e6fecc9d4
28 files changed
+4196
-38
No files matched your search
@@ -47,6 +47,7 @@ import { RicheseSpecialKarama } from './richese-special-karama';
|
||||
import { RicheseGift } from './richese-gift';
|
||||
import { Distrans } from './distrans';
|
||||
import { JuiceOfSapho } from './juice-of-sapho';
|
||||
import { LeaderSkillsPanel } from './leader-skills';
|
||||
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
|
||||
import { OrnithopterMovement } from './ornithopter-movement';
|
||||
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
|
||||
@@ -202,7 +203,11 @@ export function GameTable({
|
||||
.join(', ');
|
||||
const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10;
|
||||
const setupStatus =
|
||||
setupStage === 'prediction'
|
||||
setupStage === 'leaderSkills'
|
||||
? g.leaderSkills?.offer
|
||||
? 'Keep one of your two Leader Skill cards and assign it to an eligible leader. Traitor selection follows when every faction has chosen.'
|
||||
: `Your skill is assigned. Waiting for ${setupPendingNames || 'the remaining players'} to choose.`
|
||||
: setupStage === 'prediction'
|
||||
? setupSisterhood?.id === me.id
|
||||
? 'Seal your secret prediction before traitors or treachery cards are dealt.'
|
||||
: `Waiting for ${setupSisterhood?.name ?? 'Bene Gesserit'} to seal the secret prediction.`
|
||||
@@ -217,13 +222,13 @@ export function GameTable({
|
||||
? setupFremen.id === me.id
|
||||
? g.advanced && setupSisterhood
|
||||
? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.'
|
||||
: 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
|
||||
: g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
|
||||
: `Waiting for ${setupFremen.name} to place the Fremen starting forces.`
|
||||
: g.advanced && setupSisterhood && !setupSisterhood.advisorSetup
|
||||
? setupSisterhood.id === me.id
|
||||
? 'Choose a printed board territory and sector for your starting advisor.'
|
||||
: `Waiting for ${setupSisterhood.name} to place the starting advisor.`
|
||||
: 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.'
|
||||
: g.leaderSkills ? 'Starting placements are finishing. The Storm phase opens automatically.' : 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.'
|
||||
: 'Complete your starting choices. Play begins when everyone is ready.';
|
||||
const automaticEvents = g.log.flatMap((entry) =>
|
||||
entry.automatic
|
||||
@@ -1448,6 +1453,7 @@ export function GameTable({
|
||||
<NexusCards game={g} act={act} busy={busy} />
|
||||
<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} />
|
||||
<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} />
|
||||
@@ -1556,7 +1562,9 @@ export function GameTable({
|
||||
) : g.status === 'setup' && !g.decision ? (
|
||||
<>
|
||||
<h2>
|
||||
{setupStage === 'prediction'
|
||||
{setupStage === 'leaderSkills'
|
||||
? 'Assign your Leader Skill'
|
||||
: setupStage === 'prediction'
|
||||
? 'Seal the prediction'
|
||||
: setupStage === 'traitors'
|
||||
? 'Choose your traitor'
|
||||
@@ -1569,8 +1577,9 @@ export function GameTable({
|
||||
</p>
|
||||
{setupStage && (
|
||||
<p className="fine">
|
||||
Setup order: prediction, traitors, starting forces, then the
|
||||
automatic treachery-card deal and Storm phase.
|
||||
{g.leaderSkills
|
||||
? 'Setup order: prediction, starting Treachery Cards, Leader Skills, traitors, starting forces, then Storm.'
|
||||
: 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}
|
||||
</p>
|
||||
)}
|
||||
{(!setupStage || setupStage === 'forces') &&
|
||||
@@ -2058,6 +2067,10 @@ export function GameTable({
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'grummanCollection'
|
||||
? 'Grumman Collection'
|
||||
: g.decision.kind === 'leaderSkillVisibility'
|
||||
? 'Position your skilled leader'
|
||||
: g.decision.kind === 'leaderSkillRevival'
|
||||
? 'Choose a skill for the revived leader'
|
||||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||||
? 'Revival deployment'
|
||||
: g.decision.kind === 'ecazSpice'
|
||||
@@ -2339,6 +2352,8 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : g.decision.kind === 'leaderSkillVisibility' || g.decision.kind === 'leaderSkillRevival' ? (
|
||||
<p className="muted">Use the Leader Skills controls above to make your choice.</p>
|
||||
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
|
||||
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'caladanReinforcement' ? (
|
||||
@@ -5128,7 +5143,7 @@ export function GameTable({
|
||||
{!me.hand?.length && (
|
||||
<p className="muted">
|
||||
{setupStage
|
||||
? 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
|
||||
? g.leaderSkills ? 'Starting Treachery Cards are dealt after prediction and before skill assignment.' : 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
|
||||
: g.status === 'lobby'
|
||||
? 'Your opening cards are dealt after starting force placement.'
|
||||
: 'Your Treachery hand is empty.'}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
'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. 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user