410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import { FleetAsleep } from "../components/FleetSleep";
|
|
import { NeedsYou } from "../components/NeedsYou";
|
|
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
|
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
|
import { dispatchProgress } from "@deadhead/sim";
|
|
import { Link } from "react-router";
|
|
import { useClock } from "../clock/clock";
|
|
import { Countdown } from "../components/Countdown";
|
|
import { formatFu, FuelGauge } from "../components/Fuel";
|
|
import { GoalsCard } from "../components/GoalsCard";
|
|
import { goodName, PriorityChip } from "../components/Goods";
|
|
import { LoanCard } from "../components/LoanCard";
|
|
import { RouteStrip } from "../components/RouteStrip";
|
|
import { formatGameHours, locationName, shipModelName } from "../format";
|
|
import { lowFuel } from "../game";
|
|
import { ShopRest } from "../components/ShopRest";
|
|
import { useLoadedPlayer } from "../state/actions";
|
|
import {
|
|
Art,
|
|
buttonClass,
|
|
Eyebrow,
|
|
Icon,
|
|
Meter,
|
|
Panel,
|
|
Pill,
|
|
Screen,
|
|
ScreenHeader,
|
|
StatTile,
|
|
} from "../ui";
|
|
|
|
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
|
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
|
if (tutorial.dealAvailable) return "Halvorsen is waiting at Earth with an offer.";
|
|
if (tutorial.runsCompleted < runsBeforeDeal) {
|
|
return "Keep flying — the old man has an offer for you.";
|
|
}
|
|
// Enough runs, but the deal is made at Earth.
|
|
return tutorial.runInFlight
|
|
? "Halvorsen will talk once you land at Earth."
|
|
: "One more run: bring the ship back to Earth — Halvorsen is waiting there.";
|
|
}
|
|
|
|
/** The ship the player's own pilot flies (decision 0.5.119). */
|
|
export type PilotShip =
|
|
| { kind: "loaner"; avatar: Pilot; locationId: string; dispatch: Dispatch | undefined }
|
|
| { kind: "ship"; avatar: Pilot; ship: Ship; dispatch: Dispatch | undefined }
|
|
| { kind: "none"; avatar: Pilot | undefined };
|
|
|
|
/** The avatar's assigned ship, or the company loaner during the tutorial. */
|
|
export function pilotShip(state: PlayerState): PilotShip {
|
|
const avatar = state.pilots.find((p) => p.isAvatar);
|
|
if (!avatar) return { kind: "none", avatar };
|
|
if (state.tutorial) {
|
|
return {
|
|
kind: "loaner",
|
|
avatar,
|
|
locationId: state.tutorial.loanerLocationId,
|
|
dispatch: state.activeDispatches.find((d) => d.tutorial),
|
|
};
|
|
}
|
|
const ship = state.ships.find((s) => s.id === avatar.assignedShipId);
|
|
if (!ship) return { kind: "none", avatar };
|
|
return {
|
|
kind: "ship",
|
|
avatar,
|
|
ship,
|
|
dispatch: state.activeDispatches.find((d) => d.id === ship.dispatchId),
|
|
};
|
|
}
|
|
|
|
/** The display name of the ship a dispatch flies (the loaner isn't in the ships list). */
|
|
function flightShipName(state: PlayerState, dispatch: Dispatch): string {
|
|
if (dispatch.tutorial) return "Company loaner";
|
|
return state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "A ship";
|
|
}
|
|
|
|
/** The Dashboard ("Bridge"): your ship first, then the company (CR-020). */
|
|
export function Dashboard() {
|
|
const state = useLoadedPlayer();
|
|
const now = useClock((s) => s.now);
|
|
const idle = state.ships.filter((s) => s.status === "idle").length;
|
|
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
|
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
|
|
const onJobs = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt);
|
|
// Resting pilots and who wakes first (CR-004).
|
|
const resting = state.pilots
|
|
.filter((p) => p.resting && p.restEndsAt !== null)
|
|
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
|
|
// Ships in the upgrade shop, soonest out first (CR-009).
|
|
const inShop = state.ships
|
|
.filter((s) => s.inShopUntil !== null && now < s.inShopUntil)
|
|
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
|
|
|
|
return (
|
|
<Screen>
|
|
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
|
|
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
|
<FleetAsleep />
|
|
|
|
{state.tutorial && (
|
|
<Panel tone="amber">
|
|
<Eyebrow>Contract pilot</Eyebrow>
|
|
<h2 className="mt-0.5 text-lg font-semibold">Flying for {content.tutorialCompanyName}</h2>
|
|
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
|
|
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
|
{runsBeforeDeal}
|
|
</p>
|
|
<RunPips done={state.tutorial.runsCompleted} total={runsBeforeDeal} />
|
|
<p aria-label="Next step" className="mt-2 text-sm text-ink-300">
|
|
{tutorialHint(state.tutorial, runsBeforeDeal)}
|
|
</p>
|
|
{!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
|
|
<Link to="/contracts" className={`mt-3 ${buttonClass("primary", "md", true)}`}>
|
|
<Icon name="send" className="size-4" />
|
|
Fly a company run
|
|
</Link>
|
|
)}
|
|
</Panel>
|
|
)}
|
|
|
|
<div className="grid gap-4 @2xl:grid-cols-2">
|
|
<YourShip />
|
|
<NeedsYou />
|
|
</div>
|
|
|
|
<section aria-labelledby="on-jobs-title">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 id="on-jobs-title" className="text-sm font-semibold">
|
|
In flight
|
|
</h2>
|
|
<span aria-label="Ships in flight" className="text-xs text-ink-300">
|
|
{flying} flying · {idle} idle
|
|
</span>
|
|
</div>
|
|
{onJobs.length === 0 ? (
|
|
<p className="py-2 text-sm text-ink-300">Nothing in flight.</p>
|
|
) : (
|
|
<ul className="divide-y divide-glass-line">
|
|
{onJobs.map((d) => {
|
|
const name = flightShipName(state, d);
|
|
return (
|
|
<li key={d.id} aria-label={name} className="py-2.5 text-sm">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="min-w-0 truncate">
|
|
<span className="font-medium text-ink-50">{name}</span>{" "}
|
|
<span className="text-ink-300">
|
|
{locationName(d.originId)} → {locationName(d.destinationId)}
|
|
</span>
|
|
</span>
|
|
<span className="font-semibold whitespace-nowrap tabular-nums">
|
|
<Countdown to={d.arrivalAt} />
|
|
</span>
|
|
</div>
|
|
<Meter
|
|
label={`${name} trip`}
|
|
value={Math.round(dispatchProgress(d, now) * 100)}
|
|
tone="cyan"
|
|
size="sm"
|
|
className="mt-1.5"
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{!state.tutorial && resting.length > 0 && (
|
|
<section aria-label="Pilot rest" className="flex items-center gap-3 text-sm">
|
|
<Icon name="rest" className="size-5 shrink-0 text-flight" />
|
|
<p className="min-w-0">
|
|
<span className="font-medium">Pilots resting: {resting.length}</span>
|
|
<span className="block text-ink-300">
|
|
{resting[0]!.name} wakes in <Countdown to={resting[0]!.restEndsAt!} done="now" />
|
|
</span>
|
|
</p>
|
|
</section>
|
|
)}
|
|
|
|
{inShop.length > 0 && (
|
|
<section aria-label="In the shop" className="text-sm">
|
|
<h2 className="font-semibold">In the shop: {inShop.length}</h2>
|
|
<ul className="mt-1 space-y-0.5 text-ink-300">
|
|
{inShop.map((ship) => (
|
|
<li key={ship.id}>
|
|
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name} {ship.shop?.level}{" "}
|
|
· ready in <Countdown to={ship.inShopUntil!} done="now" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
{!state.tutorial && state.ships.length === 0 && (
|
|
<p className="text-sm text-ink-300">No ships yet — buy one in the Shipyard.</p>
|
|
)}
|
|
|
|
{!state.tutorial && <GoalsCard />}
|
|
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
|
<DispatchOfficePanel />
|
|
{!state.tutorial && (
|
|
<Link
|
|
to="/logbook"
|
|
aria-label="Open the Logbook"
|
|
className="flex min-h-11 items-center gap-2 text-sm font-medium text-ink-200"
|
|
>
|
|
<Icon name="route" className="size-4 text-ink-300" />
|
|
Open the Logbook
|
|
</Link>
|
|
)}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
/** The player pilot's ship: status, trip, fuel and the pilot's duty (decision 0.5.119). */
|
|
function YourShip({ className = "" }: { className?: string }) {
|
|
const state = useLoadedPlayer();
|
|
const now = useClock((s) => s.now);
|
|
const mine = pilotShip(state);
|
|
|
|
if (mine.kind === "none") {
|
|
return (
|
|
<Panel as="section" aria-label="Your ship" className={className}>
|
|
<Eyebrow>Your ship</Eyebrow>
|
|
<p className="mt-2 text-sm text-ink-200">You're not flying a ship right now.</p>
|
|
<Link to="/pilots" className={`mt-3 ${buttonClass("secondary", "sm")}`}>
|
|
<Icon name="pilots" className="size-4" />
|
|
Assign yourself in Crew
|
|
</Link>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
const { avatar, dispatch } = mine;
|
|
const loaner = mine.kind === "loaner";
|
|
const ship = loaner ? null : mine.ship;
|
|
const modelId = loaner ? content.config.tutorialLoanerShipModelId : ship!.modelId;
|
|
const sprite = content.shipModel(modelId)?.sprite;
|
|
const name = loaner ? "Company loaner" : ship!.name;
|
|
const hereId = loaner ? mine.locationId : ship!.locationId;
|
|
const inShop = ship !== null && ship.inShopUntil !== null && now < ship.inShopUntil;
|
|
const discovered = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
|
|
const low = ship && !dispatch ? lowFuel(ship, discovered) : null;
|
|
const contract = dispatch?.contract ?? null;
|
|
|
|
return (
|
|
<Panel
|
|
as="section"
|
|
aria-label="Your ship"
|
|
tone={dispatch || inShop ? "default" : "amber"}
|
|
className={`flex flex-col gap-3 self-start ${className}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{sprite && (
|
|
<div className="grid size-20 shrink-0 place-items-center">
|
|
<Art
|
|
sprite={sprite}
|
|
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<Eyebrow>Your ship</Eyebrow>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h2 className="truncate text-lg font-semibold">{name}</h2>
|
|
{dispatch ? (
|
|
<Pill tone="cyan" icon="send">
|
|
In flight
|
|
</Pill>
|
|
) : inShop ? (
|
|
<Pill tone="amber">In the shop</Pill>
|
|
) : (
|
|
<Pill tone="emerald">Idle</Pill>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-ink-400">{shipModelName(modelId)}</p>
|
|
<p aria-label="Your ship's status" className="mt-1 text-sm text-ink-200">
|
|
{dispatch
|
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
|
: `At ${locationName(hereId ?? "")}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{dispatch && (
|
|
<div>
|
|
<RouteStrip
|
|
originId={dispatch.originId}
|
|
destinationId={dispatch.destinationId}
|
|
progress={dispatchProgress(dispatch, now)}
|
|
shipSprite={sprite}
|
|
/>
|
|
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
<span aria-label="Your cargo" className="text-ink-200">
|
|
{contract
|
|
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
|
|
: "Flying empty"}
|
|
</span>
|
|
<span className="font-semibold">
|
|
Arrives in <Countdown to={dispatch.arrivalAt} />
|
|
</span>
|
|
</div>
|
|
{contract?.priority && contract.deliveryWindowGameHours !== null && (
|
|
<div className="mt-1">
|
|
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{inShop && (
|
|
<p className="font-display text-sm text-amber-200">
|
|
In the shop — ready in <Countdown to={ship!.inShopUntil!} done="now" />
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid gap-3 @2xl:grid-cols-2">
|
|
<div>
|
|
<Eyebrow>Fuel</Eyebrow>
|
|
{ship ? (
|
|
<div className="mt-1">
|
|
<FuelGauge ship={ship} />
|
|
{low && (
|
|
<p
|
|
role="status"
|
|
aria-label="Your ship low fuel"
|
|
className="mt-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
|
>
|
|
<span className="font-display font-semibold">Low fuel</span> ·{" "}
|
|
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="mt-1 text-sm text-ink-300">Supplied by Halvorsen</p>
|
|
)}
|
|
</div>
|
|
<PilotDuty pilot={avatar} />
|
|
</div>
|
|
|
|
{ship && !dispatch && !inShop && hereId && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Link
|
|
to={`/contracts?location=${hereId}`}
|
|
className={`${buttonClass("primary", "sm")} flex-1 basis-36`}
|
|
>
|
|
<Icon name="jobs" className="size-4" />
|
|
Find work here
|
|
</Link>
|
|
<Link to="/fleet" className={`${buttonClass("secondary", "sm")} flex-1 basis-36`}>
|
|
<Icon name="fleet" className="size-4" />
|
|
Fleet
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** The player pilot's duty, or their rest (CR-004). */
|
|
function PilotDuty({ pilot }: { pilot: Pilot }) {
|
|
return (
|
|
<div aria-label="Your pilot">
|
|
<Eyebrow>
|
|
{pilot.name} · Level {pilot.level}
|
|
</Eyebrow>
|
|
{pilot.restingInShopUntil !== null ? (
|
|
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
|
|
<Icon name="rest" className="size-4 shrink-0" />
|
|
<ShopRest until={pilot.restingInShopUntil} />
|
|
</p>
|
|
) : pilot.resting && pilot.restEndsAt !== null ? (
|
|
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
|
|
<Icon name="rest" className="size-4 shrink-0" />
|
|
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
|
|
</p>
|
|
) : (
|
|
<>
|
|
<Meter
|
|
label="Your duty"
|
|
value={Math.min(pilot.dutyHours, pilot.dutyLimit)}
|
|
max={pilot.dutyLimit}
|
|
tone={pilot.dutyHours / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
|
|
size="sm"
|
|
className="mt-2"
|
|
/>
|
|
<p className="mt-1 font-display text-xs text-ink-400">
|
|
Duty {formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** One pip per company run toward the deal. */
|
|
function RunPips({ done, total }: { done: number; total: number }) {
|
|
return (
|
|
<div aria-hidden="true" className="mt-2 flex gap-1.5">
|
|
{Array.from({ length: total }, (_, i) => (
|
|
<span
|
|
key={i}
|
|
className={`h-2 flex-1 rounded-full ${i < done ? "bg-action" : "bg-white/14"}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|