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

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&apos;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>
);
}