305 lines
10 KiB
TypeScript
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'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>
|
|
);
|
|
}
|