Step 20.3: Dashboard with your ship, alerts and ships on jobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fec2c481df
commit
f948a22d8c
|
|
@ -11,7 +11,7 @@ import { Layout } from "./layout/Layout";
|
|||
import { SCREENS } from "./routes";
|
||||
import { Contracts } from "./screens/Contracts";
|
||||
import { Fleet } from "./screens/Fleet";
|
||||
import { HQ } from "./screens/HQ";
|
||||
import { Dashboard } from "./screens/Dashboard";
|
||||
import { HQs } from "./screens/HQs";
|
||||
import { Locations } from "./screens/Locations";
|
||||
import { Market } from "./screens/Market";
|
||||
|
|
@ -26,7 +26,7 @@ import { Button } from "./ui";
|
|||
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
|
||||
|
||||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||
"/": <HQ />,
|
||||
"/": <Dashboard />,
|
||||
"/map": (
|
||||
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
|
||||
<MapScreen />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,449 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
||||
import { dispatchProgress, realToGame } from "@deadhead/sim";
|
||||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
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 { formatCredits, formatGameHours, locationName, shipModelName } from "../format";
|
||||
import { lowFuel } from "../game";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { usePreferences } from "../state/preferences";
|
||||
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 clockFormat = usePreferences((p) => p.clock);
|
||||
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 className="max-w-5xl">
|
||||
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<YourShip className="md:col-span-2" />
|
||||
<Alerts />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatTile
|
||||
label="Credits"
|
||||
value={formatCredits(state.company.credits)}
|
||||
icon="credits"
|
||||
tone="amber"
|
||||
/>
|
||||
<StatTile
|
||||
label="Game date"
|
||||
value={formatGameTime(realToGame(now, content.config), clockFormat)}
|
||||
icon="clock"
|
||||
/>
|
||||
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{state.tutorial && (
|
||||
<Panel feature tone="amber">
|
||||
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
|
||||
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
|
||||
Flying for Halvorsen Lunar Freight
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Panel
|
||||
as="section"
|
||||
aria-labelledby="on-jobs-title"
|
||||
tone={onJobs.length ? "cyan" : "default"}
|
||||
>
|
||||
<h2
|
||||
id="on-jobs-title"
|
||||
className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase"
|
||||
>
|
||||
Ships on jobs
|
||||
</h2>
|
||||
{onJobs.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-3">
|
||||
{onJobs.map((d) => {
|
||||
const name = flightShipName(state, d);
|
||||
return (
|
||||
<li key={d.id} aria-label={name} className="text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-semibold text-ink-50">{name}</span>{" "}
|
||||
<span className="text-ink-300">
|
||||
{locationName(d.originId)} → {locationName(d.destinationId)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-display font-bold whitespace-nowrap text-cyan-200">
|
||||
<Countdown to={d.arrivalAt} />
|
||||
</span>
|
||||
</div>
|
||||
<Meter
|
||||
label={`${name} trip`}
|
||||
value={Math.round(dispatchProgress(d, now) * 100)}
|
||||
tone="cyan"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{!state.tutorial && (
|
||||
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
|
||||
<Icon name="rest" className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||
Pilots resting: {resting.length}
|
||||
</h2>
|
||||
{resting[0] ? (
|
||||
<p className="text-sm text-sky-200">
|
||||
{resting[0].name} wakes in{" "}
|
||||
<Countdown to={resting[0].restEndsAt!} done="now" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{inShop.length > 0 && (
|
||||
<Panel aria-label="In the shop" tone="amber">
|
||||
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||
In the shop: {inShop.length}
|
||||
</h2>
|
||||
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
|
||||
{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>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!state.tutorial && <GoalsCard />}
|
||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||
</div>
|
||||
</div>
|
||||
</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" feature 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 on Pilots
|
||||
</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"
|
||||
feature
|
||||
tone={dispatch ? "cyan" : "default"}
|
||||
className={`flex flex-col gap-3 ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{sprite && (
|
||||
<div className="grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
||||
<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 font-display text-xl font-bold tracking-wide">{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-display text-lg font-bold text-cyan-200">
|
||||
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 sm: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 tracking-wide uppercase">
|
||||
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.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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where in-flight alerts and news will appear; empty for now (decision 0.5.120). */
|
||||
function Alerts() {
|
||||
return (
|
||||
<Panel as="section" aria-labelledby="alerts-title" tone="muted">
|
||||
<h2
|
||||
id="alerts-title"
|
||||
className="flex items-center gap-2 font-display text-sm font-semibold tracking-wide text-ink-50"
|
||||
>
|
||||
<Icon name="alert" className="size-4 text-amber-300" />
|
||||
Alerts
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-ink-300">No alerts.</p>
|
||||
<p className="mt-1 text-xs text-ink-500">In-flight alerts and news will appear here.</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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-amber-400 shadow-glow-amber" : "bg-space-700"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { TutorialState } from "@deadhead/schema";
|
||||
import { dispatchProgress, realToGame } from "@deadhead/sim";
|
||||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { GoalsCard } from "../components/GoalsCard";
|
||||
import { LoanCard } from "../components/LoanCard";
|
||||
import { RouteStrip } from "../components/RouteStrip";
|
||||
import { formatCredits, locationName } from "../format";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { usePreferences } from "../state/preferences";
|
||||
import { buttonClass, Eyebrow, Icon, Panel, 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.";
|
||||
}
|
||||
|
||||
export function HQ() {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const clockFormat = usePreferences((p) => p.clock);
|
||||
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 next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
|
||||
// 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!);
|
||||
const nextShip = next?.tutorial
|
||||
? "ship-company-courier"
|
||||
: content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
|
||||
|
||||
return (
|
||||
<Screen className="max-w-5xl">
|
||||
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatTile
|
||||
label="Credits"
|
||||
value={formatCredits(state.company.credits)}
|
||||
icon="credits"
|
||||
tone="amber"
|
||||
/>
|
||||
<StatTile
|
||||
label="Game date"
|
||||
value={formatGameTime(realToGame(now, content.config), clockFormat)}
|
||||
icon="clock"
|
||||
/>
|
||||
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{state.tutorial && (
|
||||
<Panel feature tone="amber">
|
||||
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
|
||||
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
|
||||
Flying for Halvorsen Lunar Freight
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Panel feature tone={next ? "cyan" : "default"}>
|
||||
<h2 className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase">
|
||||
Next arrival
|
||||
</h2>
|
||||
{next ? (
|
||||
<>
|
||||
<div className="mt-2">
|
||||
<RouteStrip
|
||||
originId={next.originId}
|
||||
destinationId={next.destinationId}
|
||||
progress={dispatchProgress(next, now)}
|
||||
shipSprite={nextShip}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm text-ink-200">
|
||||
{locationName(next.originId)} → {locationName(next.destinationId)} in
|
||||
</span>
|
||||
<span className="font-display text-2xl font-bold text-cyan-200">
|
||||
<Countdown to={next.arrivalAt} />
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{!state.tutorial && (
|
||||
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
|
||||
<Icon name="rest" className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||
Pilots resting: {resting.length}
|
||||
</h2>
|
||||
{resting[0] ? (
|
||||
<p className="text-sm text-sky-200">
|
||||
{resting[0].name} wakes in{" "}
|
||||
<Countdown to={resting[0].restEndsAt!} done="now" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{inShop.length > 0 && (
|
||||
<Panel aria-label="In the shop" tone="amber">
|
||||
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||
In the shop: {inShop.length}
|
||||
</h2>
|
||||
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
|
||||
{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>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!state.tutorial && <GoalsCard />}
|
||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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-amber-400 shadow-glow-amber" : "bg-space-700"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
|
|||
resolvedAt: null,
|
||||
});
|
||||
|
||||
describe("HQ screen", () => {
|
||||
describe("Dashboard (CR-020)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
|
|
@ -47,7 +47,7 @@ describe("HQ screen", () => {
|
|||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows idle and in-flight counts and counts down to the next arrival", async () => {
|
||||
it("shows idle and in-flight counts and every ship on a job, soonest first", async () => {
|
||||
const state = samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")],
|
||||
|
|
@ -60,10 +60,13 @@ describe("HQ screen", () => {
|
|||
expect(statValue("Ships idle")).toHaveTextContent("1");
|
||||
expect(statValue("Ships in flight")).toHaveTextContent("2");
|
||||
|
||||
const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!;
|
||||
expect(within(next).getByText("1m 05s")).toBeInTheDocument();
|
||||
const jobs = screen.getByRole("region", { name: "Ships on jobs" });
|
||||
const rows = within(jobs).getAllByRole("listitem");
|
||||
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
|
||||
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
|
||||
expect(rows[1]).toHaveTextContent("2m 05s");
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(within(next).getByText("55s")).toBeInTheDocument();
|
||||
expect(rows[0]).toHaveTextContent("55s");
|
||||
});
|
||||
|
||||
it("shows tutorial run progress toward the deal during the tutorial", async () => {
|
||||
|
|
@ -171,3 +174,126 @@ describe("loan on HQ", () => {
|
|||
expect(dialog).toHaveTextContent("230 cr went to the lender");
|
||||
});
|
||||
});
|
||||
|
||||
describe("your ship on the Dashboard (CR-020)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const avatar = samplePlayerState().pilots[0]!;
|
||||
const mine = (overrides: Partial<Ship> = {}): Ship => ({
|
||||
...ship("Iron Mule", "idle"),
|
||||
pilotId: "avatar",
|
||||
...overrides,
|
||||
});
|
||||
const owner = (overrides: Partial<PlayerState>) =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
pilots: [{ ...avatar, assignedShipId: "Iron Mule", dutyHours: 3, dutyLimit: 12 }],
|
||||
...overrides,
|
||||
});
|
||||
const panel = async () => screen.findByRole("region", { name: "Your ship" });
|
||||
|
||||
it("shows an idle ship's place, fuel, the pilot's duty and where to find work", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
|
||||
const p = await panel();
|
||||
expect(p).toHaveTextContent("Iron Mule");
|
||||
expect(p).toHaveTextContent("Idle");
|
||||
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
|
||||
expect(within(p).getByRole("progressbar", { name: "Iron Mule fuel" })).toBeInTheDocument();
|
||||
expect(within(p).getByRole("progressbar", { name: "Your duty" })).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"3",
|
||||
);
|
||||
expect(within(p).getByRole("link", { name: "Find work here" })).toHaveAttribute(
|
||||
"href",
|
||||
"/contracts?location=earth",
|
||||
);
|
||||
expect(within(p).queryByLabelText("Your ship low fuel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when your ship is low on fuel", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine({ fuelFu: 1 })] }) }));
|
||||
expect(await screen.findByLabelText("Your ship low fuel")).toHaveTextContent("Low fuel");
|
||||
});
|
||||
|
||||
it("shows your ship's trip, cargo and arrival in flight", async () => {
|
||||
const flight: Dispatch = {
|
||||
...dispatch("Iron Mule", NOW + 90_000),
|
||||
contract: {
|
||||
id: "c1",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "high-value",
|
||||
goodId: "medical-supplies",
|
||||
shipper: "Meridian Health",
|
||||
priority: false,
|
||||
deliveryWindowGameHours: null,
|
||||
tier: 1,
|
||||
massTonnes: 6,
|
||||
payout: 900,
|
||||
requiredSkill: { skillId: "expensive-cargo", level: 1 },
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 600_000,
|
||||
firstContact: false,
|
||||
},
|
||||
};
|
||||
const flying = mine({ status: "in-flight", locationId: null, dispatchId: flight.id });
|
||||
renderApp(
|
||||
fakeApi({ getPlayerState: () => owner({ ships: [flying], activeDispatches: [flight] }) }),
|
||||
);
|
||||
const p = await panel();
|
||||
expect(p).toHaveTextContent("In flight");
|
||||
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("Earth → Moon");
|
||||
expect(within(p).getByLabelText("Your cargo")).toHaveTextContent(
|
||||
"6 t Medical Supplies for Meridian Health",
|
||||
);
|
||||
expect(p).toHaveTextContent("Arrives in 1m 30s");
|
||||
});
|
||||
|
||||
it("shows a ship in the shop and a resting pilot", async () => {
|
||||
const shopped = mine({ inShopUntil: NOW + 120_000 });
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
owner({
|
||||
ships: [shopped],
|
||||
pilots: [
|
||||
{ ...avatar, assignedShipId: "Iron Mule", resting: true, restEndsAt: NOW + 300_000 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const p = await panel();
|
||||
expect(p).toHaveTextContent("In the shop — ready in 2m 00s");
|
||||
expect(within(p).getByLabelText("Your pilot")).toHaveTextContent("Resting · wakes in 5m 00s");
|
||||
});
|
||||
|
||||
it("says so when you aren't flying a ship", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
|
||||
const p = await panel();
|
||||
expect(p).toHaveTextContent("You're not flying a ship right now.");
|
||||
expect(within(p).getByRole("link", { name: "Assign yourself on Pilots" })).toHaveAttribute(
|
||||
"href",
|
||||
"/pilots",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the company loaner during the tutorial", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
|
||||
const p = await panel();
|
||||
expect(p).toHaveTextContent("Company loaner");
|
||||
expect(p).toHaveTextContent("Supplied by Halvorsen");
|
||||
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
|
||||
});
|
||||
|
||||
it("has an Alerts panel, empty for now", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
|
||||
const alerts = await screen.findByRole("region", { name: "Alerts" });
|
||||
expect(alerts).toHaveTextContent("No alerts.");
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { tutorialHint } from "../src/screens/HQ";
|
||||
import { tutorialHint } from "../src/screens/Dashboard";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
|
|
|||
|
|
@ -453,3 +453,4 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C
|
|||
Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions recorded as plan §0.5.118–0.5.121.
|
||||
- [x] **Step 20.1 — Navigation: Bridge and HQs.** `SCREENS`: `/` is now "Dashboard" with the tab label "Bridge" and a new `helm` icon; `/hqs` ("HQs", HQ icon) sits after Pilots, with a first `HQs` screen (header only until 20.2). The bottom bar is eight columns; labels tighten their tracking below `sm` so "PILOTS"/"PLACES" fit about 48 px. The goals badge stays on `/`. The HQ screen's heading is "Dashboard"; the away summary's "Claim on HQ" is "Claim on the Bridge" (tests that looked for the "HQ" heading updated). Test: `layout.test.tsx` (eight links, Bridge first and current on launch, HQs at `/hqs`; every screen's heading at its path).
|
||||
- [x] **Step 20.2 — HQs screen.** `screens/HQs.tsx`: one emerald feature card per bay (labelled "HQ at <place>"): location art and name, fuel price there (cheapest highlighted), ships at the location with Idle / In the shop, flights inbound to it with countdowns, unassigned pilots there (level, resting), and links to the location's contracts and to Pilots for hiring; the header says what an HQ allows (`HQ_ALLOWS`). No bays: a short explanation and "Lease one in Places". Leasing stays on Places. Test: `hqs-screen.test.tsx` (an HQ's ships, inbound flight, spare pilot, fuel price and links; ships elsewhere not listed; one card per HQ; empty state).
|
||||
- [x] **Step 20.3 — Dashboard.** `screens/HQ.tsx` → `screens/Dashboard.tsx` (`Dashboard`; `tutorialHint` moved with it). Leads with **Your ship** (`pilotShip(state)`: the avatar's assigned ship, the company loaner during the tutorial, or none): art, name and model, Idle / In flight / In the shop, where it is or its route with a route strip, cargo ("6 t Medical Supplies for Meridian Health" or "Flying empty"), arrival countdown and Priority chip, shop countdown, fuel gauge with the low-fuel warning (`lowFuel`, decision 0.5.113; "Supplied by Halvorsen" for the loaner), the avatar's duty meter or rest countdown, and "Find work here" / "Fleet" when idle; with no ship, "Assign yourself on Pilots". Beside it, **Alerts**: "No alerts. In-flight alerts and news will appear here." Then the stat tiles, the tutorial panel, **Ships on jobs** (every active dispatch, soonest first, with route, countdown and a trip meter; replaces "Next arrival"), pilot rest, in the shop, goals and the loan. Test: `dashboard.test.tsx` (was `hq.test.tsx`): ships on jobs in arrival order with live countdowns; your ship idle (place, fuel, duty, Find work here), low fuel, in flight with cargo and arrival, in the shop with a resting pilot, no ship, the tutorial loaner; the Alerts placeholder.
|
||||
|
|
|
|||
Loading…
Reference in New Issue