Step 10E.7: pilot cards with skills, XP and duty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:47:22 -04:00
parent 6d5ea7cb48
commit 45a21a8684
2 changed files with 130 additions and 83 deletions

View File

@ -11,6 +11,7 @@ import {
shipModelName, shipModelName,
} from "../format"; } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
/** XP needed for the pilot's current and next level, or null at max level. */ /** XP needed for the pilot's current and next level, or null at max level. */
export function levelProgress(xp: number, level: number) { export function levelProgress(xp: number, level: number) {
@ -25,20 +26,20 @@ export function levelProgress(xp: number, level: number) {
export function Pilots() { export function Pilots() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
return ( return (
<section className="space-y-3 p-4"> <Screen className="max-w-4xl">
<h1 className="text-xl font-semibold">Pilots</h1> <ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
<HirePilot /> <HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && ( {!state.pilots.some((p) => !p.isAvatar) && (
<p className="text-sm text-slate-400"> <p className="text-sm text-ink-400">
No hired pilots yet. Hire one at a bay to crew another ship. No hired pilots yet. Hire one at a bay to crew another ship.
</p> </p>
)} )}
<ul className="space-y-2"> <ul className="grid gap-3 md:grid-cols-2">
{state.pilots.map((pilot) => ( {state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} /> <PilotCard key={pilot.id} pilot={pilot} />
))} ))}
</ul> </ul>
</section> </Screen>
); );
} }
@ -50,18 +51,23 @@ function HirePilot() {
const hasBay = state.bays.some((b) => b.locationId === bay); const hasBay = state.bays.some((b) => b.locationId === bay);
return ( return (
<div className="rounded-lg border border-slate-800 p-3 text-sm"> <Panel feature tone="amber" className="text-sm">
{state.bays.length === 0 ? ( {state.bays.length === 0 ? (
<p className="text-slate-400">Lease a bay to hire pilots there.</p> <p className="text-ink-300">Lease a bay to hire pilots there.</p>
) : ( ) : (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/40">
<Icon name="hire" className="size-5" />
</span>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<span className="text-slate-400">Hire at</span> <span className="font-display text-xs tracking-wider text-ink-300 uppercase">
Hire at
</span>
<select <select
aria-label="Hire at" aria-label="Hire at"
value={bay} value={bay}
onChange={(e) => setBay(e.target.value)} onChange={(e) => setBay(e.target.value)}
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1" className="min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
> >
{state.bays.map((b) => ( {state.bays.map((b) => (
<option key={b.id} value={b.locationId}> <option key={b.id} value={b.locationId}>
@ -70,22 +76,21 @@ function HirePilot() {
))} ))}
</select> </select>
</label> </label>
<button <Button
type="button"
disabled={!hasBay || state.company.credits < cost || hire.isPending} disabled={!hasBay || state.company.credits < cost || hire.isPending}
onClick={() => hire.mutate({ locationId: bay })} onClick={() => hire.mutate({ locationId: bay })}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40" className="ml-auto"
> >
Hire pilot ({formatCredits(cost)}) Hire pilot ({formatCredits(cost)})
</button> </Button>
</div> </div>
)} )}
{hire.isError && ( {hire.isError && (
<p role="alert" className="mt-2 text-red-400"> <p role="alert" className="mt-2 text-rose-300">
{errorMessage(hire.error)} {errorMessage(hire.error)}
</p> </p>
)} )}
</div> </Panel>
); );
} }
@ -96,23 +101,60 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
const ship = state.ships.find((s) => s.id === pilot.assignedShipId); const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null; const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
const initials = pilot.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2);
return ( return (
<li className="rounded-lg border border-slate-800 p-3 text-sm"> <Panel as="li" tone={pilot.resting ? "muted" : "default"} className="flex flex-col text-sm">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className={`relative grid size-14 shrink-0 place-items-center rounded-full font-display text-lg font-bold ring-2 ${
pilot.isAvatar
? "bg-amber-500/15 text-amber-200 ring-amber-400/70 shadow-glow-amber"
: "bg-cyan-500/10 text-cyan-200 ring-cyan-400/50"
}`}
>
{initials}
<span className="absolute -right-1 -bottom-1 grid size-6 place-items-center rounded-full bg-space-950 font-display text-[11px] text-ink-50 ring-2 ring-space-600">
{pilot.level}
</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="font-medium"> <span className="truncate font-display text-lg font-bold tracking-wide">
{pilot.name} {pilot.name}
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>} {pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
</span> </span>
<span className="text-slate-400">Level {pilot.level}</span> <span className="font-display text-xs tracking-wider whitespace-nowrap text-ink-300 uppercase">
Level {pilot.level}
</span>
</div> </div>
<div className="mt-1 text-xs text-ink-400">
<div className="mt-1 text-slate-400">
{progress {progress
? `${progress.xp} / ${progress.needed} XP to next level` ? `${progress.xp} / ${progress.needed} XP to next level`
: `${pilot.xp} XP · max level`} : `${pilot.xp} XP · max level`}
</div> </div>
<div className="mt-1 text-slate-300"> <div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
{pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`} <div
className="h-full rounded-full bg-linear-to-r from-violet-500 to-fuchsia-300"
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
/>
</div>
</div>
</div>
<div className="mt-3 text-ink-200">
{pilot.status === "in-flight" ? (
<Pill tone="cyan" icon="send" className="mr-1.5">
In flight
</Pill>
) : (
`At ${locationName(pilot.locationId ?? "")}`
)}
{" · "} {" · "}
{lockedToLoaner {lockedToLoaner
? "Flying the company loaner" ? "Flying the company loaner"
@ -121,29 +163,41 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
: "No ship"} : "No ship"}
</div> </div>
<div className="mt-2"> <div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
<div className="text-xs text-slate-400"> <div
className={`font-display text-[11px] tracking-[0.14em] uppercase ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
>
Skill points:{" "} Skill points:{" "}
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span> <span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
</div> </div>
<ul className="mt-1 grid grid-cols-2 gap-1"> <ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
{content.skills.map((skill) => { {content.skills.map((skill) => {
const level = pilot.skills[skill.id] ?? 0; const level = pilot.skills[skill.id] ?? 0;
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel; const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
return ( return (
<li key={skill.id} className="flex items-center justify-between gap-1"> <li key={skill.id} className="flex items-center justify-between gap-1">
<span> <span className="min-w-0">
<span className="block truncate text-xs text-ink-200">
{skill.name} {level}/{skill.maxLevel} {skill.name} {level}/{skill.maxLevel}
</span> </span>
<span aria-hidden="true" className="mt-0.5 flex gap-0.5">
{Array.from({ length: skill.maxLevel }, (_, i) => (
<span
key={i}
className={`h-1.5 w-3 rounded-sm ${i < level ? "bg-cyan-300 shadow-[0_0_4px] shadow-cyan-300/70" : "bg-space-700"}`}
/>
))}
</span>
</span>
{canRaise && ( {canRaise && (
<button <button
type="button" type="button"
aria-label={`Raise ${skill.name} for ${pilot.name}`} aria-label={`Raise ${skill.name} for ${pilot.name}`}
disabled={spend.isPending} disabled={spend.isPending}
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })} onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
className="rounded border border-amber-500 px-1.5 text-amber-300" className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
> >
+ <Icon name="plus" className="size-4" />
</button> </button>
)} )}
</li> </li>
@ -151,7 +205,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
})} })}
</ul> </ul>
{spend.isError && ( {spend.isError && (
<p role="alert" className="mt-1 text-red-400"> <p role="alert" className="mt-1 text-rose-300">
{errorMessage(spend.error)} {errorMessage(spend.error)}
</p> </p>
)} )}
@ -162,7 +216,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && ( {!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
<AssignShip pilot={pilot} /> <AssignShip pilot={pilot} />
)} )}
</li> </Panel>
); );
} }
@ -179,75 +233,68 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
if (pilot.resting && pilot.restEndsAt !== null) { if (pilot.resting && pilot.restEndsAt !== null) {
return ( return (
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}> <div
className="mt-3 flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-950/40 px-3 py-2 text-sky-200"
aria-label={`${pilot.name} rest`}
>
<Icon name="rest" className="size-4 shrink-0" />
<span>
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" /> Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</span>
</div> </div>
); );
} }
const used = Math.min(pilot.dutyHours, pilot.dutyLimit); const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return ( return (
<div className="mt-2"> <div className="mt-3">
<div className="flex items-baseline justify-between text-xs text-slate-400"> <div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<span>Duty</span> <span>Duty</span>
<span> <span className="tracking-normal normal-case">
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)} {formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</span> </span>
</div> </div>
<div <Meter
role="progressbar" label={`${pilot.name} duty`}
aria-label={`${pilot.name} duty`} value={used}
aria-valuemin={0} max={pilot.dutyLimit}
aria-valuemax={pilot.dutyLimit} tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
aria-valuenow={used} size="sm"
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800" className="mt-1"
>
<div
className="h-full bg-amber-400"
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
/> />
</div>
{canRest && pilot.status === "idle" && !confirming && ( {canRest && pilot.status === "idle" && !confirming && (
<button <Button variant="secondary" size="sm" onClick={() => setConfirming(true)} className="mt-3">
type="button" <Icon name="rest" className="size-4" />
onClick={() => setConfirming(true)}
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
>
Rest now Rest now
</button> </Button>
)} )}
{confirming && ( {confirming && (
<div <div
role="group" role="group"
aria-label={`Rest ${pilot.name}?`} aria-label={`Rest ${pilot.name}?`}
className="mt-2 rounded-md bg-slate-900 p-2" className="mt-3 rounded-lg border border-sky-400/30 bg-sky-950/40 p-3"
> >
<p className="text-slate-300"> <p className="text-ink-200">
{pilot.name} rests for {formatDuration(restRealMs())} and can&apos;t fly until then. {pilot.name} rests for {formatDuration(restRealMs())} and can&apos;t fly until then.
Rest always lasts at least one board refresh, so they wake to new contracts. Rest always lasts at least one board refresh, so they wake to new contracts.
</p> </p>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<button <Button
type="button" size="sm"
disabled={rest.isPending} disabled={rest.isPending}
onClick={() => onClick={() =>
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) }) rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
} }
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
> >
Rest {pilot.name} Rest {pilot.name}
</button> </Button>
<button <Button variant="secondary" size="sm" onClick={() => setConfirming(false)}>
type="button"
onClick={() => setConfirming(false)}
className="rounded-md border border-slate-700 px-3 py-1"
>
Cancel Cancel
</button> </Button>
</div> </div>
</div> </div>
)} )}
{rest.isError && ( {rest.isError && (
<p role="alert" className="mt-1 text-red-400"> <p role="alert" className="mt-1 text-rose-300">
{errorMessage(rest.error)} {errorMessage(rest.error)}
</p> </p>
)} )}
@ -266,18 +313,18 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
if (ships.length === 0) { if (ships.length === 0) {
return ( return (
<p className="mt-2 text-xs text-slate-400"> <p className="mt-3 text-xs text-ink-400">
No uncrewed ships at {locationName(pilot.locationId ?? "")}. No uncrewed ships at {locationName(pilot.locationId ?? "")}.
</p> </p>
); );
} }
return ( return (
<div className="mt-2 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<select <select
aria-label={`Assign ${pilot.name} to`} aria-label={`Assign ${pilot.name} to`}
value={shipId} value={shipId}
onChange={(e) => setShipId(e.target.value)} onChange={(e) => setShipId(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1" className="min-w-0 flex-1 min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
> >
<option value="">Choose a ship…</option> <option value="">Choose a ship…</option>
{ships.map((s) => ( {ships.map((s) => (
@ -286,16 +333,15 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
</option> </option>
))} ))}
</select> </select>
<button <Button
type="button" variant="secondary"
disabled={!shipId || assign.isPending} disabled={!shipId || assign.isPending}
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })} onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
> >
Assign Assign
</button> </Button>
{assign.isError && ( {assign.isError && (
<p role="alert" className="text-red-400"> <p role="alert" className="text-rose-300">
{errorMessage(assign.error)} {errorMessage(assign.error)}
</p> </p>
)} )}

View File

@ -185,3 +185,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
- [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9). - [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9).
- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9). - [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).
- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed. - [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.