Compare commits
No commits in common. "ff136377f4234f03306cd338d630c81c131bfc2d" and "b5ce59d8fdd22f21c020029a2a84ae4c76c27c08" have entirely different histories.
ff136377f4
...
b5ce59d8fd
58
DECISIONS.md
58
DECISIONS.md
|
|
@ -395,61 +395,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Arrival refetch is driven by the ticking server clock** (`useClock`), not by per-dispatch timers: when `now` passes an in-flight dispatch's `arrivalAt`, state is refetched, which makes the server resolve it lazily. Each dispatch triggers one refetch; Phase 7's job covers anything the client misses.
|
||||
- **`refetchOnWindowFocus: true` is set explicitly** (it is TanStack Query's default) to document the requirement.
|
||||
- **Tests fake only `Date` and `setInterval`** for the arrival case, leaving `setTimeout` real so TanStack Query's scheduling works; the focus test runs on real timers.
|
||||
|
||||
## Step 9.1
|
||||
|
||||
- **Tutorial goal cost = cheapest ship model + Earth bay lease**, read from content, so it follows any rebalancing.
|
||||
- **The player-state query is fresh for 10 seconds** (`staleTime`), so screens that read it do not each refetch on mount. Returning to the app (`refetchOnWindowFocus: "always"`), arrivals and actions still refetch immediately.
|
||||
- **The arrival watcher runs once, in `App`,** not in every component that reads player state.
|
||||
- **Shared screen helpers:** `useLoadedPlayer()` (screens render only after state has loaded), `useGameAction(route)` (call an endpoint, then invalidate player state and boards), `Countdown` (live, from the synced clock), and `src/format.ts` (credits, durations, names).
|
||||
|
||||
## Step 9.2
|
||||
|
||||
- **The summary dialog sits above all screens** (rendered by `App`). Dismissing hides it immediately (local state) and sends `acknowledgeSummaries` for the listed ids; the follow-up state refetch confirms it.
|
||||
- **Tutorial runs are shown as "Company loaner"**, since the loaner is not in the ships list.
|
||||
|
||||
## Step 9.3
|
||||
|
||||
- **Trip-time predictions use sim with the content the server uses** (`src/game.ts`: distance at the current server time, `travelTimeGameHours`, converted to real time). They are predictions only (rule 0.1.6).
|
||||
- **Boards are TanStack queries under the `["board", …]` key**, which every game action invalidates, so a flown contract disappears from the list.
|
||||
|
||||
## Step 9.4
|
||||
|
||||
- **The distance shown on a contract is the current distance** (sim, at the server clock), which is what fuel and travel time use when dispatching now. The payout was fixed at the distance when the contract was offered (decision 0.5.11).
|
||||
- **Requirements are shown as text** ("Needs Heavy Cargo 2, Long Haul 1"); whether a particular pilot meets them is shown in the dispatch sheet (Step 9.5).
|
||||
- **The bay selector is a native `<select>`**, which works well on phones.
|
||||
|
||||
## Step 9.5
|
||||
|
||||
- **Eligibility in the sheet comes from sim `validateDispatch`**, given the client's copy of state and the synced clock, so the client shows the same reasons the server would return. The server still decides (rule 0.1.6).
|
||||
- **Pairs are the idle ships at the pickup with their assigned pilots.** Uncrewed ships there are listed as "No pilot assigned"; ships elsewhere are not listed.
|
||||
- **Net profit = payout − fuel − pilot pay** (no pilot pay when the avatar flies; decision 0.5.12).
|
||||
- **ETA is shown twice:** in-game (`YYYY-MM-DD HH:MM`) and real local time on the device.
|
||||
- **The first eligible pair is preselected**; Confirm is disabled when none is eligible.
|
||||
|
||||
## Step 9.6
|
||||
|
||||
- **Reposition is offered only for idle, crewed ships at one of the player's bays, when another bay exists.** The ship's assigned pilot flies it. Other cases do not show the button, rather than showing a button that always fails.
|
||||
- **Flight progress comes from sim `dispatchProgress`** with the synced clock, updating every second.
|
||||
|
||||
## Step 9.7
|
||||
|
||||
- **No "unassign" button yet.** The plan's Pilots-screen actions are hire, assign and spend skill point; the endpoint exists for a later UI. Hired pilots start unassigned, and the avatar is unassigned when the tutorial ends, so assignment always has a path.
|
||||
- **The avatar shows "Flying the company loaner" during the tutorial** and has no assign control (decision 0.5.23).
|
||||
- **XP progress** is shown as XP gained since the current level's threshold over the gap to the next threshold; at max level it shows total XP.
|
||||
|
||||
## Step 9.8
|
||||
|
||||
- **Market lists every content ship model** with its stats straight from `@deadhead/content`; no API call is needed until Buy. After a purchase the player-state refresh adds the ship to Fleet, and a status line says where it was delivered.
|
||||
|
||||
## Step 9.9
|
||||
|
||||
- **Distance from Earth is live** (sim at the synced clock), so it changes as the planets move; Earth itself shows "Home".
|
||||
- **Landing requirement "vacuum" is shown as "Any ship"**, since every ship can land at a vacuum-rated location.
|
||||
- **Lease buttons are disabled when unaffordable** and absent where a bay exists or the location is undiscovered.
|
||||
|
||||
## Step 9.10
|
||||
|
||||
- **Two levels of network error:** if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry.
|
||||
- **Market and Locations have no empty state:** they list static content, which always exists. Their error handling is the app-level one.
|
||||
- **The placeholder screen is gone;** every route has a real screen.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,16 @@
|
|||
import { useEffect, type ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { useApi } from "./api/context";
|
||||
import { startClockTicker, syncClock } from "./clock/clock";
|
||||
import { AwaySummary } from "./components/AwaySummary";
|
||||
import { formatCredits } from "./format";
|
||||
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 { Locations } from "./screens/Locations";
|
||||
import { Market } from "./screens/Market";
|
||||
import { Pilots } from "./screens/Pilots";
|
||||
import { Placeholder } from "./screens/Placeholder";
|
||||
import { StartScreen } from "./screens/StartScreen";
|
||||
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||
import { usePlayerState } from "./state/player";
|
||||
|
||||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||
"/": <HQ />,
|
||||
"/contracts": <Contracts />,
|
||||
"/fleet": <Fleet />,
|
||||
"/pilots": <Pilots />,
|
||||
"/market": <Market />,
|
||||
"/locations": <Locations />,
|
||||
};
|
||||
export function formatCredits(credits: number): string {
|
||||
return `${credits.toLocaleString("en-US")} cr`;
|
||||
}
|
||||
|
||||
/** Syncs the clock with the server once, then ticks it every second. */
|
||||
function useServerClock() {
|
||||
|
|
@ -39,60 +27,31 @@ function useServerClock() {
|
|||
export function App() {
|
||||
useServerClock();
|
||||
const state = usePlayerState();
|
||||
useRefetchOnArrival(state.data ?? null, state.refetch);
|
||||
const retry = () => void state.refetch();
|
||||
|
||||
if (state.isPending) {
|
||||
return <p className="p-6 text-slate-400">Loading…</p>;
|
||||
}
|
||||
if (state.data === undefined) {
|
||||
// The first load failed: nothing to show yet.
|
||||
if (state.isError) {
|
||||
return (
|
||||
<div role="alert" className="space-y-3 p-6">
|
||||
<p className="text-red-400">Could not reach the server.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
disabled={state.isFetching}
|
||||
className="rounded-lg bg-amber-500 px-4 py-2 font-semibold text-slate-950 disabled:opacity-60"
|
||||
>
|
||||
{state.isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
<p role="alert" className="p-6 text-red-400">
|
||||
Could not reach the server.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.data === null) return <StartScreen />;
|
||||
|
||||
// A later refresh failed: keep showing the last state, with a way to retry.
|
||||
const banner = state.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-2 bg-red-950 px-4 py-2 text-sm text-red-200"
|
||||
>
|
||||
<span>Offline — showing last update.</span>
|
||||
<button type="button" onClick={retry} className="underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
element={<Layout credits={formatCredits(state.data.company.credits)} banner={banner} />}
|
||||
>
|
||||
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
|
||||
{SCREENS.map((screen) => (
|
||||
<Route
|
||||
key={screen.path}
|
||||
index={screen.path === "/"}
|
||||
path={screen.path === "/" ? undefined : screen.path}
|
||||
element={SCREEN_COMPONENTS[screen.path]}
|
||||
element={<Placeholder title={screen.title} />}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
<AwaySummary />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { formatCredits, locationName } from "../format";
|
||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
|
||||
export function AwaySummary() {
|
||||
const state = useLoadedPlayer();
|
||||
const acknowledge = useGameAction("acknowledgeSummaries");
|
||||
// Hide as soon as the player dismisses; the refetch clears the list for real.
|
||||
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
|
||||
if (summaries.length === 0) return null;
|
||||
|
||||
const shipName = (shipId: string, tutorial: boolean) =>
|
||||
tutorial ? "Company loaner" : (state.ships.find((s) => s.id === shipId)?.name ?? "A ship");
|
||||
|
||||
const dismiss = () => {
|
||||
const ids = summaries.map((s) => s.dispatchId);
|
||||
setDismissed(new Set([...dismissed, ...ids]));
|
||||
acknowledge.mutate({ dispatchIds: ids });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="away-title"
|
||||
className="max-h-[80dvh] w-full max-w-md overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-4"
|
||||
>
|
||||
<h2 id="away-title" className="text-lg font-semibold">
|
||||
While you were away
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{summaries.map((s) => (
|
||||
<li key={s.dispatchId} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="font-medium">{shipName(s.shipId, s.tutorial)}</div>
|
||||
<div className="text-slate-400">
|
||||
{locationName(s.originId)} → {locationName(s.destinationId)}
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 tabular-nums">
|
||||
<span className={s.credits >= 0 ? "text-emerald-400" : "text-red-400"}>
|
||||
{s.credits >= 0 ? "+" : ""}
|
||||
{formatCredits(s.credits)}
|
||||
</span>
|
||||
<span>+{s.pilotXp} XP</span>
|
||||
</div>
|
||||
{s.discoveredLocationId && (
|
||||
<div className="mt-1 text-amber-300">
|
||||
Discovered {locationName(s.discoveredLocationId)}!
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { useClock } from "../clock/clock";
|
||||
import { formatDuration } from "../format";
|
||||
|
||||
/** Live time remaining until `to` (server clock); shows `done` once it has passed. */
|
||||
export function Countdown({ to, done = "Arriving…" }: { to: number; done?: string }) {
|
||||
const now = useClock((s) => s.now);
|
||||
const remaining = to - now;
|
||||
return <span className="tabular-nums">{remaining > 0 ? formatDuration(remaining) : done}</span>;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
|
||||
export function formatCredits(credits: number): string {
|
||||
return `${Math.round(credits).toLocaleString("en-US")} cr`;
|
||||
}
|
||||
|
||||
/** A duration as the two largest units: "2h 05m", "4m 09s", "45s". */
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (hours > 0) return `${hours}h ${pad(minutes)}m`;
|
||||
if (minutes > 0) return `${minutes}m ${pad(seconds)}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/** A game-hours duration, e.g. "5.4 h" or "250 h". */
|
||||
export function formatGameHours(hours: number): string {
|
||||
return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`;
|
||||
}
|
||||
|
||||
export function formatMillionKm(distance: number): string {
|
||||
return distance < 10 ? `${distance.toFixed(2)}M km` : `${Math.round(distance)}M km`;
|
||||
}
|
||||
|
||||
export function locationName(id: string): string {
|
||||
return content.location(id)?.name ?? id;
|
||||
}
|
||||
|
||||
export function shipModelName(id: string): string {
|
||||
return content.shipModel(id)?.name ?? id;
|
||||
}
|
||||
|
||||
export function cargoName(id: string): string {
|
||||
return content.cargoType(id)?.name ?? id;
|
||||
}
|
||||
|
||||
export function skillName(id: string): string {
|
||||
return content.skill(id)?.name ?? id;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { content, type Location, type ShipModel } from "@deadhead/content";
|
||||
import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim";
|
||||
|
||||
/** Client-side predictions with the same sim the server uses (rule 0.1.6). */
|
||||
|
||||
export function location(id: string): Location {
|
||||
const found = content.location(id);
|
||||
if (!found) throw new Error(`Unknown location ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function shipModel(id: string): ShipModel {
|
||||
const found = content.shipModel(id);
|
||||
if (!found) throw new Error(`Unknown ship model ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Route distance if departing at real time `now`, in million km. */
|
||||
export function distanceAt(originId: string, destinationId: string, now: number): number {
|
||||
return routeDistance(
|
||||
location(originId),
|
||||
location(destinationId),
|
||||
realToGame(now, content.config),
|
||||
);
|
||||
}
|
||||
|
||||
/** Trip duration in real milliseconds for a ship departing at `now`. */
|
||||
export function tripRealMs(model: ShipModel, originId: string, destinationId: string, now: number) {
|
||||
const hours = travelTimeGameHours(model, distanceAt(originId, destinationId, now));
|
||||
return (hours * MS_PER_HOUR) / content.config.timeCompression;
|
||||
}
|
||||
|
|
@ -7,12 +7,10 @@ export interface LayoutProps {
|
|||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
||||
credits?: ReactNode;
|
||||
clock?: ReactNode;
|
||||
/** Shown under the header, e.g. the offline notice. */
|
||||
banner?: ReactNode;
|
||||
}
|
||||
|
||||
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
|
||||
export function Layout({ credits = "—", clock = <GameClock />, banner }: LayoutProps) {
|
||||
export function Layout({ credits = "—", clock = <GameClock /> }: LayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
|
||||
|
|
@ -24,7 +22,6 @@ export function Layout({ credits = "—", clock = <GameClock />, banner }: Layou
|
|||
</span>
|
||||
</div>
|
||||
</header>
|
||||
{banner}
|
||||
|
||||
<main className="flex-1 overflow-x-hidden pb-20">
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useApi } from "../api/context";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { cargoName, formatCredits, formatMillionKm, locationName, skillName } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
|
||||
export const bayBoardKey = (locationId: string) => ["board", "bay", locationId] as const;
|
||||
|
||||
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
|
||||
export function requirementsText(contract: Contract): string | null {
|
||||
const parts = [
|
||||
contract.requiredSkill
|
||||
? `${skillName(contract.requiredSkill.skillId)} ${contract.requiredSkill.level}`
|
||||
: null,
|
||||
contract.requiredLongHaulLevel > 0 ? `Long Haul ${contract.requiredLongHaulLevel}` : null,
|
||||
].filter((p): p is string => p !== null);
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
/** Contract boards for the player's bays (Step 9.4). */
|
||||
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const [selected, setSelected] = useState(state.bays[0]?.locationId);
|
||||
const bayId = state.bays.some((b) => b.locationId === selected)
|
||||
? selected
|
||||
: state.bays[0]?.locationId;
|
||||
|
||||
if (!bayId) {
|
||||
return (
|
||||
<p className="text-sm text-slate-400">
|
||||
You have no bays yet. Lease one on the Places screen to get contracts.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-400">Bay</span>
|
||||
<select
|
||||
aria-label="Bay"
|
||||
value={bayId}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||
>
|
||||
{state.bays.map((bay) => (
|
||||
<option key={bay.id} value={bay.locationId}>
|
||||
{locationName(bay.locationId)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<BayBoard locationId={bayId} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BayBoard({
|
||||
locationId,
|
||||
onSelect,
|
||||
}: {
|
||||
locationId: string;
|
||||
onSelect?: (contract: Contract) => void;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const now = useClock((s) => s.now);
|
||||
const board = useQuery({
|
||||
queryKey: bayBoardKey(locationId),
|
||||
queryFn: () => api.call("getContractBoard", { locationId }),
|
||||
});
|
||||
|
||||
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
|
||||
if (board.isError) {
|
||||
return (
|
||||
<div role="alert" className="text-red-400">
|
||||
Could not load contracts.{" "}
|
||||
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (board.data.contracts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-400">No contracts left this period. Check back soon.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{board.data.contracts.map((contract) => {
|
||||
const requirements = requirementsText(contract);
|
||||
return (
|
||||
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||
{contract.firstContact && (
|
||||
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
||||
First contact
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums text-emerald-400">
|
||||
{formatCredits(contract.payout)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{cargoName(contract.cargoTypeId)}
|
||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
||||
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-slate-400">
|
||||
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
|
||||
<Countdown to={contract.expiresAt} done="expired" />
|
||||
</span>
|
||||
{onSelect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(contract)}
|
||||
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
|
||||
>
|
||||
Dispatch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { useState } from "react";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { BayBoards } from "./BayBoards";
|
||||
import { DispatchSheet } from "./DispatchSheet";
|
||||
import { TutorialBoard } from "./TutorialBoard";
|
||||
|
||||
/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */
|
||||
export function Contracts() {
|
||||
const state = useLoadedPlayer();
|
||||
const [selected, setSelected] = useState<Contract | null>(null);
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Contracts</h1>
|
||||
{state.tutorial ? <TutorialBoard /> : <BayBoards onSelect={setSelected} />}
|
||||
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||
import { realToGame, tripFuelCost, validateDispatch, type DispatchFailure } from "@deadhead/sim";
|
||||
import { useState } from "react";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { formatCredits, formatDuration, locationName } from "../format";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
/** Short player-facing reasons a ship+pilot cannot take a contract. */
|
||||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||
"ship-not-idle": "Ship is flying",
|
||||
"ship-not-at-origin": "Not at pickup",
|
||||
"pilot-not-idle": "Pilot is flying",
|
||||
"pilot-not-assigned": "No pilot assigned",
|
||||
"over-capacity": "Too heavy",
|
||||
"cannot-land": "Can't land there",
|
||||
"out-of-range": "Out of range",
|
||||
"cargo-skill-too-low": "Cargo skill too low",
|
||||
"long-haul-too-low": "Long Haul too low",
|
||||
"contract-expired": "Contract expired",
|
||||
"insufficient-credits": "Not enough credits for fuel",
|
||||
};
|
||||
|
||||
/** Real-world arrival time on the device, e.g. "14:05". */
|
||||
export function formatLocalTime(time: number): string {
|
||||
return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export interface PairOption {
|
||||
shipId: string;
|
||||
shipName: string;
|
||||
modelName: string;
|
||||
pilotId: string | null;
|
||||
pilotName: string | null;
|
||||
reason: string | null;
|
||||
tripMs: number;
|
||||
fuelCost: number;
|
||||
netProfit: number;
|
||||
arrivalAt: number;
|
||||
}
|
||||
|
||||
/** Every ship at the contract's origin, with its pilot, numbers and eligibility. */
|
||||
export function pairOptions(state: PlayerState, contract: Contract, now: number): PairOption[] {
|
||||
const gameNow = realToGame(now, content.config);
|
||||
return state.ships
|
||||
.filter((ship) => ship.status === "idle" && ship.locationId === contract.originId)
|
||||
.map((ship) => {
|
||||
const model = shipModel(ship.modelId);
|
||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||||
const tripMs = tripRealMs(model, contract.originId, contract.destinationId, now);
|
||||
const fuelCost = tripFuelCost(
|
||||
model,
|
||||
location(contract.originId),
|
||||
location(contract.destinationId),
|
||||
gameNow,
|
||||
);
|
||||
const pilotPay = pilot && !pilot.isAvatar ? content.config.pilotPayPerDelivery : 0;
|
||||
|
||||
let reason: string | null = INELIGIBLE_REASONS["pilot-not-assigned"];
|
||||
if (pilot) {
|
||||
const check = validateDispatch(
|
||||
{
|
||||
ship: {
|
||||
id: ship.id,
|
||||
modelId: ship.modelId,
|
||||
locationId: ship.locationId!,
|
||||
inFlight: false,
|
||||
},
|
||||
pilot: {
|
||||
id: pilot.id,
|
||||
isAvatar: pilot.isAvatar,
|
||||
assignedShipId: pilot.assignedShipId,
|
||||
inFlight: pilot.status === "in-flight",
|
||||
skills: pilot.skills,
|
||||
},
|
||||
contract,
|
||||
tutorial: false,
|
||||
credits: state.company.credits,
|
||||
now,
|
||||
},
|
||||
content,
|
||||
);
|
||||
reason = check.ok ? null : INELIGIBLE_REASONS[check.reason];
|
||||
}
|
||||
return {
|
||||
shipId: ship.id,
|
||||
shipName: ship.name,
|
||||
modelName: model.name,
|
||||
pilotId: pilot?.id ?? null,
|
||||
pilotName: pilot?.name ?? null,
|
||||
reason,
|
||||
tripMs,
|
||||
fuelCost,
|
||||
netProfit: contract.payout - fuelCost - pilotPay,
|
||||
arrivalAt: now + tripMs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */
|
||||
export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const dispatch = useGameAction("createDispatch");
|
||||
const options = pairOptions(state, contract, now);
|
||||
const [chosen, setChosen] = useState<string | null>(
|
||||
options.find((o) => o.reason === null)?.shipId ?? null,
|
||||
);
|
||||
const selected = options.find((o) => o.shipId === chosen && o.reason === null);
|
||||
|
||||
const confirm = () => {
|
||||
if (!selected?.pilotId) return;
|
||||
dispatch.mutate(
|
||||
{
|
||||
kind: "contract",
|
||||
contractId: contract.id,
|
||||
shipId: selected.shipId,
|
||||
pilotId: selected.pilotId,
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="dispatch-title"
|
||||
className="max-h-[85dvh] w-full max-w-md overflow-y-auto rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
||||
>
|
||||
<h2 id="dispatch-title" className="text-lg font-semibold">
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">Payout {formatCredits(contract.payout)}</p>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
No idle ships at {locationName(contract.originId)}.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{options.map((option) => (
|
||||
<li key={option.shipId}>
|
||||
<label
|
||||
aria-disabled={option.reason !== null}
|
||||
className={`block rounded-lg border p-3 text-sm ${
|
||||
option.reason
|
||||
? "border-slate-800 opacity-60"
|
||||
: chosen === option.shipId
|
||||
? "border-amber-400"
|
||||
: "border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="pair"
|
||||
value={option.shipId}
|
||||
disabled={option.reason !== null}
|
||||
checked={chosen === option.shipId}
|
||||
onChange={() => setChosen(option.shipId)}
|
||||
/>
|
||||
<span className="font-medium">{option.shipName}</span>
|
||||
<span className="text-slate-400">
|
||||
{option.modelName} · {option.pilotName ?? "no pilot"}
|
||||
</span>
|
||||
</div>
|
||||
{option.reason ? (
|
||||
<p className="mt-1 text-red-400">{option.reason}</p>
|
||||
) : (
|
||||
<dl className="mt-1 grid grid-cols-2 gap-x-3 text-slate-300">
|
||||
<dt>Trip</dt>
|
||||
<dd className="tabular-nums">{formatDuration(option.tripMs)}</dd>
|
||||
<dt>Fuel</dt>
|
||||
<dd className="tabular-nums">{formatCredits(option.fuelCost)}</dd>
|
||||
<dt>Net profit</dt>
|
||||
<dd className="tabular-nums">{formatCredits(option.netProfit)}</dd>
|
||||
<dt>ETA (game)</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatGameTime(realToGame(option.arrivalAt, content.config))}
|
||||
</dd>
|
||||
<dt>ETA (real)</dt>
|
||||
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{dispatch.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||
{errorMessage(dispatch.error)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-slate-700 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={!selected || dispatch.isPending}
|
||||
className="flex-1 rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||
import { dispatchProgress } from "@deadhead/sim";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { locationName, shipModelName } from "../format";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
/** All ships, their pilots and flights; idle ships at a bay can reposition (Step 9.6). */
|
||||
export function Fleet() {
|
||||
const state = useLoadedPlayer();
|
||||
const [repositioning, setRepositioning] = useState<Ship | null>(null);
|
||||
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Fleet</h1>
|
||||
{state.ships.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">
|
||||
No ships yet —{" "}
|
||||
<Link to="/market" className="text-amber-400 underline">
|
||||
visit the Market
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{state.ships.map((ship) => (
|
||||
<ShipRow
|
||||
key={ship.id}
|
||||
ship={ship}
|
||||
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
||||
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
||||
canReposition={
|
||||
ship.status === "idle" &&
|
||||
ship.pilotId !== null &&
|
||||
state.bays.some((b) => b.locationId === ship.locationId) &&
|
||||
state.bays.length > 1
|
||||
}
|
||||
onReposition={() => setRepositioning(ship)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{repositioning && (
|
||||
<RepositionPicker ship={repositioning} onClose={() => setRepositioning(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ShipRow(props: {
|
||||
ship: Ship;
|
||||
dispatch: Dispatch | undefined;
|
||||
pilotName: string | null;
|
||||
canReposition: boolean;
|
||||
onReposition: () => void;
|
||||
}) {
|
||||
const { ship, dispatch, pilotName } = props;
|
||||
const now = useClock((s) => s.now);
|
||||
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{ship.name}</span>
|
||||
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-300">
|
||||
{dispatch
|
||||
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||
: `At ${locationName(ship.locationId ?? "")}`}
|
||||
{" · "}
|
||||
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
|
||||
</div>
|
||||
{dispatch && (
|
||||
<div className="mt-2">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`${ship.name} progress`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
className="h-2 overflow-hidden rounded bg-slate-800"
|
||||
>
|
||||
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 text-right text-xs text-slate-400">
|
||||
Arrives in <Countdown to={dispatch.arrivalAt} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{props.canReposition && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onReposition}
|
||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||
>
|
||||
Reposition
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Choose another bay to fly an idle ship to, empty (fuel only). */
|
||||
function RepositionPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const reposition = useGameAction("createDispatch");
|
||||
const destinations = state.bays.filter((b) => b.locationId !== ship.locationId);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reposition-title"
|
||||
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
||||
>
|
||||
<h2 id="reposition-title" className="text-lg font-semibold">
|
||||
Reposition {ship.name}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">Fly empty to another bay. You pay fuel only.</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{destinations.map((bay) => (
|
||||
<li key={bay.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reposition.isPending}
|
||||
onClick={() =>
|
||||
reposition.mutate(
|
||||
{
|
||||
kind: "reposition",
|
||||
shipId: ship.id,
|
||||
pilotId: ship.pilotId!,
|
||||
destinationId: bay.locationId,
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
}
|
||||
className="w-full rounded-lg border border-slate-700 p-3 text-left"
|
||||
>
|
||||
{locationName(bay.locationId)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{reposition.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||
{errorMessage(reposition.error)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { realToGame } from "@deadhead/sim";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { formatCredits, locationName } from "../format";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
|
||||
/** Cost of the tutorial goal: the cheapest ship plus an Earth bay. */
|
||||
export function tutorialGoalCost(): number {
|
||||
const cheapest = Math.min(...content.shipModels.map((s) => s.price));
|
||||
return cheapest + (content.location("earth")?.bayLeasePrice ?? 0);
|
||||
}
|
||||
|
||||
export function HQ() {
|
||||
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 next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
|
||||
|
||||
const goal = tutorialGoalCost();
|
||||
const progress = Math.min(100, Math.floor((state.company.credits / goal) * 100));
|
||||
|
||||
return (
|
||||
<section className="space-y-4 p-4">
|
||||
<h1 className="text-xl font-semibold">HQ</h1>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat label="Credits" value={formatCredits(state.company.credits)} />
|
||||
<Stat label="Game date" value={formatGameTime(realToGame(now, content.config))} />
|
||||
<Stat label="Ships idle" value={String(idle)} />
|
||||
<Stat label="Ships in flight" value={String(flying)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-800 p-3">
|
||||
<h2 className="text-sm font-semibold text-slate-300">Next arrival</h2>
|
||||
{next ? (
|
||||
<p className="mt-1">
|
||||
{locationName(next.originId)} → {locationName(next.destinationId)} in{" "}
|
||||
<Countdown to={next.arrivalAt} />
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-slate-400">Nothing in flight.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
|
||||
)}
|
||||
|
||||
{state.tutorial && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
|
||||
<h2 className="font-semibold text-amber-300">
|
||||
Save up for your first ship and an Earth bay
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-300">
|
||||
{formatCredits(state.company.credits)} of {formatCredits(goal)}
|
||||
</p>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Tutorial goal"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
|
||||
>
|
||||
<div className="h-full bg-amber-400" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-slate-400">{progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="text-xs text-slate-400">{label}</div>
|
||||
<div className="mt-1 font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { content, type Location } from "@deadhead/content";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||
"atmospheric-world": "Atmospheric world",
|
||||
"airless-body": "Airless body",
|
||||
"cloud-platform": "Cloud platform",
|
||||
"belt-station": "Belt station",
|
||||
};
|
||||
const LANDING_LABELS = { vacuum: "Any ship", atmospheric: "Atmospheric", hardened: "Hardened" };
|
||||
|
||||
/** Every location; details and bay leasing only once discovered (Step 9.9). */
|
||||
export function Locations() {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const lease = useGameAction("leaseBay");
|
||||
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Locations</h1>
|
||||
{lease.isError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(lease.error)}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{content.locations.map((location) => {
|
||||
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
||||
if (!discovered) {
|
||||
return (
|
||||
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="font-medium">{location.name}</div>
|
||||
<div className="text-slate-400">Undiscovered — deliver here to unlock.</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const hasBay = state.bays.some((b) => b.locationId === location.id);
|
||||
const affordable = state.company.credits >= location.bayLeasePrice;
|
||||
return (
|
||||
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{location.name}</span>
|
||||
<span className="text-slate-400">
|
||||
{location.id === "earth"
|
||||
? "Home"
|
||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-slate-300">
|
||||
<dt className="text-slate-400">Type</dt>
|
||||
<dd>{TYPE_LABELS[location.type]}</dd>
|
||||
<dt className="text-slate-400">Landing</dt>
|
||||
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
|
||||
<dt className="text-slate-400">Produces</dt>
|
||||
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
|
||||
<dt className="text-slate-400">Demands</dt>
|
||||
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||
</dl>
|
||||
{hasBay ? (
|
||||
<p className="mt-2 font-medium text-emerald-400">Bay owned</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Lease bay at ${location.name}`}
|
||||
disabled={!affordable || lease.isPending}
|
||||
onClick={() => lease.mutate({ locationId: location.id })}
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Lease bay ({formatCredits(location.bayLeasePrice)})
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { formatCredits, formatMillionKm } from "../format";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
|
||||
|
||||
/** Ship models for sale; new ships appear at Earth (Step 9.8). */
|
||||
export function Market() {
|
||||
const state = useLoadedPlayer();
|
||||
const buy = useGameAction("buyShip");
|
||||
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Market</h1>
|
||||
<p className="text-sm text-slate-400">New ships are delivered to Earth with no pilot.</p>
|
||||
{buy.isError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(buy.error)}
|
||||
</p>
|
||||
)}
|
||||
{buy.isSuccess && (
|
||||
<p role="status" className="text-sm text-emerald-400">
|
||||
{buy.data.ship.name} is waiting at Earth.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{content.shipModels.map((model) => {
|
||||
const affordable = state.company.credits >= model.price;
|
||||
return (
|
||||
<li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="font-semibold tabular-nums">{formatCredits(model.price)}</span>
|
||||
</div>
|
||||
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 text-slate-300">
|
||||
<dt className="text-slate-400">Speed</dt>
|
||||
<dd>{formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h</dd>
|
||||
<dt className="text-slate-400">Capacity</dt>
|
||||
<dd>{model.cargoCapacityTonnes} t</dd>
|
||||
<dt className="text-slate-400">Range</dt>
|
||||
<dd>{formatMillionKm(model.fuelRangeMillionKm)}</dd>
|
||||
<dt className="text-slate-400">Landing</dt>
|
||||
<dd>{LANDING_LABELS[model.landingClass]}</dd>
|
||||
</dl>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Buy ${model.name}`}
|
||||
disabled={!affordable || buy.isPending}
|
||||
onClick={() => buy.mutate({ modelId: model.id })}
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
{affordable ? "Buy" : "Not enough credits"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Pilot } from "@deadhead/schema";
|
||||
import { useState } from "react";
|
||||
import { formatCredits, 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; hire, assign and spend skill points (Step 9.7). */
|
||||
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>
|
||||
|
||||
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
||||
<AssignShip pilot={pilot} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** A screen that has not been built yet (Phase 9 fills these in). */
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">Coming soon.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { loanerModel } from "@deadhead/sim";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useApi } from "../api/context";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
|
||||
import { tripRealMs } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
||||
|
||||
/** The company board during the tutorial (Step 9.3). */
|
||||
export function TutorialBoard() {
|
||||
const api = useApi();
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const board = useQuery({
|
||||
queryKey: TUTORIAL_BOARD_KEY,
|
||||
queryFn: () => api.call("getTutorialBoard", {}),
|
||||
});
|
||||
const fly = useGameAction("createDispatch");
|
||||
const runInFlight = state.tutorial?.runInFlight ?? false;
|
||||
const loaner = loanerModel(content);
|
||||
|
||||
if (board.isPending) return <p className="p-4 text-slate-400">Loading contracts…</p>;
|
||||
if (board.isError) {
|
||||
return (
|
||||
<div role="alert" className="p-4 text-red-400">
|
||||
Could not load contracts.{" "}
|
||||
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-400">
|
||||
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
||||
</p>
|
||||
{runInFlight && (
|
||||
<p className="rounded-lg bg-slate-800 p-2 text-sm">
|
||||
Your run is in flight. Wait for it to land.
|
||||
</p>
|
||||
)}
|
||||
{fly.isError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(fly.error)}
|
||||
</p>
|
||||
)}
|
||||
{board.data.contracts.length === 0 && (
|
||||
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{board.data.contracts.map((contract) => (
|
||||
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums text-emerald-400">
|
||||
{formatCredits(contract.payout)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-sm text-slate-400">
|
||||
<span>
|
||||
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "}
|
||||
<span aria-label="Trip time">
|
||||
{formatDuration(
|
||||
tripRealMs(loaner, contract.originId, contract.destinationId, now),
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runInFlight || fly.isPending}
|
||||
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
||||
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Fly it
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useApi } from "../api/context";
|
||||
import { PLAYER_STATE_KEY, usePlayerState } from "./player";
|
||||
|
||||
/** Player state on screens that only render once it has loaded (see `App`). */
|
||||
export function useLoadedPlayer(): PlayerState {
|
||||
const { data } = usePlayerState();
|
||||
if (!data) throw new Error("useLoadedPlayer used before player state loaded");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls one game endpoint, then refreshes player state (and any boards) so
|
||||
* every screen sees the result.
|
||||
*/
|
||||
export function useGameAction<N extends RouteName>(name: N) {
|
||||
const api = useApi();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<RouteResponse<N>, Error, RouteRequest<N>>({
|
||||
mutationFn: (input) => api.call(name, input),
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
||||
await queryClient.invalidateQueries({ queryKey: ["board"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The server's message for a failed action, or a generic one. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof ApiError ? error.message : "Something went wrong. Try again.";
|
||||
}
|
||||
|
|
@ -18,19 +18,17 @@ export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | nu
|
|||
}
|
||||
|
||||
/**
|
||||
* Server player state (Step 8.5), shared by every screen. Refetched when the app
|
||||
* regains focus; `App` also runs `useRefetchOnArrival` once for arrivals.
|
||||
* Server player state (Step 8.5). Refetched when the app regains focus and,
|
||||
* via `useRefetchOnArrival`, when an in-flight dispatch arrives.
|
||||
*/
|
||||
export function usePlayerState() {
|
||||
const api = useApi();
|
||||
const query = useQuery({
|
||||
queryKey: PLAYER_STATE_KEY,
|
||||
queryFn: () => fetchPlayerState(api),
|
||||
// Screens share this query; fresh for 10 s so mounting a screen does not refetch.
|
||||
// Returning to the app, arrivals and actions always refetch.
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
useRefetchOnArrival(query.data ?? null, query.refetch);
|
||||
return query;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import type { ResolutionSummary, Ship } from "@deadhead/schema";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const ship: Ship = {
|
||||
id: "s1",
|
||||
modelId: "courier",
|
||||
name: "Dusty Mule",
|
||||
status: "idle",
|
||||
locationId: "mars",
|
||||
dispatchId: null,
|
||||
pilotId: "avatar",
|
||||
};
|
||||
|
||||
const summaries: ResolutionSummary[] = [
|
||||
{
|
||||
dispatchId: "d1",
|
||||
shipId: "s1",
|
||||
pilotId: "avatar",
|
||||
originId: "earth",
|
||||
destinationId: "mars",
|
||||
tutorial: false,
|
||||
credits: 51_200,
|
||||
pilotXp: 32,
|
||||
discoveredLocationId: "mars",
|
||||
resolvedAt: 1,
|
||||
},
|
||||
{
|
||||
dispatchId: "d2",
|
||||
shipId: "loaner",
|
||||
pilotId: "avatar",
|
||||
originId: "moon",
|
||||
destinationId: "earth",
|
||||
tutorial: true,
|
||||
credits: 240,
|
||||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
resolvedAt: 2,
|
||||
},
|
||||
];
|
||||
|
||||
describe("while you were away", () => {
|
||||
it("lists each completed delivery, and dismissing acknowledges them and hides the dialog", async () => {
|
||||
let acknowledged = false;
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: [ship],
|
||||
summaries: acknowledged ? [] : summaries,
|
||||
}),
|
||||
acknowledgeSummaries: ({ dispatchIds }) => {
|
||||
acknowledged = true;
|
||||
return { acknowledged: dispatchIds.length };
|
||||
},
|
||||
});
|
||||
renderApp(api);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
|
||||
const items = within(dialog).getAllByRole("listitem");
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
expect(items[0]).toHaveTextContent("Dusty Mule");
|
||||
expect(items[0]).toHaveTextContent("Earth → Mars");
|
||||
expect(items[0]).toHaveTextContent("+51,200 cr");
|
||||
expect(items[0]).toHaveTextContent("+32 XP");
|
||||
expect(items[0]).toHaveTextContent("Discovered Mars!");
|
||||
|
||||
expect(items[1]).toHaveTextContent("Company loaner");
|
||||
expect(items[1]).toHaveTextContent("Moon → Earth");
|
||||
expect(items[1]).not.toHaveTextContent("Discovered");
|
||||
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Got it" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1", "d2"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show the dialog when there is nothing to report", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
|
||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
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 { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const contract = (overrides: Partial<Contract>): Contract => ({
|
||||
id: "c",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
tier: 0,
|
||||
massTonnes: 50,
|
||||
payout: 2000,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 30 * 60_000,
|
||||
firstContact: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const boards: Record<string, Contract[]> = {
|
||||
earth: [
|
||||
contract({ id: "e1", payout: 1999 }),
|
||||
contract({
|
||||
id: "e2",
|
||||
destinationId: "mars",
|
||||
cargoTypeId: "heavy",
|
||||
tier: 2,
|
||||
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||
requiredLongHaulLevel: 1,
|
||||
firstContact: true,
|
||||
payout: 88_000,
|
||||
}),
|
||||
],
|
||||
moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })],
|
||||
};
|
||||
|
||||
const owner = () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [
|
||||
{ id: "b1", locationId: "earth" },
|
||||
{ id: "b2", locationId: "moon" },
|
||||
],
|
||||
});
|
||||
|
||||
describe("bay contract boards", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows the first bay's board, and switching bays shows that bay's board", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: owner,
|
||||
getContractBoard: ({ locationId }) => ({
|
||||
contracts: boards[locationId] ?? [],
|
||||
periodEndsAt: NOW + 30 * 60_000,
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Bay"), "moon");
|
||||
expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
|
||||
expect(screen.getByText("Moon → Earth")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1,999 cr")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: owner,
|
||||
getContractBoard: () => ({ contracts: boards.earth!, periodEndsAt: NOW + 30 * 60_000 }),
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
|
||||
const items = await screen.findAllByRole("listitem");
|
||||
const [plain, special] = items;
|
||||
expect(plain).toHaveTextContent("Standard · 50 t");
|
||||
expect(plain).toHaveTextContent("No skills needed");
|
||||
expect(plain).toHaveTextContent("expires in 30m 00s");
|
||||
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
|
||||
|
||||
expect(special).toHaveTextContent("Earth → Mars");
|
||||
expect(special).toHaveTextContent("Heavy (tier 2)");
|
||||
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
||||
expect(special).toHaveTextContent(/M km/);
|
||||
expect(within(special!).getByText("First contact")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains how to get contracts when the player has no bays", async () => {
|
||||
renderApp(
|
||||
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }),
|
||||
"/contracts",
|
||||
);
|
||||
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||
import {
|
||||
MS_PER_HOUR,
|
||||
realToGame,
|
||||
routeDistance,
|
||||
travelTimeGameHours,
|
||||
tripFuelCost,
|
||||
} from "@deadhead/sim";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "../src/api/client";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { formatGameTime } from "../src/clock/GameClock";
|
||||
import { formatCredits, formatDuration } from "../src/format";
|
||||
import { formatLocalTime } from "../src/screens/DispatchSheet";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const ship = (id: string, name: string, modelId: string, pilotId: string | null): Ship => ({
|
||||
id,
|
||||
name,
|
||||
modelId,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId,
|
||||
});
|
||||
|
||||
const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pilot => ({
|
||||
id,
|
||||
name,
|
||||
isAvatar,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
skillPointsAvailable: 0,
|
||||
assignedShipId: shipId,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
});
|
||||
|
||||
const marsJob: Contract = {
|
||||
id: "c-mars",
|
||||
originId: "earth",
|
||||
destinationId: "mars",
|
||||
cargoTypeId: "standard",
|
||||
tier: 0,
|
||||
massTonnes: 15,
|
||||
payout: 500_000,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 3_600_000,
|
||||
firstContact: false,
|
||||
};
|
||||
|
||||
const state = samplePlayerState({
|
||||
tutorial: null,
|
||||
company: { id: "c", name: "D", credits: 1_000_000 },
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships: [
|
||||
ship("s-courier", "Dusty Mule", "courier", "avatar"),
|
||||
ship("s-freighter", "Slow Ox", "freighter", "p-rae"),
|
||||
ship("s-spare", "Spare Kettle", "courier", null),
|
||||
],
|
||||
pilots: [
|
||||
pilot("avatar", "Captain", "s-courier", true),
|
||||
pilot("p-rae", "Rae Okafor", "s-freighter", false),
|
||||
],
|
||||
});
|
||||
|
||||
function api(createDispatch?: () => never) {
|
||||
return fakeApi({
|
||||
getPlayerState: () => state,
|
||||
getContractBoard: () => ({ contracts: [marsJob], periodEndsAt: NOW + 3_600_000 }),
|
||||
...(createDispatch ? { createDispatch } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function openSheet() {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
const option = (dialog: HTMLElement, shipName: string) =>
|
||||
within(dialog).getByText(shipName).closest("li")!;
|
||||
|
||||
describe("dispatch sheet", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows the sim's trip time, fuel, net profit and ETAs for an eligible pair", async () => {
|
||||
renderApp(api(), "/contracts");
|
||||
const dialog = await openSheet();
|
||||
const freighter = option(dialog, "Slow Ox");
|
||||
|
||||
const model = content.shipModel("freighter")!;
|
||||
const earth = content.location("earth")!;
|
||||
const mars = content.location("mars")!;
|
||||
const gameNow = realToGame(NOW, content.config);
|
||||
const tripMs =
|
||||
(travelTimeGameHours(model, routeDistance(earth, mars, gameNow)) * MS_PER_HOUR) /
|
||||
content.config.timeCompression;
|
||||
const fuel = tripFuelCost(model, earth, mars, gameNow);
|
||||
const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery;
|
||||
|
||||
expect(within(freighter).getByRole("radio")).toBeEnabled();
|
||||
expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`);
|
||||
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`);
|
||||
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
|
||||
expect(freighter).toHaveTextContent(
|
||||
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config))}`,
|
||||
);
|
||||
expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`);
|
||||
});
|
||||
|
||||
it("lists ineligible pairs disabled, with the reason", async () => {
|
||||
renderApp(api(), "/contracts");
|
||||
const dialog = await openSheet();
|
||||
|
||||
const courier = option(dialog, "Dusty Mule");
|
||||
expect(within(courier).getByRole("radio")).toBeDisabled();
|
||||
expect(courier).toHaveTextContent("Out of range");
|
||||
|
||||
const spare = option(dialog, "Spare Kettle");
|
||||
expect(within(spare).getByRole("radio")).toBeDisabled();
|
||||
expect(spare).toHaveTextContent("No pilot assigned");
|
||||
});
|
||||
|
||||
it("sends the dispatch for the chosen pair on Confirm", async () => {
|
||||
const fake = api();
|
||||
renderApp(fake, "/contracts");
|
||||
const dialog = await openSheet();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
|
||||
await waitFor(() =>
|
||||
expect(fake.call).toHaveBeenCalledWith("createDispatch", {
|
||||
kind: "contract",
|
||||
contractId: "c-mars",
|
||||
shipId: "s-freighter",
|
||||
pilotId: "p-rae",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the server's message when the dispatch is refused", async () => {
|
||||
renderApp(
|
||||
api(() => {
|
||||
throw new ApiError(409, "contract-expired", "This contract has expired.");
|
||||
}),
|
||||
"/contracts",
|
||||
);
|
||||
const dialog = await openSheet();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"This contract has expired.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
|
||||
import { dispatchProgress } from "@deadhead/sim";
|
||||
import { act, screen, waitFor, 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 { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const flight: Dispatch = {
|
||||
id: "d1",
|
||||
shipId: "s-fly",
|
||||
pilotId: "p2",
|
||||
contract: null,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
tutorial: false,
|
||||
departureAt: NOW - 60_000,
|
||||
arrivalAt: NOW + 180_000,
|
||||
distanceMillionKm: 0.39,
|
||||
fuelCost: 50,
|
||||
expectedPayout: 0,
|
||||
seed: 1,
|
||||
status: "in-flight",
|
||||
resolvedAt: null,
|
||||
};
|
||||
|
||||
const ship = (overrides: Partial<Ship>): Ship => ({
|
||||
id: "s",
|
||||
modelId: "courier",
|
||||
name: "Ship",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function fleetState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
const base = samplePlayerState({ tutorial: null });
|
||||
return {
|
||||
...base,
|
||||
bays: [
|
||||
{ id: "b1", locationId: "earth" },
|
||||
{ id: "b2", locationId: "moon" },
|
||||
{ id: "b3", locationId: "mars" },
|
||||
],
|
||||
ships: [
|
||||
ship({ id: "s-idle", name: "Dusty Mule", pilotId: "avatar" }),
|
||||
ship({
|
||||
id: "s-fly",
|
||||
name: "Slow Ox",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
pilotId: "p2",
|
||||
}),
|
||||
],
|
||||
pilots: [
|
||||
{ ...base.pilots[0]!, assignedShipId: "s-idle" },
|
||||
{
|
||||
...base.pilots[0]!,
|
||||
id: "p2",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s-fly",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
},
|
||||
],
|
||||
activeDispatches: [flight],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fleet screen", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows an in-flight ship's progress from the sim, and it advances", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
|
||||
const bar = await screen.findByRole("progressbar", { name: "Slow Ox progress" });
|
||||
expect(bar).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
String(Math.round(dispatchProgress(flight, NOW) * 100)),
|
||||
);
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "25");
|
||||
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "50");
|
||||
expect(screen.getByText("Earth → Moon", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("2m 00s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists each ship with model, location and pilot", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
|
||||
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||
expect(idle).toHaveTextContent("Courier");
|
||||
expect(idle).toHaveTextContent("At Earth");
|
||||
expect(idle).toHaveTextContent("Captain");
|
||||
});
|
||||
|
||||
it("repositions to a destination chosen from the player's other bays only", async () => {
|
||||
const api = fakeApi({ getPlayerState: () => fleetState() });
|
||||
renderApp(api, "/fleet");
|
||||
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||
await userEvent.click(within(idle).getByRole("button", { name: "Reposition" }));
|
||||
|
||||
const picker = screen.getByRole("dialog", { name: "Reposition Dusty Mule" });
|
||||
const options = within(picker)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
.filter((t) => t !== "Cancel");
|
||||
expect(options).toEqual(["Moon", "Mars"]);
|
||||
|
||||
await userEvent.click(within(picker).getByRole("button", { name: "Moon" }));
|
||||
await waitFor(() =>
|
||||
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
||||
kind: "reposition",
|
||||
shipId: "s-idle",
|
||||
pilotId: "avatar",
|
||||
destinationId: "moon",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer reposition to in-flight ships", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
|
||||
const flying = (await screen.findByText("Slow Ox")).closest("li")!;
|
||||
expect(within(flying).queryByRole("button", { name: "Reposition" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||
import { act, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { tutorialGoalCost } from "../src/screens/HQ";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const ship = (id: string, status: Ship["status"]): Ship => ({
|
||||
id,
|
||||
modelId: "courier",
|
||||
name: id,
|
||||
status,
|
||||
locationId: status === "idle" ? "earth" : null,
|
||||
dispatchId: status === "idle" ? null : `d-${id}`,
|
||||
pilotId: null,
|
||||
});
|
||||
|
||||
const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
|
||||
id: `d-${shipId}`,
|
||||
shipId,
|
||||
pilotId: "avatar",
|
||||
contract: null,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
tutorial: false,
|
||||
departureAt: NOW - 60_000,
|
||||
arrivalAt,
|
||||
distanceMillionKm: 0.39,
|
||||
fuelCost: 50,
|
||||
expectedPayout: 0,
|
||||
seed: 1,
|
||||
status: "in-flight",
|
||||
resolvedAt: null,
|
||||
});
|
||||
|
||||
describe("HQ screen", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows idle and in-flight counts and counts down to the next arrival", async () => {
|
||||
const state = samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")],
|
||||
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
|
||||
});
|
||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
||||
|
||||
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
|
||||
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();
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(within(next).getByText("55s")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(/Save up for your first ship/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => {
|
||||
const goal = tutorialGoalCost();
|
||||
const state = samplePlayerState({
|
||||
company: { id: "c", name: "D", credits: Math.round(goal * 0.4) },
|
||||
});
|
||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Save up for your first ship and an Earth bay"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar", { name: "Tutorial goal" })).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"40",
|
||||
);
|
||||
expect(screen.getByText("40%")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("caps tutorial progress at 100%", async () => {
|
||||
const state = samplePlayerState({
|
||||
company: { id: "c", name: "D", credits: tutorialGoalCost() * 3 },
|
||||
});
|
||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||
expect(await screen.findByText("100%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const item = (name: string) => screen.getByText(name).closest("li")!;
|
||||
|
||||
describe("locations screen", () => {
|
||||
it("hides details of undiscovered locations", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
|
||||
await screen.findByText("Mars");
|
||||
const mars = item("Mars");
|
||||
expect(mars).toHaveTextContent("Undiscovered — deliver here to unlock.");
|
||||
expect(mars).not.toHaveTextContent("Produces");
|
||||
expect(within(mars).queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows details of discovered locations, with distance from Earth", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
|
||||
await screen.findByText("Moon");
|
||||
const moon = item("Moon");
|
||||
expect(moon).toHaveTextContent("TypeAirless body");
|
||||
expect(moon).toHaveTextContent("LandingAny ship");
|
||||
expect(moon).toHaveTextContent("ProducesStandard, Heavy");
|
||||
expect(moon).toHaveTextContent(/M km from Earth/);
|
||||
expect(item("Earth")).toHaveTextContent("Home");
|
||||
});
|
||||
|
||||
it("offers leasing only at discovered locations without a bay", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
company: { id: "c", name: "D", credits: 100_000 },
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/locations");
|
||||
await screen.findByText("Moon");
|
||||
|
||||
expect(item("Earth")).toHaveTextContent("Bay owned");
|
||||
expect(within(item("Earth")).queryByRole("button")).not.toBeInTheDocument();
|
||||
|
||||
const moonPrice = content.location("moon")!.bayLeasePrice;
|
||||
const lease = within(item("Moon")).getByRole("button", { name: "Lease bay at Moon" });
|
||||
expect(lease).toHaveTextContent(`Lease bay (${moonPrice.toLocaleString("en-US")} cr)`);
|
||||
await userEvent.click(lease);
|
||||
expect(api.call).toHaveBeenCalledWith("leaseBay", { locationId: "moon" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Lease bay at Mars" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables leasing the player cannot afford", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
|
||||
expect(await screen.findByRole("button", { name: "Lease bay at Earth" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Ship } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const courierPrice = content.shipModel("courier")!.price;
|
||||
const freighterPrice = content.shipModel("freighter")!.price;
|
||||
|
||||
describe("market screen", () => {
|
||||
it("lists every ship model with its stats", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market");
|
||||
const items = await screen.findAllByRole("listitem");
|
||||
expect(items).toHaveLength(content.shipModels.length);
|
||||
const courier = items[0]!;
|
||||
expect(courier).toHaveTextContent("Courier");
|
||||
expect(courier).toHaveTextContent("3.00M km/h");
|
||||
expect(courier).toHaveTextContent("Capacity20 t");
|
||||
expect(courier).toHaveTextContent("Range5.00M km");
|
||||
expect(courier).toHaveTextContent("LandingAtmospheric");
|
||||
});
|
||||
|
||||
it("disables Buy for ships the player cannot afford", async () => {
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({ company: { id: "c", name: "D", credits: courierPrice } }),
|
||||
}),
|
||||
"/market",
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: "Buy Courier" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Buy Freighter" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Buy Bulk Hauler" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("buys a ship, which then appears in the Fleet", async () => {
|
||||
const bought: Ship = {
|
||||
id: "s-new",
|
||||
modelId: "freighter",
|
||||
name: "Lucky Heron",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
};
|
||||
let owned: Ship[] = [];
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: owned,
|
||||
company: { id: "c", name: "D", credits: owned.length ? 1 : freighterPrice },
|
||||
}),
|
||||
buyShip: () => {
|
||||
owned = [bought];
|
||||
return { ship: bought };
|
||||
},
|
||||
});
|
||||
renderApp(api, "/market");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" }));
|
||||
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter" });
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at Earth.");
|
||||
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
||||
expect(await screen.findByText("Lucky Heron")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -6,14 +6,9 @@ import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
|
|||
|
||||
describe("guest onboarding", () => {
|
||||
it("shows the start screen without a session; the button creates a guest and opens HQ", async () => {
|
||||
// Like the real server: no session until the guest is created.
|
||||
let signedUp = false;
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => (signedUp ? samplePlayerState() : unauthorized()),
|
||||
createGuest: () => {
|
||||
signedUp = true;
|
||||
return { playerId: "p1", state: samplePlayerState() };
|
||||
},
|
||||
getPlayerState: unauthorized,
|
||||
createGuest: () => ({ playerId: "p1", state: samplePlayerState() }),
|
||||
});
|
||||
renderApp(api, "/fleet");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { levelProgress } from "../src/screens/Pilots";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const ship = (overrides: Partial<Ship>): Ship => ({
|
||||
id: "s",
|
||||
modelId: "courier",
|
||||
name: "Ship",
|
||||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const rae: Pilot = {
|
||||
id: "p-rae",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
xp: 260,
|
||||
level: 2,
|
||||
skills: { "heavy-cargo": 1 },
|
||||
skillPointsAvailable: 3,
|
||||
assignedShipId: null,
|
||||
status: "idle",
|
||||
locationId: "moon",
|
||||
};
|
||||
|
||||
function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
const base = samplePlayerState({ tutorial: null });
|
||||
return {
|
||||
...base,
|
||||
company: { ...base.company, credits: 50_000 },
|
||||
bays: [
|
||||
{ id: "b1", locationId: "earth" },
|
||||
{ id: "b2", locationId: "moon" },
|
||||
],
|
||||
pilots: [{ ...base.pilots[0]!, assignedShipId: "s-earth" }, rae],
|
||||
ships: [
|
||||
ship({ id: "s-earth", name: "Earth Crewed", locationId: "earth", pilotId: "avatar" }),
|
||||
ship({ id: "s-moon-free", name: "Moon Free" }),
|
||||
ship({ id: "s-moon-crewed", name: "Moon Crewed", pilotId: "someone" }),
|
||||
ship({ id: "s-earth-free", name: "Earth Free", locationId: "earth" }),
|
||||
ship({ id: "s-flying", name: "Flying", status: "in-flight", locationId: null }),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pilots screen", () => {
|
||||
it("shows level, XP progress, skills and points", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => pilotsState() }), "/pilots");
|
||||
const card = (await screen.findByText("Rae Okafor")).closest("li")!;
|
||||
const { xp, needed } = levelProgress(260, 2)!;
|
||||
expect(card).toHaveTextContent("Level 2");
|
||||
expect(card).toHaveTextContent(`${xp} / ${needed} XP to next level`);
|
||||
expect(card).toHaveTextContent("Heavy Cargo 1/5");
|
||||
expect(within(card).getByLabelText("Rae Okafor skill points")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
it("spends a skill point through the endpoint and shows the updated pilot", async () => {
|
||||
let spent = false;
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
pilotsState({
|
||||
pilots: [
|
||||
pilotsState().pilots[0]!,
|
||||
spent ? { ...rae, skills: { "heavy-cargo": 2 }, skillPointsAvailable: 2 } : rae,
|
||||
],
|
||||
}),
|
||||
spendSkillPoint: ({ pilotId, skillId }) => {
|
||||
spent = true;
|
||||
return {
|
||||
pilot: { ...rae, id: pilotId, skills: { [skillId]: 2 }, skillPointsAvailable: 2 },
|
||||
};
|
||||
},
|
||||
});
|
||||
renderApp(api, "/pilots");
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Raise Heavy Cargo for Rae Okafor" }),
|
||||
);
|
||||
|
||||
expect(api.call).toHaveBeenCalledWith("spendSkillPoint", {
|
||||
pilotId: "p-rae",
|
||||
skillId: "heavy-cargo",
|
||||
});
|
||||
const card = screen.getByText("Rae Okafor").closest("li")!;
|
||||
await waitFor(() => expect(card).toHaveTextContent("Heavy Cargo 2/5"));
|
||||
expect(within(card).getByLabelText("Rae Okafor skill points")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("disables hiring when the player cannot afford a pilot", async () => {
|
||||
const cost = content.config.pilotHireCost;
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => pilotsState({ company: { id: "c", name: "D", credits: cost - 1 } }),
|
||||
}),
|
||||
"/pilots",
|
||||
);
|
||||
const hire = await screen.findByRole("button", { name: /Hire pilot/ });
|
||||
expect(hire).toBeDisabled();
|
||||
expect(hire).toHaveTextContent(`${cost.toLocaleString("en-US")} cr`);
|
||||
});
|
||||
|
||||
it("hires at the chosen bay", async () => {
|
||||
const api = fakeApi({ getPlayerState: () => pilotsState() });
|
||||
renderApp(api, "/pilots");
|
||||
await userEvent.selectOptions(await screen.findByLabelText("Hire at"), "moon");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Hire pilot/ }));
|
||||
expect(api.call).toHaveBeenCalledWith("hirePilot", { locationId: "moon" });
|
||||
});
|
||||
|
||||
it("offers only idle, uncrewed ships at the pilot's location for assignment", async () => {
|
||||
const api = fakeApi({ getPlayerState: () => pilotsState() });
|
||||
renderApp(api, "/pilots");
|
||||
const select = await screen.findByLabelText("Assign Rae Okafor to");
|
||||
const options = within(select)
|
||||
.getAllByRole("option")
|
||||
.map((o) => o.textContent);
|
||||
expect(options).toEqual(["Choose a ship…", "Moon Free (Courier)"]);
|
||||
|
||||
await userEvent.selectOptions(select, "s-moon-free");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Assign" }));
|
||||
expect(api.call).toHaveBeenCalledWith("assignPilot", {
|
||||
pilotId: "p-rae",
|
||||
shipId: "s-moon-free",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { focusManager } from "@tanstack/react-query";
|
||||
import { act, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SCREENS } from "../src/routes";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const networkDown = () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
};
|
||||
|
||||
const afterTutorial = samplePlayerState({ tutorial: null });
|
||||
|
||||
describe("network errors", () => {
|
||||
afterEach(() => focusManager.setFocused(undefined));
|
||||
|
||||
it.each(SCREENS)(
|
||||
"$title: a failed first load offers Retry, which recovers",
|
||||
async ({ path, title }) => {
|
||||
let online = false;
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => (online ? afterTutorial : networkDown()),
|
||||
getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }),
|
||||
}),
|
||||
path,
|
||||
);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Could not reach the server.");
|
||||
|
||||
online = true;
|
||||
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SCREENS)(
|
||||
"$title: a failed refresh keeps the screen and shows an offline banner",
|
||||
async ({ path, title }) => {
|
||||
let online = true;
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => (online ? afterTutorial : networkDown()),
|
||||
getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }),
|
||||
}),
|
||||
path,
|
||||
);
|
||||
await screen.findByRole("heading", { level: 1, name: title });
|
||||
|
||||
online = false;
|
||||
act(() => {
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
});
|
||||
const banner = await screen.findByText("Offline — showing last update.");
|
||||
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||
|
||||
online = true;
|
||||
await userEvent.click(within(banner.parentElement!).getByRole("button", { name: "Retry" }));
|
||||
await act(async () => {});
|
||||
expect(screen.queryByText("Offline — showing last update.")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("Contracts: a failed board load offers Retry", async () => {
|
||||
let online = false;
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => ({ ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] }),
|
||||
getContractBoard: () => (online ? { contracts: [], periodEndsAt: 0 } : networkDown()),
|
||||
}),
|
||||
"/contracts",
|
||||
);
|
||||
const alert = await screen.findByText("Could not load contracts.", { exact: false });
|
||||
online = true;
|
||||
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText(/No contracts left this period/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty states", () => {
|
||||
const show = (path: string, state = afterTutorial, extra = {}) =>
|
||||
renderApp(fakeApi({ getPlayerState: () => state, ...extra }), path);
|
||||
|
||||
it("HQ: no ships points to the Market", async () => {
|
||||
show("/");
|
||||
expect(await screen.findByText("No ships yet — buy one in the Market.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Fleet: no ships links to the Market", async () => {
|
||||
show("/fleet");
|
||||
expect(await screen.findByRole("link", { name: "visit the Market" })).toHaveAttribute(
|
||||
"href",
|
||||
"/market",
|
||||
);
|
||||
});
|
||||
|
||||
it("Contracts: no bays explains how to get contracts", async () => {
|
||||
show("/contracts");
|
||||
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Contracts: an empty bay board says to check back", async () => {
|
||||
show(
|
||||
"/contracts",
|
||||
{ ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] },
|
||||
{ getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }) },
|
||||
);
|
||||
expect(await screen.findByText(/No contracts left this period/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Contracts: an empty tutorial board says to check back", async () => {
|
||||
show("/contracts", samplePlayerState(), {
|
||||
getTutorialBoard: () => ({ contracts: [], periodEndsAt: 0 }),
|
||||
});
|
||||
expect(await screen.findByText(/No company runs left this period/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Pilots: no hired pilots suggests hiring", async () => {
|
||||
show("/pilots");
|
||||
expect(await screen.findByText(/No hired pilots yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Lease a bay to hire pilots there.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Market and Locations always have content for a new player", async () => {
|
||||
show("/market");
|
||||
expect(await screen.findByRole("button", { name: "Buy Courier" })).toBeInTheDocument();
|
||||
show("/locations");
|
||||
expect((await screen.findAllByText("Earth")).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Contract } from "@deadhead/schema";
|
||||
import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ApiError } from "../src/api/client";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { formatDuration } from "../src/format";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const contract = (id: string, mass: number, payout: number): Contract => ({
|
||||
id,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
tier: 0,
|
||||
massTonnes: mass,
|
||||
payout,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 3_600_000,
|
||||
firstContact: false,
|
||||
});
|
||||
|
||||
const board = {
|
||||
contracts: [contract("t1", 12, 230), contract("t2", 18, 345)],
|
||||
periodEndsAt: NOW + 3_600_000,
|
||||
};
|
||||
|
||||
/** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */
|
||||
function expectedTripTime(now: number) {
|
||||
const distance = routeDistance(
|
||||
content.location("earth")!,
|
||||
content.location("moon")!,
|
||||
realToGame(now, content.config),
|
||||
);
|
||||
const hours = travelTimeGameHours(content.shipModel("courier")!, distance);
|
||||
return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression);
|
||||
}
|
||||
|
||||
describe("tutorial contract board", () => {
|
||||
beforeEach(() => useClock.setState({ offsetMs: 0, synced: true, now: NOW }));
|
||||
|
||||
it("shows each contract with route, cargo, payout and the sim's trip time", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => samplePlayerState(),
|
||||
getTutorialBoard: () => board,
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
|
||||
const items = await screen.findAllByRole("listitem");
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent("Earth → Moon");
|
||||
expect(items[0]).toHaveTextContent("Standard · 12 t");
|
||||
expect(items[0]).toHaveTextContent("230 cr");
|
||||
expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent(
|
||||
expectedTripTime(useClock.getState().now),
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches the tutorial run with Fly it", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => samplePlayerState(),
|
||||
getTutorialBoard: () => board,
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
const [first] = await screen.findAllByRole("listitem");
|
||||
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
|
||||
await waitFor(() =>
|
||||
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
||||
kind: "tutorial",
|
||||
contractId: "t1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables Fly it while a tutorial run is in flight", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true } }),
|
||||
getTutorialBoard: () => board,
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
await screen.findAllByRole("listitem");
|
||||
for (const button of screen.getAllByRole("button", { name: "Fly it" })) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
expect(screen.getByText(/Your run is in flight/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server's message when dispatch fails", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => samplePlayerState(),
|
||||
getTutorialBoard: () => board,
|
||||
createDispatch: () => {
|
||||
throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying.");
|
||||
},
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
const [first] = await screen.findAllByRole("listitem");
|
||||
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Your tutorial run is still flying.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -418,9 +418,5 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
|||
| `src/App.tsx` | Clock sync; loading / start screen / routed layout |
|
||||
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
|
||||
| `src/routes.ts` | Screen list shared by router and navigation |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
||||
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
||||
|
||||
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
|
||||
|
|
|
|||
|
|
@ -77,21 +77,8 @@
|
|||
|
||||
## Phase 8 — Client Foundation
|
||||
|
||||
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check: passed on the user's iPhone.
|
||||
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
|
||||
- [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message.
|
||||
- [x] **Step 8.3 — Server-synced clock.** `apps/web/src/clock`: Zustand `useClock` store, `syncClock(api)` (offset from server time, half round trip), `serverNow()`, `startClockTicker()` (1 s), and a `GameClock` header component showing in-game time.
|
||||
- [x] **Step 8.4 — Guest onboarding.** No session (state request returns 401) shows `StartScreen`; "Start your company" calls `createGuest`, caches the returned state and opens HQ. The session is Better Auth's persistent cookie. TanStack Query and an `ApiProvider` context added; header shows live credits. Verified end to end through the Vite proxy.
|
||||
- [x] **Step 8.5 — Player state store.** `usePlayerState` (TanStack Query) refetches on window focus and, via `useRefetchOnArrival`, once per dispatch when the server clock passes its arrival time.
|
||||
|
||||
## Phase 9 — Client Screens
|
||||
|
||||
- [x] **Step 9.1 — HQ screen.** `src/screens/HQ.tsx`: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: `src/format.ts`, `Countdown`, `useLoadedPlayer` / `useGameAction` (`src/state/actions.ts`).
|
||||
- [x] **Step 9.2 — "While you were away" summary.** `src/components/AwaySummary.tsx`: dialog over every screen listing each unacknowledged delivery (ship or "Company loaner", route, credits, XP, discovery); "Got it" hides it at once and calls `acknowledgeSummaries`.
|
||||
- [x] **Step 9.3 — Tutorial contract board.** Contracts screen shows the tutorial board during the tutorial (`src/screens/TutorialBoard.tsx`): route, cargo, mass, payout, sim-predicted trip time (`src/game.ts`), and "Fly it" (disabled while a run is in flight; server errors shown).
|
||||
- [x] **Step 9.4 — Bay contract boards.** After the tutorial the Contracts screen shows `BayBoards`: a bay selector and, per contract, route, cargo (and tier), mass, current distance, payout, required skills, live expiry countdown and a first-contact badge.
|
||||
- [x] **Step 9.5 — Dispatch flow.** `src/screens/DispatchSheet.tsx`: each idle ship at the pickup with its pilot, checked with sim `validateDispatch`; eligible pairs show trip time, fuel, net profit and game/real ETA; ineligible pairs are disabled with a short reason; Confirm sends the dispatch and shows server errors.
|
||||
- [x] **Step 9.6 — Fleet screen.** `src/screens/Fleet.tsx`: ships with model, location or route, pilot; in-flight progress bar (sim `dispatchProgress`) and countdown; "Reposition" for idle crewed ships at a bay opens a picker of the player's other bays.
|
||||
- [x] **Step 9.7 — Pilots screen.** `src/screens/Pilots.tsx`: level, XP progress to next level, skills, points, assignment and status per pilot; hire at a chosen bay (cost shown, disabled when unaffordable); assign to idle uncrewed ships at the pilot's location; "+" to spend a skill point.
|
||||
- [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh.
|
||||
- [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
|
||||
- [x] **Step 9.10 — Empty and error states.** First-load failure shows "Could not reach the server" with Retry; a failed refresh keeps the last state with an "Offline — showing last update" banner and Retry; boards have their own Retry. Empty states on HQ, Fleet, Contracts (no bays, empty bay/tutorial board) and Pilots. Tests cover every screen.
|
||||
|
|
|
|||
Loading…
Reference in New Issue