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 (
{!state.pilots.some((p) => !p.isAvatar) && (
No hired pilots yet. Hire one at a bay to crew another ship.
)}
{state.pilots.map((pilot) => (
))}
);
}
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 (
{state.bays.length === 0 ? (
Lease a bay to hire pilots there.
) : (
Hire at
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) => (
{locationName(b.locationId)}
))}
hire.mutate({ locationId: bay })}
className="ml-auto"
>
Hire pilot ({formatCredits(cost)})
)}
{hire.isError && (
{errorMessage(hire.error)}
)}
);
}
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 (
{initials}
{pilot.level}
{pilot.name}
{pilot.isAvatar && (you) }
Level {pilot.level}
{pilot.isVeteran && (
{content.veteranPilot.bio}
)}
{progress
? `${progress.xp} / ${progress.needed} XP to next level`
: `${pilot.xp} XP · max level`}
{pilot.status === "in-flight" ? (
In flight
) : (
`At ${locationName(pilot.locationId ?? "")}`
)}
{" · "}
{lockedToLoaner
? "Flying the company loaner"
: ship
? `${ship.name} (${shipModelName(ship.modelId)})`
: "No ship"}
0 ? "text-amber-300" : "text-ink-400"}`}
>
Skill points:{" "}
{pilot.skillPointsAvailable}
{content.skills.map((skill) => {
const level = pilot.skills[skill.id] ?? 0;
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
return (
{skill.name} {level}/{skill.maxLevel}
{Array.from({ length: skill.maxLevel }, (_, i) => (
))}
{canRaise && (
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"
>
)}
);
})}
{spend.isError && (
{errorMessage(spend.error)}
)}
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
)}
);
}
/** 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 (
);
}
if (pilot.resting && pilot.restEndsAt !== null) {
return (
Resting · wakes in
);
}
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
Duty
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
= 0.85 ? "rose" : "amber"}
size="sm"
className="mt-1"
/>
{canRest && pilot.status === "idle" && !confirming && (
setConfirming(true)} className="mt-3">
Rest now
)}
{confirming && (
{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.
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
}
>
Rest {pilot.name}
setConfirming(false)}>
Cancel
)}
{rest.isError && (
{errorMessage(rest.error)}
)}
);
}
/** 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 (
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
);
}
return (
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"
>
Choose a ship…
{ships.map((s) => (
{s.name} ({shipModelName(s.modelId)})
))}
assign.mutate({ pilotId: pilot.id, shipId })}
>
Assign
{assign.isError && (
{errorMessage(assign.error)}
)}
);
}