deadhead-space-freight-tycoon/apps/web/src/screens/Pilots.tsx

370 lines
14 KiB
TypeScript

import { content } from "@deadhead/content";
import { PilotIndependence } from "../components/IndependentPilots";
import type { Pilot } from "@deadhead/schema";
import { useState } from "react";
import { MS_PER_HOUR } from "@deadhead/sim";
import { Countdown } from "../components/Countdown";
import {
formatCredits,
formatDuration,
formatGameHours,
locationName,
shipModelName,
} from "../format";
import { ShopRest } from "../components/ShopRest";
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. */
export function levelProgress(xp: number, level: number) {
const thresholds = content.config.pilotLevelXpThresholds;
const next = thresholds[level];
if (next === undefined) return null;
const previous = level === 0 ? 0 : thresholds[level - 1]!;
return { xp: xp - previous, needed: next - previous };
}
/** Pilots: levels, skills, assignment, duty and rest; hire, assign, rest and spend skill points (Steps 9.7, 10D.6). */
export function Pilots() {
const state = useLoadedPlayer();
return (
<Screen>
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
<HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && (
<p className="text-sm text-ink-400">
No hired pilots yet. Hire one at a bay to crew another ship.
</p>
)}
<ul className="grid gap-3 @2xl:grid-cols-2">
{state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} />
))}
</ul>
</Screen>
);
}
function HirePilot() {
const state = useLoadedPlayer();
const hire = useGameAction("hirePilot");
const [bay, setBay] = useState(state.bays[0]?.locationId ?? "");
const cost = content.config.pilotHireCost;
const hasBay = state.bays.some((b) => b.locationId === bay);
return (
<Panel tone="amber" className="text-sm">
{state.bays.length === 0 ? (
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
) : (
<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">
<span className="font-display text-xs text-ink-300">Hire at</span>
<select
aria-label="Hire at"
value={bay}
onChange={(e) => setBay(e.target.value)}
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) => (
<option key={b.id} value={b.locationId}>
{locationName(b.locationId)}
</option>
))}
</select>
</label>
<Button
disabled={!hasBay || state.company.credits < cost || hire.isPending}
onClick={() => hire.mutate({ locationId: bay })}
className="ml-auto"
>
Hire pilot ({formatCredits(cost)})
</Button>
</div>
)}
{hire.isError && (
<p role="alert" className="mt-2 text-rose-300">
{errorMessage(hire.error)}
</p>
)}
</Panel>
);
}
function PilotCard({ pilot }: { pilot: Pilot }) {
const state = useLoadedPlayer();
const spend = useGameAction("spendSkillPoint");
const progress = levelProgress(pilot.xp, pilot.level);
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
const initials = pilot.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2);
return (
<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-xs 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">
<span className="truncate font-display text-lg font-bold">
{pilot.name}
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
</span>
<span className="font-display text-xs whitespace-nowrap text-ink-300">
Level {pilot.level}
</span>
</div>
{pilot.isVeteran && (
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-amber-200/90 italic">
{content.veteranPilot.bio}
</p>
)}
<div className="mt-1 text-xs text-ink-400">
{progress
? `${progress.xp} / ${progress.needed} XP to next level`
: `${pilot.xp} XP · max level`}
</div>
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
<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
? "Flying the company loaner"
: ship
? `${ship.name} (${shipModelName(ship.modelId)})`
: "No ship"}
</div>
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
<div
className={`font-display text-xs ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
>
Skill points:{" "}
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
</div>
<ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
{content.skills.map((skill) => {
const level = pilot.skills[skill.id] ?? 0;
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
return (
<li key={skill.id} className="flex items-center justify-between gap-1">
<span className="min-w-0">
<span className="block truncate text-xs text-ink-200">
{skill.name} {level}/{skill.maxLevel}
</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 && (
<button
type="button"
aria-label={`Raise ${skill.name} for ${pilot.name}`}
disabled={spend.isPending}
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
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>
)}
</li>
);
})}
</ul>
{spend.isError && (
<p role="alert" className="mt-1 text-rose-300">
{errorMessage(spend.error)}
</p>
)}
</div>
<PilotIndependence pilot={pilot} />
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
<AssignShip pilot={pilot} />
)}
</Panel>
);
}
/** Real time a rest lasts: at least one contract board period (decision 0.5.46). */
export function restRealMs(): number {
const { restGameHours, timeCompression } = content.config;
return (restGameHours * MS_PER_HOUR) / timeCompression;
}
/** Duty used of the limit, or the rest countdown; and "Rest now" with a confirmation (CR-004). */
function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
const rest = useGameAction("restPilot");
const [confirming, setConfirming] = useState(false);
// Waiting with the ship in the shop counts as rest (decision 0.5.135).
if (pilot.restingInShopUntil !== null) {
return (
<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" />
<ShopRest until={pilot.restingInShopUntil} />
</div>
);
}
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<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" />
</span>
</div>
);
}
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
<div className="mt-3">
<div className="flex items-baseline justify-between font-display text-xs text-ink-400">
<span>Duty</span>
<span className="tracking-normal normal-case">
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</span>
</div>
<Meter
label={`${pilot.name} duty`}
value={used}
max={pilot.dutyLimit}
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
size="sm"
className="mt-1"
/>
{canRest && pilot.status === "idle" && !confirming && (
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)} className="mt-3">
<Icon name="rest" className="size-4" />
Rest now
</Button>
)}
{confirming && (
<div
role="group"
aria-label={`Rest ${pilot.name}?`}
className="mt-3 rounded-lg border border-sky-400/30 bg-sky-950/40 p-3"
>
<p className="text-ink-200">
{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.
</p>
<div className="mt-2 flex gap-2">
<Button
size="sm"
disabled={rest.isPending}
onClick={() =>
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
}
>
Rest {pilot.name}
</Button>
<Button variant="secondary" size="sm" onClick={() => setConfirming(false)}>
Cancel
</Button>
</div>
</div>
)}
{rest.isError && (
<p role="alert" className="mt-1 text-rose-300">
{errorMessage(rest.error)}
</p>
)}
</div>
);
}
/** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */
function AssignShip({ pilot }: { pilot: Pilot }) {
const state = useLoadedPlayer();
const assign = useGameAction("assignPilot");
const ships = state.ships.filter(
(s) => s.status === "idle" && s.pilotId === null && s.locationId === pilot.locationId,
);
const [shipId, setShipId] = useState("");
if (ships.length === 0) {
return (
<p className="mt-3 text-xs text-ink-400">
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
</p>
);
}
return (
<div className="mt-3 flex items-center gap-2">
<select
aria-label={`Assign ${pilot.name} to`}
value={shipId}
onChange={(e) => setShipId(e.target.value)}
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>
{ships.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({shipModelName(s.modelId)})
</option>
))}
</select>
<Button
variant="secondary"
disabled={!shipId || assign.isPending}
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
>
Assign
</Button>
{assign.isError && (
<p role="alert" className="text-rose-300">
{errorMessage(assign.error)}
</p>
)}
</div>
);
}