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

305 lines
10 KiB
TypeScript

import { content } from "@deadhead/content";
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 { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** 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 (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Pilots</h1>
<HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && (
<p className="text-sm text-slate-400">
No hired pilots yet. Hire one at a bay to crew another ship.
</p>
)}
<ul className="space-y-2">
{state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} />
))}
</ul>
</section>
);
}
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 (
<div className="rounded-lg border border-slate-800 p-3 text-sm">
{state.bays.length === 0 ? (
<p className="text-slate-400">Lease a bay to hire pilots there.</p>
) : (
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2">
<span className="text-slate-400">Hire at</span>
<select
aria-label="Hire at"
value={bay}
onChange={(e) => setBay(e.target.value)}
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
>
{state.bays.map((b) => (
<option key={b.id} value={b.locationId}>
{locationName(b.locationId)}
</option>
))}
</select>
</label>
<button
type="button"
disabled={!hasBay || state.company.credits < cost || hire.isPending}
onClick={() => hire.mutate({ locationId: bay })}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
>
Hire pilot ({formatCredits(cost)})
</button>
</div>
)}
{hire.isError && (
<p role="alert" className="mt-2 text-red-400">
{errorMessage(hire.error)}
</p>
)}
</div>
);
}
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;
return (
<li className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
{pilot.name}
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
</span>
<span className="text-slate-400">Level {pilot.level}</span>
</div>
<div className="mt-1 text-slate-400">
{progress
? `${progress.xp} / ${progress.needed} XP to next level`
: `${pilot.xp} XP · max level`}
</div>
<div className="mt-1 text-slate-300">
{pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`}
{" · "}
{lockedToLoaner
? "Flying the company loaner"
: ship
? `${ship.name} (${shipModelName(ship.modelId)})`
: "No ship"}
</div>
<div className="mt-2">
<div className="text-xs text-slate-400">
Skill points:{" "}
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
</div>
<ul className="mt-1 grid grid-cols-2 gap-1">
{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>
{skill.name} {level}/{skill.maxLevel}
</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="rounded border border-amber-500 px-1.5 text-amber-300"
>
+
</button>
)}
</li>
);
})}
</ul>
{spend.isError && (
<p role="alert" className="mt-1 text-red-400">
{errorMessage(spend.error)}
</p>
)}
</div>
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
<AssignShip pilot={pilot} />
)}
</li>
);
}
/** 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);
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}>
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</div>
);
}
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
<div className="mt-2">
<div className="flex items-baseline justify-between text-xs text-slate-400">
<span>Duty</span>
<span>
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</span>
</div>
<div
role="progressbar"
aria-label={`${pilot.name} duty`}
aria-valuemin={0}
aria-valuemax={pilot.dutyLimit}
aria-valuenow={used}
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800"
>
<div
className="h-full bg-amber-400"
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
/>
</div>
{canRest && pilot.status === "idle" && !confirming && (
<button
type="button"
onClick={() => setConfirming(true)}
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
>
Rest now
</button>
)}
{confirming && (
<div
role="group"
aria-label={`Rest ${pilot.name}?`}
className="mt-2 rounded-md bg-slate-900 p-2"
>
<p className="text-slate-300">
{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
type="button"
disabled={rest.isPending}
onClick={() =>
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}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-md border border-slate-700 px-3 py-1"
>
Cancel
</button>
</div>
</div>
)}
{rest.isError && (
<p role="alert" className="mt-1 text-red-400">
{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-2 text-xs text-slate-400">
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
</p>
);
}
return (
<div className="mt-2 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 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
>
<option value="">Choose a ship…</option>
{ships.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({shipModelName(s.modelId)})
</option>
))}
</select>
<button
type="button"
disabled={!shipId || assign.isPending}
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
>
Assign
</button>
{assign.isError && (
<p role="alert" className="text-red-400">
{errorMessage(assign.error)}
</p>
)}
</div>
);
}