Compare commits

..

8 Commits

Author SHA1 Message Date
fireshaper 83cb2ae306 Step 20.1: content and config
CI / check (push) Has been cancelled Details
GameConfig.independentPilots: Dispatch Office price, first-look window,
turnaround, empty periods before heading home, catch-up cap and spending
floor (CR-020).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:55 -04:00
fireshaper f7bbc6c1ea Record CR-020: independent pilots (Phase 20)
Received as CR-019 for Phase 19; renumbered. Decisions 0.5.118-0.5.126.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:59:17 -04:00
fireshaper 2a0a9fb0f1 Dashboard: drop the Credits and Game date tiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:45:19 -04:00
fireshaper 716002b3c7 Step 20.4: CR-020 amendments, regression and screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:34:15 -04:00
fireshaper f948a22d8c Step 20.3: Dashboard with your ship, alerts and ships on jobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:28:58 -04:00
fireshaper fec2c481df Step 20.2: HQs screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:24:20 -04:00
fireshaper a16c34ffc3 Step 20.1: Bridge and HQs tabs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:22:09 -04:00
fireshaper 4b6bf64a82 Record CR-020: Bridge dashboard and HQs tab (Phase 20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:19:51 -04:00
34 changed files with 1256 additions and 219 deletions

View File

@ -1655,3 +1655,22 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **e2e 12.6 no longer shows a Heavy job blocked by skill on the board**: under CR-019 the job isn't offered until someone has the skill, so the test checks that instead and dispatches once the skill is learned. The dispatch sheet's "Needs Heavy Cargo 1 (pilot has 0)" reason is still covered by the component tests. - **e2e 12.6 no longer shows a Heavy job blocked by skill on the board**: under CR-019 the job isn't offered until someone has the skill, so the test checks that instead and dispatches once the skill is learned. The dispatch sheet's "Needs Heavy Cargo 1 (pilot has 0)" reason is still covered by the component tests.
- **The balance simulator reads boards the way the engine does** (qualifying fleet, read-time filter). The new `jobsShown`/`uncompletableShown` counts make "never sees a job it can't complete" a checked invariant. - **The balance simulator reads boards the way the engine does** (qualifying fleet, read-time filter). The new `jobsShown`/`uncompletableShown` counts make "never sees a job it can't complete" a checked invariant.
## CR-020 — Bridge Dashboard and HQs Tab (Phase 20)
- **Requested by the user directly** (not a supplied CR), so it takes the next free numbers: CR-020, Phase 20, decisions 0.5.118–0.5.121.
- **Asked:** the Dashboard's tab label (user chose "Bridge", since eight tabs leave about 48 px each on a phone and "Dashboard" won't fit) and whether Alerts should already list alerts the game can derive (user chose an empty placeholder for the future in-flight alerts/news).
## Follow-up after Phase 20: fewer Dashboard tiles
- **The Dashboard drops its Credits and Game date tiles** (user request): both are always in the header's chips. Ships idle and Ships in flight stay, side by side.
## CR-020 recorded (Independent Pilots)
- **Numbering:** received as "CR-019 — Independent Pilots" for "Phase 19"; stored as **CR-020**, built as **Phase 20** (its 19.n = 20.n). Its decisions CR-019.D1–D9 are **0.5.118–0.5.126**. Its references: CR-005 → CR-007, CR-007 → CR-010, CR-011 → CR-014 (its 10K.6 → Step 14.6), CR-014 → CR-015, CR-016 → CR-017, CR-017 → CR-018, CR-018 → CR-019; CR-001 and CR-004 are unchanged.
- **Plan 0.3:** automation/standing routes is struck from the deferred list; standing orders, pilot traits, decision events and a hiring market stay deferred.
## Step 20.1
- **The settings live in one `independentPilots` group** in GameConfig.
- **The Dispatch Office costs exactly a Lighter (36,000 cr)**, the CR's "start around the price of a Lighter", until Step 20.8's balance pass says otherwise.

View File

@ -94,7 +94,7 @@ export async function playToDeal(page: Page) {
await page.goto("/"); await page.goto("/");
await dismissLogbook(page); await dismissLogbook(page);
await page.getByRole("button", { name: "Shake on it" }).click(); await page.getByRole("button", { name: "Shake on it" }).click();
await expect(page.getByRole("heading", { level: 1, name: "HQ" })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Dashboard" })).toBeVisible();
// Both pilots have skill points at the deal (CR-017); the pop-up says so. // Both pilots have skill points at the deal (CR-017); the pop-up says so.
const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" }); const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" });
await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/); await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/);

View File

@ -11,7 +11,8 @@ import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes"; import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts"; import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet"; import { Fleet } from "./screens/Fleet";
import { HQ } from "./screens/HQ"; import { Dashboard } from "./screens/Dashboard";
import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations"; import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market"; import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots"; import { Pilots } from "./screens/Pilots";
@ -25,7 +26,7 @@ import { Button } from "./ui";
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen }))); const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = { const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <HQ />, "/": <Dashboard />,
"/map": ( "/map": (
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}> <Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
<MapScreen /> <MapScreen />
@ -34,6 +35,7 @@ const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/contracts": <Contracts />, "/contracts": <Contracts />,
"/fleet": <Fleet />, "/fleet": <Fleet />,
"/pilots": <Pilots />, "/pilots": <Pilots />,
"/hqs": <HQs />,
"/market": <Market />, "/market": <Market />,
"/locations": <Locations />, "/locations": <Locations />,
}; };

View File

@ -154,7 +154,7 @@ export function AwaySummary() {
}} }}
className="mt-3" className="mt-3"
> >
Claim on HQ Claim on the Bridge
</Button> </Button>
</div> </div>
)} )}

View File

@ -70,7 +70,7 @@ export function Layout({
<nav <nav
aria-label="Main" aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-7 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-1 md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4" className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-0.5 md:overflow-y-auto md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
> >
<span <span
aria-hidden="true" aria-hidden="true"
@ -106,7 +106,7 @@ function NavTab({
to={path} to={path}
end={path === "/"} end={path === "/"}
className={({ isActive }) => className={({ isActive }) =>
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-2 ${ `group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-1.5 ${
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50" isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50"
}` }`
} }
@ -120,7 +120,7 @@ function NavTab({
}`} }`}
/> />
<span <span
className={`relative grid size-10 place-items-center rounded-xl transition md:size-12 ${ className={`relative grid size-10 place-items-center rounded-xl transition md:size-11 ${
isActive isActive
? "bg-amber-500/15 shadow-glow-amber" ? "bg-amber-500/15 shadow-glow-amber"
: "group-hover:bg-white/5 group-active:scale-95" : "group-hover:bg-white/5 group-active:scale-95"
@ -137,7 +137,7 @@ function NavTab({
</span> </span>
)} )}
</span> </span>
<span className="font-display text-[10px] font-semibold tracking-wider uppercase sm:text-[11px]"> <span className="font-display text-[10px] font-semibold tracking-wide uppercase sm:text-[11px] sm:tracking-wider">
{label} {label}
</span> </span>
</> </>

View File

@ -1,12 +1,14 @@
import type { IconName } from "./ui/icons"; import type { IconName } from "./ui/icons";
/** The app's top-level screens, in navigation order. Labels are short to fit seven tabs. */ /** The app's top-level screens, in navigation order. Labels are short to fit eight tabs. */
export const SCREENS = [ export const SCREENS = [
{ path: "/", title: "HQ", label: "HQ", icon: "hq" }, // The Dashboard, "Bridge" in the navigation (CR-020, decision 0.5.118).
{ path: "/", title: "Dashboard", label: "Bridge", icon: "helm" },
{ path: "/map", title: "Map", label: "Map", icon: "map" }, { path: "/map", title: "Map", label: "Map", icon: "map" },
{ path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" }, { path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" },
{ path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" }, { path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" }, { path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" },
{ path: "/hqs", title: "HQs", label: "HQs", icon: "hq" },
{ path: "/market", title: "Market", label: "Shop", icon: "market" }, { path: "/market", title: "Market", label: "Shop", icon: "market" },
{ path: "/locations", title: "Locations", label: "Places", icon: "places" }, { path: "/locations", title: "Locations", label: "Places", icon: "places" },
] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[]; ] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];

View File

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

View File

@ -1,186 +0,0 @@
import { content } from "@deadhead/content";
import type { TutorialState } from "@deadhead/schema";
import { dispatchProgress, realToGame } from "@deadhead/sim";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { GoalsCard } from "../components/GoalsCard";
import { LoanCard } from "../components/LoanCard";
import { RouteStrip } from "../components/RouteStrip";
import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
/** What the player needs to do next to reach the deal (decision 0.5.30). */
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
if (tutorial.dealAvailable) return "Halvorsen is waiting at Earth with an offer.";
if (tutorial.runsCompleted < runsBeforeDeal) {
return "Keep flying — the old man has an offer for you.";
}
// Enough runs, but the deal is made at Earth.
return tutorial.runInFlight
? "Halvorsen will talk once you land at Earth."
: "One more run: bring the ship back to Earth — Halvorsen is waiting there.";
}
export function HQ() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const clockFormat = usePreferences((p) => p.clock);
const idle = state.ships.filter((s) => s.status === "idle").length;
const flying = state.ships.filter((s) => s.status === "in-flight").length;
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
// Resting pilots and who wakes first (CR-004).
const resting = state.pilots
.filter((p) => p.resting && p.restEndsAt !== null)
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
// Ships in the upgrade shop, soonest out first (CR-009).
const inShop = state.ships
.filter((s) => s.inShopUntil !== null && now < s.inShopUntil)
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
const nextShip = next?.tutorial
? "ship-company-courier"
: content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
return (
<Screen className="max-w-5xl">
<ScreenHeader title="HQ" eyebrow={state.company.name} icon="hq" />
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile
label="Credits"
value={formatCredits(state.company.credits)}
icon="credits"
tone="amber"
/>
<StatTile
label="Game date"
value={formatGameTime(realToGame(now, content.config), clockFormat)}
icon="clock"
/>
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4">
{state.tutorial && (
<Panel feature tone="amber">
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
Flying for Halvorsen Lunar Freight
</h2>
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
{runsBeforeDeal}
</p>
<RunPips done={state.tutorial.runsCompleted} total={runsBeforeDeal} />
<p aria-label="Next step" className="mt-2 text-sm text-ink-300">
{tutorialHint(state.tutorial, runsBeforeDeal)}
</p>
{!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
<Link to="/contracts" className={`mt-3 ${buttonClass("primary", "md", true)}`}>
<Icon name="send" className="size-4" />
Fly a company run
</Link>
)}
</Panel>
)}
<Panel feature tone={next ? "cyan" : "default"}>
<h2 className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase">
Next arrival
</h2>
{next ? (
<>
<div className="mt-2">
<RouteStrip
originId={next.originId}
destinationId={next.destinationId}
progress={dispatchProgress(next, now)}
shipSprite={nextShip}
/>
</div>
<p className="mt-2 flex items-baseline justify-between gap-2">
<span className="text-sm text-ink-200">
{locationName(next.originId)} → {locationName(next.destinationId)} in
</span>
<span className="font-display text-2xl font-bold text-cyan-200">
<Countdown to={next.arrivalAt} />
</span>
</p>
</>
) : (
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
)}
</Panel>
{!state.tutorial && (
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
<Icon name="rest" className="size-5" />
</span>
<div className="min-w-0">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
Pilots resting: {resting.length}
</h2>
{resting[0] ? (
<p className="text-sm text-sky-200">
{resting[0].name} wakes in{" "}
<Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
)}
</div>
</div>
</Panel>
)}
{inShop.length > 0 && (
<Panel aria-label="In the shop" tone="amber">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
In the shop: {inShop.length}
</h2>
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
{inShop.map((ship) => (
<li key={ship.id}>
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
</li>
))}
</ul>
</Panel>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
)}
</div>
<div className="space-y-4">
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div>
</div>
</Screen>
);
}
/** One pip per company run toward the deal. */
function RunPips({ done, total }: { done: number; total: number }) {
return (
<div aria-hidden="true" className="mt-2 flex gap-1.5">
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
/>
))}
</div>
);
}

View File

@ -0,0 +1,156 @@
import { content } from "@deadhead/content";
import type { Bay } from "@deadhead/schema";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
import { FuelPrice } from "../components/Fuel";
import { locationName, shipModelName } from "../format";
import { useLoadedPlayer } from "../state/actions";
import { Art, buttonClass, Eyebrow, Icon, Panel, Pill, Screen, ScreenHeader } from "../ui";
/** What owning an HQ allows (CR-002: bays are HQs; CR-020). */
export const HQ_ALLOWS = "Hire pilots here and take delivery of new ships here.";
/** The player's HQs (bays): information only (CR-020, decision 0.5.121). */
export function HQs() {
const state = useLoadedPlayer();
return (
<Screen className="max-w-4xl">
<ScreenHeader
title="HQs"
eyebrow="Your bases"
icon="hq"
subtitle={state.bays.length > 0 ? HQ_ALLOWS : undefined}
/>
{state.bays.length === 0 ? (
<Panel tone="muted" className="space-y-3 text-sm">
<p className="text-ink-200">You don&apos;t have an HQ yet.</p>
<p className="text-ink-400">
An HQ lets you hire pilots and take delivery of new ships at its location.
</p>
<Link to="/locations" className={buttonClass("primary", "sm")}>
<Icon name="places" className="size-4" />
Lease one in Places
</Link>
</Panel>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{state.bays.map((bay) => (
<HqCard key={bay.id} bay={bay} />
))}
</ul>
)}
</Screen>
);
}
function HqCard({ bay }: { bay: Bay }) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const location = content.location(bay.locationId);
const name = locationName(bay.locationId);
const shipsHere = state.ships.filter((s) => s.locationId === bay.locationId);
const inbound = state.activeDispatches
.filter((d) => d.destinationId === bay.locationId && !d.tutorial)
.sort((a, b) => a.arrivalAt - b.arrivalAt);
const spare = state.pilots.filter(
(p) => p.assignedShipId === null && p.status === "idle" && p.locationId === bay.locationId,
);
return (
<Panel as="li" feature tone="emerald" aria-label={`HQ at ${name}`} className="text-sm">
<div className="flex items-center gap-4">
{location && (
<Art
sprite={location.sprite}
className="size-16 shrink-0 drop-shadow-[0_0_14px_rgb(125_211_252/0.35)]"
/>
)}
<div className="min-w-0 flex-1">
<h2 className="font-display text-xl font-bold tracking-wide">{name}</h2>
<p className="text-ink-300">
Fuel: <FuelPrice locationId={bay.locationId} />
</p>
</div>
</div>
<section aria-label={`Ships at ${name}`} className="mt-4">
<Eyebrow>Ships here</Eyebrow>
{shipsHere.length === 0 ? (
<p className="mt-1 text-ink-400">None right now.</p>
) : (
<ul className="mt-1 space-y-1">
{shipsHere.map((ship) => {
const inShop = ship.inShopUntil !== null && now < ship.inShopUntil;
return (
<li key={ship.id} className="flex items-center justify-between gap-2">
<span>
<span className="font-semibold text-ink-50">{ship.name}</span>{" "}
<span className="text-ink-400">{shipModelName(ship.modelId)}</span>
</span>
{inShop ? (
<Pill tone="amber">In the shop</Pill>
) : (
<Pill tone="emerald">Idle</Pill>
)}
</li>
);
})}
</ul>
)}
</section>
{inbound.length > 0 && (
<section aria-label={`Inbound to ${name}`} className="mt-3">
<Eyebrow>Inbound</Eyebrow>
<ul className="mt-1 space-y-1">
{inbound.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2">
<span className="text-ink-200">
{state.ships.find((s) => s.id === d.shipId)?.name ?? "A ship"} from{" "}
{locationName(d.originId)}
</span>
<span className="font-display text-cyan-200">
<Countdown to={d.arrivalAt} />
</span>
</li>
))}
</ul>
</section>
)}
<section aria-label={`Pilots at ${name}`} className="mt-3">
<Eyebrow>Pilots without a ship</Eyebrow>
{spare.length === 0 ? (
<p className="mt-1 text-ink-400">None.</p>
) : (
<ul className="mt-1 space-y-1">
{spare.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-2">
<span className="font-semibold text-ink-50">{p.name}</span>
<span className="text-ink-400">
Level {p.level}
{p.resting && " · resting"}
</span>
</li>
))}
</ul>
)}
</section>
<div className="mt-4 flex flex-wrap gap-2">
<Link
to={`/contracts?location=${bay.locationId}`}
className={`${buttonClass("secondary", "sm")} flex-1 basis-36`}
>
<Icon name="jobs" className="size-4" />
View contracts
</Link>
<Link to="/pilots" className={`${buttonClass("secondary", "sm")} flex-1 basis-36`}>
<Icon name="hire" className="size-4" />
Hire pilots
</Link>
</div>
</Panel>
);
}

View File

@ -82,6 +82,14 @@ const PATHS = {
</> </>
), ),
close: <path d="M6 6l12 12M18 6 6 18" />, close: <path d="M6 6l12 12M18 6 6 18" />,
// A ship's helm: the Bridge (CR-020).
helm: (
<>
<circle cx="12" cy="12" r="6.5" />
<circle cx="12" cy="12" r="2" />
<path d="M12 2v3.5M12 18.5V22M2 12h3.5M18.5 12H22M4.9 4.9l2.5 2.5M16.6 16.6l2.5 2.5M4.9 19.1l2.5-2.5M16.6 7.4l2.5-2.5" />
</>
),
// A cog (outline after Lucide "settings", ISC licence). // A cog (outline after Lucide "settings", ISC licence).
gear: ( gear: (
<> <>

View File

@ -72,9 +72,9 @@ describe("something waiting on return", () => {
const ready = within(dialog).getByRole("list", { name: "Ready goals" }); const ready = within(dialog).getByRole("list", { name: "Ready goals" });
expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title); expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title);
await userEvent.click(within(dialog).getByRole("button", { name: "Claim on HQ" })); await userEvent.click(within(dialog).getByRole("button", { name: "Claim on the Bridge" }));
expect(screen.queryByRole("dialog", { name: "While you were away" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "While you were away" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
const goals = screen.getByRole("region", { name: "Goals" }); const goals = screen.getByRole("region", { name: "Goals" });
expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument(); expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument();
await waitFor(() => await waitFor(() =>

View File

@ -155,7 +155,7 @@ describe("while you were away", () => {
it("does not show the dialog when there is nothing to report", async () => { it("does not show the dialog when there is nothing to report", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() })); renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
}); });
}); });

View File

@ -39,7 +39,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
resolvedAt: null, resolvedAt: null,
}); });
describe("HQ screen", () => { describe("Dashboard (CR-020)", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
@ -47,23 +47,26 @@ describe("HQ screen", () => {
}); });
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it("shows idle and in-flight counts and counts down to the next arrival", async () => { it("shows idle and in-flight counts and every ship on a job, soonest first", async () => {
const state = samplePlayerState({ const state = samplePlayerState({
tutorial: null, tutorial: null,
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")], ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")],
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)], activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
}); });
renderApp(fakeApi({ getPlayerState: () => state })); renderApp(fakeApi({ getPlayerState: () => state }));
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling; const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1"); expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2"); expect(statValue("Ships in flight")).toHaveTextContent("2");
const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!; const jobs = screen.getByRole("region", { name: "Ships on jobs" });
expect(within(next).getByText("1m 05s")).toBeInTheDocument(); const rows = within(jobs).getAllByRole("listitem");
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
expect(rows[1]).toHaveTextContent("2m 05s");
act(() => vi.advanceTimersByTime(10_000)); act(() => vi.advanceTimersByTime(10_000));
expect(within(next).getByText("55s")).toBeInTheDocument(); expect(rows[0]).toHaveTextContent("55s");
}); });
it("shows tutorial run progress toward the deal during the tutorial", async () => { it("shows tutorial run progress toward the deal during the tutorial", async () => {
@ -132,7 +135,7 @@ describe("loan on HQ", () => {
it("is hidden once the loan is paid off", async () => { it("is hidden once the loan is paid off", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) })); renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument(); expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
}); });
@ -171,3 +174,126 @@ describe("loan on HQ", () => {
expect(dialog).toHaveTextContent("230 cr went to the lender"); expect(dialog).toHaveTextContent("230 cr went to the lender");
}); });
}); });
describe("your ship on the Dashboard (CR-020)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
const avatar = samplePlayerState().pilots[0]!;
const mine = (overrides: Partial<Ship> = {}): Ship => ({
...ship("Iron Mule", "idle"),
pilotId: "avatar",
...overrides,
});
const owner = (overrides: Partial<PlayerState>) =>
samplePlayerState({
tutorial: null,
pilots: [{ ...avatar, assignedShipId: "Iron Mule", dutyHours: 3, dutyLimit: 12 }],
...overrides,
});
const panel = async () => screen.findByRole("region", { name: "Your ship" });
it("shows an idle ship's place, fuel, the pilot's duty and where to find work", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
const p = await panel();
expect(p).toHaveTextContent("Iron Mule");
expect(p).toHaveTextContent("Idle");
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
expect(within(p).getByRole("progressbar", { name: "Iron Mule fuel" })).toBeInTheDocument();
expect(within(p).getByRole("progressbar", { name: "Your duty" })).toHaveAttribute(
"aria-valuenow",
"3",
);
expect(within(p).getByRole("link", { name: "Find work here" })).toHaveAttribute(
"href",
"/contracts?location=earth",
);
expect(within(p).queryByLabelText("Your ship low fuel")).not.toBeInTheDocument();
});
it("warns when your ship is low on fuel", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine({ fuelFu: 1 })] }) }));
expect(await screen.findByLabelText("Your ship low fuel")).toHaveTextContent("Low fuel");
});
it("shows your ship's trip, cargo and arrival in flight", async () => {
const flight: Dispatch = {
...dispatch("Iron Mule", NOW + 90_000),
contract: {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "high-value",
goodId: "medical-supplies",
shipper: "Meridian Health",
priority: false,
deliveryWindowGameHours: null,
tier: 1,
massTonnes: 6,
payout: 900,
requiredSkill: { skillId: "expensive-cargo", level: 1 },
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 600_000,
firstContact: false,
},
};
const flying = mine({ status: "in-flight", locationId: null, dispatchId: flight.id });
renderApp(
fakeApi({ getPlayerState: () => owner({ ships: [flying], activeDispatches: [flight] }) }),
);
const p = await panel();
expect(p).toHaveTextContent("In flight");
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("Earth → Moon");
expect(within(p).getByLabelText("Your cargo")).toHaveTextContent(
"6 t Medical Supplies for Meridian Health",
);
expect(p).toHaveTextContent("Arrives in 1m 30s");
});
it("shows a ship in the shop and a resting pilot", async () => {
const shopped = mine({ inShopUntil: NOW + 120_000 });
renderApp(
fakeApi({
getPlayerState: () =>
owner({
ships: [shopped],
pilots: [
{ ...avatar, assignedShipId: "Iron Mule", resting: true, restEndsAt: NOW + 300_000 },
],
}),
}),
);
const p = await panel();
expect(p).toHaveTextContent("In the shop — ready in 2m 00s");
expect(within(p).getByLabelText("Your pilot")).toHaveTextContent("Resting · wakes in 5m 00s");
});
it("says so when you aren't flying a ship", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
const p = await panel();
expect(p).toHaveTextContent("You're not flying a ship right now.");
expect(within(p).getByRole("link", { name: "Assign yourself on Pilots" })).toHaveAttribute(
"href",
"/pilots",
);
});
it("shows the company loaner during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
const p = await panel();
expect(p).toHaveTextContent("Company loaner");
expect(p).toHaveTextContent("Supplied by Halvorsen");
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
});
it("has an Alerts panel, empty for now", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
const alerts = await screen.findByRole("region", { name: "Alerts" });
expect(alerts).toHaveTextContent("No alerts.");
});
});

View File

@ -144,7 +144,7 @@ describe("the deal", () => {
); );
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" })); await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {})); await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect( expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }), screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();

View File

@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { tutorialHint } from "../src/screens/HQ"; import { tutorialHint } from "../src/screens/Dashboard";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");

View File

@ -66,7 +66,7 @@ describe("goals on HQ", () => {
it("is not shown during the tutorial", async () => { it("is not shown during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() })); renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument(); expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
}); });
}); });

View File

@ -0,0 +1,112 @@
import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fuelPriceOf, formatFuelPrice } from "../src/components/Fuel";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const ship = (id: string, name: string, overrides: Partial<Ship> = {}): Ship => ({
id,
modelId: "courier",
name,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
...overrides,
});
const avatar = samplePlayerState().pilots[0]!;
const spare: Pilot = { ...avatar, id: "p2", name: "Rae Okafor", isAvatar: false, level: 2 };
const inbound: Dispatch = {
id: "d1",
shipId: "s-fly",
pilotId: "avatar",
contract: null,
originId: "moon",
destinationId: "earth",
tutorial: false,
departureAt: Date.now() - 60_000,
arrivalAt: Date.now() + 180_000,
distanceMillionKm: 0.39,
fuelCost: 50,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
};
function render(bays: { id: string; locationId: string }[]) {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
bays,
ships: [
ship("s1", "Dusty Mule"),
ship("s2", "Moon Moth", { locationId: "moon" }),
ship("s-fly", "Swift Heron", {
status: "in-flight",
locationId: null,
dispatchId: "d1",
}),
],
pilots: [
{ ...avatar, assignedShipId: "s-fly", status: "in-flight", locationId: null },
spare,
],
activeDispatches: [inbound],
}),
}),
"/hqs",
);
}
describe("HQs screen (CR-020)", () => {
it("shows each HQ's ships, inbound flights, spare pilots and fuel price", async () => {
render([{ id: "b1", locationId: "earth" }]);
const hq = await screen.findByRole("listitem", { name: "HQ at Earth" });
expect(hq).toHaveTextContent(`Fuel: ${formatFuelPrice(fuelPriceOf("earth"))}`);
const ships = within(hq).getByRole("region", { name: "Ships at Earth" });
expect(ships).toHaveTextContent("Dusty Mule");
expect(ships).not.toHaveTextContent("Moon Moth");
expect(within(hq).getByRole("region", { name: "Inbound to Earth" })).toHaveTextContent(
"Swift Heron from Moon",
);
expect(within(hq).getByRole("region", { name: "Pilots at Earth" })).toHaveTextContent(
"Rae OkaforLevel 2",
);
expect(within(hq).getByRole("link", { name: "View contracts" })).toHaveAttribute(
"href",
"/contracts?location=earth",
);
expect(within(hq).getByRole("link", { name: "Hire pilots" })).toHaveAttribute(
"href",
"/pilots",
);
});
it("lists one card per HQ", async () => {
render([
{ id: "b1", locationId: "earth" },
{ id: "b2", locationId: "moon" },
]);
expect(await screen.findByRole("listitem", { name: "HQ at Moon" })).toHaveTextContent(
"Moon Moth",
);
expect(screen.getAllByRole("listitem", { name: /^HQ at / })).toHaveLength(2);
});
it("points to Places when the player has no HQ", async () => {
render([]);
expect(await screen.findByText("You don't have an HQ yet.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Lease one in Places" })).toHaveAttribute(
"href",
"/locations",
);
});
});

View File

@ -38,4 +38,15 @@ describe("app layout", () => {
"page", "page",
); );
}); });
it("opens on the Bridge tab, the Dashboard, with HQs among eight tabs (CR-020)", async () => {
renderApp(signedIn());
const nav = await screen.findByRole("navigation", { name: "Main" });
const links = within(nav).getAllByRole("link");
expect(links).toHaveLength(8);
expect(links[0]).toHaveAccessibleName("Bridge");
expect(links[0]).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(within(nav).getByRole("link", { name: "HQs" })).toHaveAttribute("href", "/hqs");
});
}); });

View File

@ -44,7 +44,7 @@ describe("guest onboarding", () => {
it("skips the start screen when a stored session is still valid", async () => { it("skips the start screen when a stored session is still valid", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() }); const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api); renderApp(api);
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything()); expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
}); });

View File

@ -46,7 +46,7 @@ describe("player state store", () => {
getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }), getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
}); });
renderApp(api); renderApp(api);
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(stateCalls(api)).toBe(1); expect(stateCalls(api)).toBe(1);
act(() => vi.advanceTimersByTime(29_000)); act(() => vi.advanceTimersByTime(29_000));
@ -67,7 +67,7 @@ describe("player state store on focus", () => {
it("refetches when the app regains focus", async () => { it("refetches when the app regains focus", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() }); const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api); renderApp(api);
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
const before = stateCalls(api); const before = stateCalls(api);
// Leaving and returning to the app, as the browser reports it to TanStack Query. // Leaving and returning to the app, as the browser reports it to TanStack Query.

View File

@ -70,7 +70,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Shake on it" })); await userEvent.click(screen.getByRole("button", { name: "Shake on it" }));
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
// The deal is done, but the permission prompt is still open. // The deal is done, but the permission prompt is still open.
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
@ -100,7 +100,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
await waitFor(() => await waitFor(() =>
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(), expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(),
); );
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
act(() => usePreferences.getState().showTipsAgain()); act(() => usePreferences.getState().showTipsAgain());
@ -109,7 +109,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
it("doesn't appear with no unspent points", async () => { it("doesn't appear with no unspent points", async () => {
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/"); renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/");
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
// Give the prompt check a turn to settle. // Give the prompt check a turn to settle.
await act(async () => {}); await act(async () => {});
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

View File

@ -66,6 +66,7 @@ Record these in `DECISIONS.md` (as plan §0.5.47–0.5.51).
### Step 10E.4 — HQ dashboard ### Step 10E.4 — HQ dashboard
- Company hero (name, credits large), icon stat tiles, a "Next arrival" panel with a route strip and live progress, pilot rest, the tutorial card as a story panel, goals with reward pills, the loan with a meter. - Company hero (name, credits large), icon stat tiles, a "Next arrival" panel with a route strip and live progress, pilot rest, the tutorial card as a story panel, goals with reward pills, the loan with a meter.
- **Test:** existing HQ, goals, loan and rest tests pass. **Manual check.** - **Test:** existing HQ, goals, loan and rest tests pass. **Manual check.**
- **Amended by CR-020:** The HQ dashboard became the Dashboard ("Bridge"), led by the player pilot's ship and an Alerts panel; "Next arrival" became "Ships on jobs" (every flight); owned HQs moved to their own tab (Phase 20).
### Step 10E.5 — Contracts and dispatch ### Step 10E.5 — Contracts and dispatch
- Location switcher as planet chips. Contract cards: a route strip (origin art → line → destination art), cargo and mass, a large payout, requirement pills, expiry, and a prominent Dispatch button. The tutorial board gets the same card, with suggested and scripted variants. The dispatch sheet uses the `Sheet` and shows each ship with its art and the numbers as stat tiles. - Location switcher as planet chips. Contract cards: a route strip (origin art → line → destination art), cargo and mass, a large payout, requirement pills, expiry, and a prominent Dispatch button. The tutorial board gets the same card, with suggested and scripted variants. The dispatch sheet uses the `Sheet` and shows each ship with its art and the numbers as stat tiles.

View File

@ -0,0 +1,48 @@
# Change Request CR-020 — Bridge Dashboard and HQs Tab
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with CR-001 to CR-019 complete
**Do this:** as **Phase 20**, after Phase 19
**Related docs:** `CR-005-ui-overhaul.md` (HQ dashboard, Step 10E.4), `CR-015-fixed-map-fuel-tanks.md`, `CR-018-fuel-tuning.md`, `DECISIONS.md`
---
## 1. Why
User request (2026-10-10):
> I'd like to overhaul the HQ page to be Dashboard instead, we can move HQs to a new tab that just has info about the owned HQs. The Dashboard should show similar info like we have already with the ships on jobs and goals, but it should also be a dashboard for the player pilot to show information about his ship like status, fuel, and it will show alerts in the future when we add in-flight alerts/news.
Asked and answered: the tab is labelled **Bridge** (the screen title is "Dashboard"), and the alerts panel starts as an **empty placeholder**.
## 2. Decisions
Record as plan §0.5.118–0.5.121.
- **0.5.118 Bridge.** The first tab (`/`) is the Dashboard, labelled "Bridge" in the navigation. It replaces the HQ screen: company stats, every ship on a job, pilot rest, ships in the shop, goals and the loan stay; the tutorial panel stays during the tutorial.
- **0.5.119 Your ship.** The Dashboard leads with the player pilot's ship: the ship the avatar is assigned to, or the company loaner during the tutorial. It shows status (idle where, in flight with route, progress, arrival and cargo, or in the shop), fuel with the low-fuel warning (decision 0.5.113), and the avatar's duty or rest. With no ship assigned it says so and links to Pilots.
- **0.5.120 Alerts.** The Dashboard has an Alerts panel that is empty for now ("No alerts"), the place future in-flight alerts and news will appear.
- **0.5.121 HQs tab.** A new "HQs" tab lists the player's HQs (bays) with information only: location, fuel price there, ships there, unassigned pilots there, and what an HQ allows. Leasing stays on Places; the empty state links there. Eight tabs in all.
## 3. Rules
- All rules in `implementation-plan.md` §0.1 apply. Update `progress.md` after every step.
- Copy that names the old screen changes with it: the away summary's "Claim on HQ" becomes "Claim on the Bridge". Tests that looked for the "HQ" heading look for "Dashboard".
- Visual steps end with desktop and phone screenshots. Stop for review at the end of Phase 20.
## 4. Steps — Phase 20
### Step 20.1 — Navigation: Bridge and HQs
- `SCREENS`: `/` becomes Dashboard (label "Bridge", a new bridge icon); add `/hqs` (title "HQs", label "HQs", the HQ icon) after Pilots. The navigation fits eight tabs on phones and on the desktop rail. The HQ attention badge (goals ready) moves with `/`.
- **Test:** `layout.test.tsx` (eight links; each screen's heading at its path; Bridge is the first tab).
### Step 20.2 — HQs screen
- One card per bay: location art and name, fuel price there (cheapest highlighted), ships at the location (name, model, idle or in the shop), unassigned pilots there, what an HQ allows (hire pilots, take delivery of new ships), and links to the location's contracts and to Pilots for hiring. No bays: a short explanation and a link to Places.
- **Test:** `hqs-screen.test.tsx` (a bay's card lists its ships and pilots and fuel price; ships elsewhere aren't listed; the empty state links to Places).
### Step 20.3 — Dashboard
- "Your ship" panel per decision 0.5.119 (helper `pilotShip(state)`), Alerts placeholder per 0.5.120, company stats, "Ships on jobs" (every active dispatch with route strip, progress and arrival, soonest first), and the kept panels.
- **Test:** `dashboard.test.tsx` (your ship idle with fuel and low-fuel warning; in flight with cargo and countdown; in the shop; no ship assigned; tutorial loaner; alerts placeholder; every ship on a job listed); existing HQ tests renamed and updated.
### Step 20.4 — Regression and review
- Plan amendments (Steps 8.1 and 10E.4), full suite, coverage, lint, build, screenshots of Dashboard and HQs at desktop and phone width. Stop for review.

View File

@ -0,0 +1,179 @@
# Change Request CR-020 — Independent Pilots
> **Numbering note (added when recorded):** this request arrived titled "CR-019 — Independent Pilots" for "Phase 19". CR-019 (completable boards) and Phase 19 already existed, so it is stored as **CR-020** and done as **Phase 20**: every "19.n" below means **20.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-018" = Phase 13 and **CR-001 to CR-019**; "CR-001" (goals, Step 10A.5) and "CR-004" (pilot rest) are the same numbers; "CR-005 cargo goods" = **CR-007**; "CR-007 veteran pilot" (profit-share pay) = **CR-010**; "CR-011 local-first" = **CR-014**, and its "10K.6" resolution loop is **Step 14.6**; "CR-014 fixed map and fuel tanks" = **CR-015**; "CR-016" (the Pilots tab badge) = **CR-017**; "CR-017 fuel tuning" = **CR-018**; "CR-018 completable jobs" = **CR-019**. Its new decisions CR-019.D1–D9 are recorded as **0.5.118 Owner-operator, 0.5.119 Dispatch Office, 0.5.120 Modes and orders, 0.5.121 Job choice, 0.5.122 Player priority, 0.5.123 Fuel for pilots, 0.5.124 Idle behavior, 0.5.125 Deterministic catch-up, 0.5.126 No independent-trip notifications**.
>
> **On recording:** "HQ" means a player bay (plan 0.5 bays); the HQ screen is the app's HQ tab. "Game minutes" convert to real time by `timeCompression` (60 in this build), so the default 15-game-minute turnaround is 15 real seconds.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-018 complete
**Do this:** as **Phase 19**
**Related docs:** `change-request-pilot-rest.md` (CR-004), `change-request-cargo-goods.md` (CR-005), `change-request-veteran-pilot.md` (CR-007), `change-request-local-first.md` (CR-011), `change-request-fixed-map-and-fuel-tanks.md` (CR-014), `change-request-fuel-tuning.md` (CR-017), `change-request-completable-jobs.md` (CR-018), `game-design-document.md`, `DECISIONS.md`
This change request is written for the local-first engine (CR-011). It turns the "NPC pilots" ideas from the design theorizing sessions into a first, buildable version.
---
## 1. Why
Today every job is dispatched by hand, so the fleet stops when the player puts the phone down. This change lets **hired pilots run jobs on their own**, so the company keeps earning while the player is away: closer to an idle game, without losing the hands-on dispatching.
Settled in the design sessions:
- **The player stays an owner-operator.** The avatar is never autonomous. The player still flies, buys and upgrades ships, and spends every pilot's skill points.
- **Hired pilots only fly jobs.** They don't buy ships, upgrade ships, or spend skill points. They still **earn XP** from the work they do; the player decides how to spend it.
- **Rest is the soft cap.** Independent pilots follow the existing duty and rest rules (CR-004), so a fleet left overnight works a shift, rests, and works again. No arbitrary offline timer.
- **The player gets first look.** New jobs on a board are the player's alone for a few minutes before pilots can take them, and first-contact (discovery) jobs are the player's only.
- **Everything is deterministic.** Pilots' decisions are replayed on the device when the app opens, from the same state and boards, so results are the same however the time is split up.
## 2. How it works
### Unlocking: the Dispatch Office
- A one-time company purchase from the HQ screen: **Dispatch Office** (price in content; start around the price of a Lighter). Until it's bought, pilots can't be set to Independent.
- It's also a money sink and a mid-game goal.
### Pilot modes
| Mode | Behavior |
|---|---|
| **Manual** (default) | Current behavior. The pilot only flies when the player dispatches them. |
| **Independent** | Whenever the pilot is idle, rested, and crewing an idle ship that isn't in the shop, they choose and fly a job from the board where they are, following their orders. |
- The avatar can't be set to Independent.
- The player can still dispatch an Independent pilot by hand when they're idle; the player's action always wins.
- Switching to Manual takes effect when the pilot's current trip ends.
### Orders (set per pilot by the player)
| Order | Default |
|---|---|
| Allowed destinations | All discovered locations |
| Allowed cargo classes | Standard, Heavy, High-Value, Dangerous (those the pilot is skilled for anyway) |
| Take Priority jobs | Yes |
| Minimum profit per job | 0 cr |
| Home HQ | The HQ where they were hired, or Earth |
### Choosing a job
When an Independent pilot can act, they look at the board at their location (CR-018: it only lists jobs the fleet can do) and consider jobs that:
- this pilot and ship can complete right now (all dispatch validations, including fuel after an allowed refuel, below);
- pass their orders;
- are **not first-contact jobs** (discovery is the player's);
- are **past the first-look window**: at least the configured number of real minutes (default 3) into the board period;
- aren't being viewed by the player at that moment (live play only, see §2 "While the app is open").
They pick the job with the highest **profit per hour**:
`profit per hour = (payout − fuel cost − pilot share) ÷ trip time in game hours`
Fuel cost uses the tank's average price for fuel already aboard and the location's price for fuel that must be bought. Ties go to the shorter trip, then the lower contract id.
### Fuel
- Pilots follow their ship's refuel policy (CR-017; Manual by default).
- If the best job needs more fuel than is aboard, the pilot buys **only what that leg needs**, as long as company credits stay above the **NPC spending floor** (a company setting, default 500 cr). Otherwise they consider only jobs they can fly on the fuel aboard.
- Pilots never use emergency fuel; that stays a player decision.
### When there's nothing to take
- The pilot **waits** at the location for the next board period (waiting doesn't add duty hours).
- After 3 empty periods in a row away from their home HQ, they **deadhead home** if the ship can reach it with the fuel rules above; otherwise they keep waiting.
### Rest and turnaround
- Duty and rest are unchanged (CR-004). A pilot over the duty limit rests on arrival, then resumes.
- Each new job starts after a **turnaround time** (default 15 game minutes) from the moment the pilot becomes able to act. This is the main balance knob for idle income.
### Pay and XP
- Profit-share pay (CR-007) and loan repayment (CR-001) apply as for any delivery.
- XP is earned as usual; unspent points show on the Pilots tab badge (CR-016).
### Catch-up and while the app is open
- **Catch-up:** when the app opens or resumes, the engine replays everything since the last saved time as an event queue: arrivals, rest endings, shop completions, board period starts, and pilot decision points, processed in time order (ties broken by pilot id). Board generation during catch-up uses the same deterministic rules as live play.
- **Splitting time doesn't change results:** catching up 8 hours at once gives exactly the same state as catching up in 1-hour pieces.
- **Limits:** catch-up covers at most 7 days of absence and runs in chunks so the UI stays responsive, with a "Catching up on your fleet…" indicator when it takes more than a moment.
- **While the app is open:** the resolution loop (CR-011) also runs pilot decisions. The client tells the engine which contract the player has open, and pilots skip it until it's closed.
- **Notifications:** independent trips aren't decided while the app is closed, so they can't be scheduled as notifications ahead of time. No per-trip notifications for independent pilots.
### The logbook
The "while you were away" screen gains a **per-pilot logbook**: jobs flown (good, route, payout, pilot share, fuel), deadheads, rests, XP gained, and where the pilot is now, with company totals at the top.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- **Pilots use the same commands as the player.** An independent dispatch goes through the normal dispatch validation and command path; nothing bypasses rules.
- **Job choice is a pure function** in `packages/sim` (state and board in, chosen action out), so it can be tested in isolation.
- Save migration: every pilot starts in Manual with default orders; the company has no Dispatch Office; the NPC spending floor is set to its default.
- Update `architecture.md` and `progress.md` as you go. Stop for the user's review at the end of Phase 19.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-019.D1–D9 here; note the mapping).
- **CR-019.D1 Owner-operator.** The avatar is never autonomous. Hired pilots never buy, upgrade, or spend points; they do earn XP.
- **CR-019.D2 Dispatch Office.** One-time company purchase required before any pilot can be Independent.
- **CR-019.D3 Modes and orders.** Manual (default) or Independent, with per-pilot orders per §2.
- **CR-019.D4 Job choice.** Highest profit per hour among eligible jobs, per §2.
- **CR-019.D5 Player priority.** First-look window (default 3 real minutes into each board period); first-contact jobs are player-only; jobs the player is viewing are skipped.
- **CR-019.D6 Fuel for pilots.** Buy only what the chosen leg needs, never below the NPC spending floor; never emergency fuel.
- **CR-019.D7 Idle behavior.** Wait for the next board; after 3 empty periods away from home HQ, deadhead home if possible.
- **CR-019.D8 Deterministic catch-up.** Event-queue replay on open/resume, at most 7 days, results independent of how time is split.
- **CR-019.D9 No independent-trip notifications.**
Removes "automation / standing routes and auto-accept rules" from the deferred list (`implementation-plan.md` §0.3); standing orders remain a possible later addition.
## 5. Steps — Phase 19
### Step 19.1 — Content and config
- Add: Dispatch Office price, first-look window (real minutes), turnaround time (game minutes), empty periods before heading home (3), maximum catch-up window (7 days), default NPC spending floor (500 cr).
- **Test:** Config validates; all values positive; the first-look window is shorter than the board period.
### Step 19.2 — Sim: eligibility and job choice
- Pure functions: which jobs on a board an Independent pilot may consider (§2 filters); profit per hour for a job given the ship's fuel and the location's price; the pilot's chosen action (take a job, refuel-then-take, wait, or deadhead home).
- **Test:** First-contact jobs are never chosen. Jobs inside the first-look window aren't chosen; the same job is after the window. Jobs outside the orders (destination, class, Priority, minimum profit) aren't chosen. The highest profit-per-hour job is chosen, with the documented tie-breaks. A pilot who can only afford fuel above the spending floor picks among jobs they can fly on fuel aboard. With nothing eligible, the result is wait; after 3 empty periods away from home, deadhead home when reachable.
### Step 19.3 — Engine: Dispatch Office, modes, orders
- Commands: buy Dispatch Office, set pilot mode, set pilot orders, set NPC spending floor. Save fields and migration per §3.
- **Test:** Setting Independent fails before the Dispatch Office is bought and succeeds after. The avatar can't be set to Independent. Orders save and load. An older save migrates with all pilots on Manual and no Dispatch Office.
### Step 19.4 — Engine: event-queue catch-up
- Extend the resolution loop (CR-011 Step 10K.6) into a time-ordered event queue covering arrivals, rest endings, shop completions, board period starts, and Independent pilots' decision points (each decision point is turnaround time after the pilot becomes able to act). Each pilot action is executed through the normal engine commands.
- Cap catch-up at 7 days; process in chunks with a progress indicator hook.
- **Test:** Determinism: from the same save, catching up 8 hours in one go and in eight 1-hour pieces gives identical saves. Two Independent pilots at the same location never take the same contract; the one whose decision comes first (then lower pilot id) gets it. A pilot over the duty limit rests on arrival and resumes after rest. Catch-up past 7 days stops at 7 days. No pilot is ever dispatched in a way that fails normal validation.
### Step 19.5 — Engine: live play
- While the app is open, the loop runs pilot decisions in real time. Add a client-to-engine signal for the contract currently open in the detail view; pilots skip it.
- **Test:** With a contract marked as being viewed, an eligible pilot picks a different job; once the view closes, the contract becomes eligible again.
### Step 19.6 — Client: Dispatch Office and pilot controls
- HQ screen: Dispatch Office purchase with a short explanation ("Lets your hired pilots run jobs on their own").
- Pilots screen: per-pilot Manual/Independent toggle (disabled with a reason before the Dispatch Office, hidden for the avatar), an orders editor (destinations, cargo classes, Priority, minimum profit, home HQ), and a status line ("Independent: flying Medical Supplies to the Moon", "Waiting for new contracts at Mars", "Resting until 14:20").
- Settings → Company: NPC spending floor.
- Fleet cards show an "Independent" badge on ships crewed by Independent pilots.
- **Test:** Component tests: the toggle is disabled with a reason before purchase and hidden for the avatar; orders edits call the engine; each status line renders from seeded state; the badge appears only for Independent crews.
### Step 19.7 — Client: board and logbook
- Contract cards inside the first-look window show a small chip: "Yours for 2:31".
- Catch-up shows "Catching up on your fleet…" when it takes more than half a second.
- The "while you were away" screen gains the per-pilot logbook from §2, with company totals.
- **Test:** Component tests: the chip counts down and disappears when the window ends; the indicator appears for slow catch-ups only; the logbook lists each pilot's jobs, deadheads, rests, XP, and current location, and totals match the sum of entries.
### Step 19.8 — Balance
- Extend the Phase 12 balance script with an "away" scenario: the player sets all hired pilots to Independent and closes the app for 8 real hours.
- Target: the independent fleet earns **40–80%** of what the Phase 12 active-play policy earns with the same fleet over the same time. Tune turnaround time first, then the first-look window.
- **Test:** The away scenario meets the target with default content. No simulated pilot ever buys fuel below the spending floor or gets stranded.
### Step 19.9 — Plan amendments and regression
- Apply the amendments in §6.
- Run the full suite.
- **Test:** CI is green. Manual check: buy the Dispatch Office, set Rosa to Independent with default orders, close the app for an hour, reopen, and read her logbook; then confirm a first-contact job stayed on the board for you.
## 6. Amendments
| Where | Change |
|---|---|
| Plan 0.3 | Remove automation/standing routes from deferred; standing orders, pilot traits, decision events, and a hiring market remain deferred. |
| Plan 0.5 | Add CR-019.D1–D9. |
| CR-001 goals | Optional later: a goal for buying the Dispatch Office (not part of this change). |
| CR-011 10K.6 | Resolution loop becomes the event-queue catch-up (19.4). |
| CR-018 | Pilots choose only from completable boards; no change to board rules. |
| Plan 12.1 | Away scenario added (19.8). |
| `game-design-document.md` | Pilots and automation sections updated by the designer. |
## 7. Out of scope
Standing orders (repeat routes), personality traits, decision events that wait for the player, a hiring market with varied candidates, pilots looking ahead to the destination's backhaul, pilots using emergency fuel, a premium contract tier, ship wear, and anything else in `implementation-plan.md` §0.3.

View File

@ -36,7 +36,7 @@ This plan covers the **base game only**: the core loop of taking contracts, disp
### 0.3 Explicitly deferred (do NOT build) ### 0.3 Explicitly deferred (do NOT build)
Random events, reputation, fuel price drift and depot shortages *(CR-015: fixed per-location prices are in, decision 0.5.96)*, fuel storage at HQ bays, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, automation/standing routes, interstellar, push notifications, Capacitor builds, Hardened landing class, surface Io and Venus's surface *(CR-016 added Venus Cloud City and Io Sulfur Works, which need no Hardened ships)*, asteroid-belt hazards, mining claims and belt events *(CR-016's belt is visual only)*, Fragile/Urgent/Fuel-efficiency skills, social features, monetization, account linking (email, Apple, Google sign-in) *(CR-011)*. Random events, reputation, fuel price drift and depot shortages *(CR-015: fixed per-location prices are in, decision 0.5.96)*, fuel storage at HQ bays, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, ~~automation/standing routes~~ *(CR-020: Independent pilots are in; standing orders, pilot traits, decision events and a hiring market stay deferred)*, interstellar, push notifications, Capacitor builds, Hardened landing class, surface Io and Venus's surface *(CR-016 added Venus Cloud City and Io Sulfur Works, which need no Hardened ships)*, asteroid-belt hazards, mining claims and belt events *(CR-016's belt is visual only)*, Fragile/Urgent/Fuel-efficiency skills, social features, monetization, account linking (email, Apple, Google sign-in) *(CR-011)*.
### 0.4 Glossary ### 0.4 Glossary
@ -173,6 +173,19 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
115. **Qualifying fleet** (CR-019). Owned ships at the location, or in flight there and arriving before the board period ends, each with its assigned pilot, or with any unassigned pilot at the location when the player has a bay there; the whole fleet when no pair qualifies. 115. **Qualifying fleet** (CR-019). Owned ships at the location, or in flight there and arriving before the board period ends, each with its assigned pilot, or with any unassigned pilot at the location when the player has a bay there; the whole fleet when no pair qualifies.
116. **Permanent checks only** (CR-019). Completable means passing cargo fit (effective capacity), landing, range (full-tank reach, or the fuel on board where the origin sells none), the no-strand rule, the Priority window and the cargo and Long Haul skills. Rest, the shop, fuel on board and credits don't count. 116. **Permanent checks only** (CR-019). Completable means passing cargo fit (effective capacity), landing, range (full-tank reach, or the fuel on board where the origin sells none), the no-strand rule, the Priority window and the cargo and Long Haul skills. Rest, the shop, fuel on board and credits don't count.
117. **Mid-period changes** (CR-019). A stored board keeps the fleet snapshot it was generated with; reads hide jobs the current qualifying fleet can no longer complete; new capability shows at the next board period. Replaces 0.5.53's weight warning. 117. **Mid-period changes** (CR-019). A stored board keeps the fleet snapshot it was generated with; reads hide jobs the current qualifying fleet can no longer complete; new capability shows at the next board period. Replaces 0.5.53's weight warning.
118. **Owner-operator** (CR-020). The avatar is never autonomous. Hired pilots never buy, upgrade or spend skill points; they do earn XP.
119. **Dispatch Office** (CR-020). A one-time company purchase, required before any pilot can be Independent.
120. **Modes and orders** (CR-020). A hired pilot is Manual (default) or Independent; Independent pilots follow per-pilot orders: allowed destinations, allowed cargo classes, Priority jobs or not, minimum profit per job, and a home HQ (the bay where they were hired, or Earth).
121. **Job choice** (CR-020). An Independent pilot takes the eligible job on the board where they are with the highest profit per game hour, (payout − fuel cost − pilot share) ÷ trip time; ties go to the shorter trip, then the lower contract id.
122. **Player priority** (CR-020). Pilots don't take jobs in the first-look window (default 3 real minutes into each board period), first-contact jobs, or the job the player has open.
123. **Fuel for pilots** (CR-020). A pilot buys only the fuel the chosen leg needs, and only while company credits stay above the NPC spending floor (default 500 cr); never emergency fuel.
124. **Idle behavior** (CR-020). With nothing to take, a pilot waits for the next board period; after 3 empty periods in a row away from their home HQ they deadhead home when they can.
125. **Deterministic catch-up** (CR-020). On open or resume the engine replays events since the last saved time in time order (ties by pilot id), at most 7 days; the result doesn't depend on how the time is split.
126. **No independent-trip notifications** (CR-020). Independent trips are decided on the device as time is replayed, so none are scheduled as notifications.
118. **Bridge** (CR-020). The first tab (`/`) is the Dashboard, labelled "Bridge"; it replaces the HQ screen and keeps its company stats, ships on jobs, rest, shop, goals, loan and tutorial panels.
119. **Your ship** (CR-020). The Dashboard leads with the avatar's assigned ship (the loaner during the tutorial): status, trip and cargo, fuel with the low-fuel warning, and the avatar's duty or rest.
120. **Alerts** (CR-020). An Alerts panel, empty for now, where in-flight alerts and news will appear.
121. **HQs tab** (CR-020). A new tab lists the player's HQs with information only (location, fuel price, ships and unassigned pilots there, what an HQ allows); leasing stays on Places.
### 0.6 Translating change requests written for the server (CR-014 §7) ### 0.6 Translating change requests written for the server (CR-014 §7)
@ -525,6 +538,7 @@ The game has had no server since CR-014. When a later or unfinished change reque
- **Test:** A component test renders the layout and each route shows its placeholder title. Manual check: the layout fits a 375px-wide viewport with no horizontal scroll. - **Test:** A component test renders the layout and each route shows its placeholder title. Manual check: the layout fits a 375px-wide viewport with no horizontal scroll.
- **Amended by CR-005:** The header becomes a HUD bar and navigation becomes icon tabs: a bottom bar on phones, a left rail from 768 px, with attention badges (decision 0.5.49, Step 10E.3). - **Amended by CR-005:** The header becomes a HUD bar and navigation becomes icon tabs: a bottom bar on phones, a left rail from 768 px, with attention badges (decision 0.5.49, Step 10E.3).
- **Amended by CR-011:** The header gains a Settings gear (Step 10K.2). - **Amended by CR-011:** The header gains a Settings gear (Step 10K.2).
- **Amended by CR-020:** The first screen is the Dashboard (tab "Bridge") instead of HQ, and an HQs tab lists the player's HQs: eight tabs (Steps 20.1–20.3).
### Step 8.2 — Typed API client ### Step 8.2 — Typed API client
- Build an API client from the route catalog so every call is typed and responses are validated. - Build an API client from the route catalog so every call is typed and responses are validated.
@ -846,6 +860,18 @@ Steps 19.1–19.5 are specified in `memory-bank/change-requests/CR-019-completab
--- ---
## Phase 20 — Bridge Dashboard and HQs Tab (CR-020)
Steps 20.1–20.4 are specified in `memory-bank/change-requests/CR-020-bridge-dashboard.md` §4. Stop for review at the end.
---
## Phase 20 — Independent Pilots (CR-020)
Steps 20.1–20.9 are specified in `memory-bank/change-requests/CR-020-independent-pilots.md` §5 (written there as 19.1–19.9), with its §6 amendments. Stop for review at the end.
---
## Definition of Done (Base Game) ## Definition of Done (Base Game)
- All step tests pass in CI. - All step tests pass in CI.

View File

@ -447,3 +447,27 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C
- [x] **Step 19.5 — Plan amendments and regression.** CR-019 §6 applied: plan 0.5.52 and 0.5.53, Steps 3.9, 9.4 and 12.1; CR-002's 0.5.5 (stored boards keep the fleet snapshot); CR-007 Steps 10G.3 and 10G.8; and this repo's CR-006 Steps 10F.1, 10F.3 and 10F.4, which it supersedes. The balance simulator now generates each board for that location's qualifying fleet and hides jobs the fleet can no longer complete, as the engine does. It counts the jobs shown: none in any of the 14 runs was uncompletable. e2e 12.6 is reordered: a Heavy Cargo job now appears only after the pilot learns the skill, so the pilot raises Heavy Cargo first; the test then waits for a Heavy Cargo 1 job at the Moon and checks the Lighter's pair can take it. Before that, it checks no Heavy Cargo job is offered. Full suite: typecheck, lint, 1327 unit tests and 9 e2e tests pass. Balance (7 worlds): deal 3.7 min, first Market purchase 36.6 (30–95), discovery 36.6, loan paid off 123.0, four ships 118.4 vs 122.9, no violations. - [x] **Step 19.5 — Plan amendments and regression.** CR-019 §6 applied: plan 0.5.52 and 0.5.53, Steps 3.9, 9.4 and 12.1; CR-002's 0.5.5 (stored boards keep the fleet snapshot); CR-007 Steps 10G.3 and 10G.8; and this repo's CR-006 Steps 10F.1, 10F.3 and 10F.4, which it supersedes. The balance simulator now generates each board for that location's qualifying fleet and hides jobs the fleet can no longer complete, as the engine does. It counts the jobs shown: none in any of the 14 runs was uncompletable. e2e 12.6 is reordered: a Heavy Cargo job now appears only after the pilot learns the skill, so the pilot raises Heavy Cargo first; the test then waits for a Heavy Cargo 1 job at the Moon and checks the Lighter's pair can take it. Before that, it checks no Heavy Cargo job is offered. Full suite: typecheck, lint, 1327 unit tests and 9 e2e tests pass. Balance (7 worlds): deal 3.7 min, first Market purchase 36.6 (30–95), discovery 36.6, loan paid off 123.0, four ships 118.4 vs 122.9, no violations.
**Phase 19 is complete; stopped for review.** Manual check for the user: with only the deal's Courier and Clipper, browse the Earth board and confirm every job can be dispatched with one of them; boards elsewhere (e.g. Mars, where neither pilot has Long Haul) say no job fits. **Phase 19 is complete; stopped for review.** Manual check for the user: with only the deal's Courier and Clipper, browse the Earth board and confirm every job can be dispatched with one of them; boards elsewhere (e.g. Mars, where neither pilot has Long Haul) say no job fits.
## Phase 20 — Bridge Dashboard and HQs Tab (CR-020)
Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions recorded as plan §0.5.118–0.5.121.
- [x] **Step 20.1 — Navigation: Bridge and HQs.** `SCREENS`: `/` is now "Dashboard" with the tab label "Bridge" and a new `helm` icon; `/hqs` ("HQs", HQ icon) sits after Pilots, with a first `HQs` screen (header only until 20.2). The bottom bar is eight columns; labels tighten their tracking below `sm` so "PILOTS"/"PLACES" fit about 48 px. The goals badge stays on `/`. The HQ screen's heading is "Dashboard"; the away summary's "Claim on HQ" is "Claim on the Bridge" (tests that looked for the "HQ" heading updated). Test: `layout.test.tsx` (eight links, Bridge first and current on launch, HQs at `/hqs`; every screen's heading at its path).
- [x] **Step 20.2 — HQs screen.** `screens/HQs.tsx`: one emerald feature card per bay (labelled "HQ at <place>"): location art and name, fuel price there (cheapest highlighted), ships at the location with Idle / In the shop, flights inbound to it with countdowns, unassigned pilots there (level, resting), and links to the location's contracts and to Pilots for hiring; the header says what an HQ allows (`HQ_ALLOWS`). No bays: a short explanation and "Lease one in Places". Leasing stays on Places. Test: `hqs-screen.test.tsx` (an HQ's ships, inbound flight, spare pilot, fuel price and links; ships elsewhere not listed; one card per HQ; empty state).
- [x] **Step 20.3 — Dashboard.** `screens/HQ.tsx` → `screens/Dashboard.tsx` (`Dashboard`; `tutorialHint` moved with it). Leads with **Your ship** (`pilotShip(state)`: the avatar's assigned ship, the company loaner during the tutorial, or none): art, name and model, Idle / In flight / In the shop, where it is or its route with a route strip, cargo ("6 t Medical Supplies for Meridian Health" or "Flying empty"), arrival countdown and Priority chip, shop countdown, fuel gauge with the low-fuel warning (`lowFuel`, decision 0.5.113; "Supplied by Halvorsen" for the loaner), the avatar's duty meter or rest countdown, and "Find work here" / "Fleet" when idle; with no ship, "Assign yourself on Pilots". Beside it, **Alerts**: "No alerts. In-flight alerts and news will appear here." Then the stat tiles, the tutorial panel, **Ships on jobs** (every active dispatch, soonest first, with route, countdown and a trip meter; replaces "Next arrival"), pilot rest, in the shop, goals and the loan. Test: `dashboard.test.tsx` (was `hq.test.tsx`): ships on jobs in arrival order with live countdowns; your ship idle (place, fuel, duty, Find work here), low fuel, in flight with cargo and arrival, in the shop with a resting pilot, no ship, the tutorial loaner; the Alerts placeholder.
- [x] **Step 20.4 — Regression and review.** CR-020 amendments added to plan Step 8.1 and CR-005 Step 10E.4. The e2e `playToDeal` now waits for the "Dashboard" heading. With eight tabs the desktop rail cut off Places in a 720 px-tall window (first screenshot), so rail tabs are more compact (`md:size-11`, `md:py-1.5`) and the rail scrolls if a window is shorter still. Full suite: typecheck, lint, 1341 unit tests and 9 e2e tests pass. Screenshots (taken after the deal with one ship on a job): `docs/screenshots/20-4-bridge-desktop.png`, `20-4-bridge-phone.png`, `20-4-hqs-desktop.png`, `20-4-hqs-phone.png`.
**Phase 20 is complete; stopped for review.**
## Phase 20 — Independent Pilots (CR-020)
Spec: `memory-bank/change-requests/CR-020-independent-pilots.md` (received as "CR-019 … Phase 19"). New decisions recorded as plan §0.5.118–0.5.126.
- [x] **Step 20.1 — Content and config.** `config.independentPilots`: `dispatchOfficePrice` 36,000 (a Lighter), `firstLookRealMinutes` 3, `turnaroundGameMinutes` 15 (15 real seconds at ×60), `emptyPeriodsBeforeHome` 3, `maxCatchUpRealDays` 7 and `defaultSpendingFloor` 500. All must be positive, and the first-look window must be shorter than the board period. Two stale quarter-tank comments from CR-015 are gone. Tests (`packages/content/test/config.test.ts`): the defaults, with the Dispatch Office at the Lighter's price; zero and negative values rejected; a first-look window as long as the board period rejected.
- [ ] Step 20.2 — Sim: eligibility and job choice
- [ ] Step 20.3 — Engine: Dispatch Office, modes, orders
- [ ] Step 20.4 — Engine: event-queue catch-up
- [ ] Step 20.5 — Engine: live play
- [ ] Step 20.6 — Client: Dispatch Office and pilot controls
- [ ] Step 20.7 — Client: board and logbook
- [ ] Step 20.8 — Balance
- [ ] Step 20.9 — Plan amendments and regression

View File

@ -25,8 +25,7 @@ export const gameConfig: GameConfig = {
loanRepaymentShare: 0.25, loanRepaymentShare: 0.25,
// The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the // The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the
// veteran, and the Earth bay. The loan is computed from their prices. // veteran, and the Earth bay. The loan is computed from their prices.
// The ships come with a quarter tank (CR-015, user decision): the Clipper earns // The deal's ships come with full tanks (CR-018, decision 0.5.111).
// locally before the player can fuel a Mars run, as before fuel tanks.
deal: { deal: {
avatarShipModelId: "courier", avatarShipModelId: "courier",
veteranShipModelId: "clipper", veteranShipModelId: "clipper",
@ -87,4 +86,15 @@ export const gameConfig: GameConfig = {
pilotHireCost: 2000, pilotHireCost: 2000,
// A quarter of each contract's profit (payout − fuel) goes to a hired pilot (CR-010). // A quarter of each contract's profit (payout − fuel) goes to a hired pilot (CR-010).
pilotProfitShare: 0.25, pilotProfitShare: 0.25,
// Independent pilots (CR-020): the Dispatch Office costs about a Lighter; the
// player has the first 3 minutes of every 10-minute board; pilots turn round
// in 15 game minutes (15 real seconds) and head home after 3 empty boards.
independentPilots: {
dispatchOfficePrice: 36_000,
firstLookRealMinutes: 3,
turnaroundGameMinutes: 15,
emptyPeriodsBeforeHome: 3,
maxCatchUpRealDays: 7,
defaultSpendingFloor: 500,
},
}; };

View File

@ -242,7 +242,6 @@ export const gameConfigSchema = z
avatarShipModelId: z.string().min(1), avatarShipModelId: z.string().min(1),
veteranShipModelId: z.string().min(1), veteranShipModelId: z.string().min(1),
bayLocationId: z.string().min(1), bayLocationId: z.string().min(1),
/** How full the deal's ships' tanks are (CR-015, user decision): Halvorsen's leftovers. */
}), }),
contractBoardPeriodRealMinutes: z.number().positive(), contractBoardPeriodRealMinutes: z.number().positive(),
/** Past board periods whose boards and accepted contracts are kept (Step 7.3). */ /** Past board periods whose boards and accepted contracts are kept (Step 7.3). */
@ -330,12 +329,35 @@ export const gameConfigSchema = z
pilotHireCost: nonNegativeInt, pilotHireCost: nonNegativeInt,
/** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */ /** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */
pilotProfitShare: fraction, pilotProfitShare: fraction,
/** Independent pilots (CR-020, decisions 0.5.119–0.5.125). */
independentPilots: z.object({
/** Credits: the one-time Dispatch Office (0.5.119). */
dispatchOfficePrice: positiveInt,
/** Real minutes into each board period that its jobs are the player's alone (0.5.122). */
firstLookRealMinutes: z.number().positive(),
/** Game minutes from a pilot becoming able to act to their next job (CR-020 §2). */
turnaroundGameMinutes: z.number().positive(),
/** Empty board periods in a row away from home before heading home (0.5.124). */
emptyPeriodsBeforeHome: positiveInt,
/** Real days of absence catch-up replays at most (0.5.125). */
maxCatchUpRealDays: z.number().positive(),
/** Credits pilots never spend the company below, by default (0.5.123). */
defaultSpendingFloor: positiveInt,
}),
}) })
.refine( .refine(
(config) => (config) =>
config.contractMassBands.reduce((sum, band) => sum + band.count, 0) === config.contractMassBands.reduce((sum, band) => sum + band.count, 0) ===
config.contractBoardSize, config.contractBoardSize,
{ message: "Mass band counts must add up to contractBoardSize", path: ["contractMassBands"] }, { message: "Mass band counts must add up to contractBoardSize", path: ["contractMassBands"] },
)
.refine(
(config) =>
config.independentPilots.firstLookRealMinutes < config.contractBoardPeriodRealMinutes,
{
message: "The first-look window must be shorter than the board period",
path: ["independentPilots", "firstLookRealMinutes"],
},
); );
export type GameConfig = z.infer<typeof gameConfigSchema>; export type GameConfig = z.infer<typeof gameConfigSchema>;

View File

@ -96,3 +96,37 @@ describe("tutorial settings (Step 17.1, decisions 0.5.106, 0.5.107)", () => {
); );
}); });
}); });
describe("independent pilots config (Step 20.1, CR-020)", () => {
const settings = gameConfig.independentPilots;
const withSettings = (change: Partial<typeof settings>) =>
gameConfigSchema.safeParse({ ...gameConfig, independentPilots: { ...settings, ...change } })
.success;
it("has every setting, all positive, at the CR's defaults", () => {
const lighter = shipModels.find((s) => s.id === "lighter")!;
expect(settings).toEqual({
dispatchOfficePrice: lighter.price,
firstLookRealMinutes: 3,
turnaroundGameMinutes: 15,
emptyPeriodsBeforeHome: 3,
maxCatchUpRealDays: 7,
defaultSpendingFloor: 500,
});
for (const value of Object.values(settings)) expect(value).toBeGreaterThan(0);
});
it("rejects zero or negative values", () => {
for (const key of Object.keys(settings) as (keyof typeof settings)[]) {
expect(withSettings({ [key]: 0 }), key).toBe(false);
expect(withSettings({ [key]: -1 }), key).toBe(false);
}
});
it("keeps the first-look window shorter than the board period", () => {
expect(settings.firstLookRealMinutes).toBeLessThan(gameConfig.contractBoardPeriodRealMinutes);
expect(withSettings({ firstLookRealMinutes: gameConfig.contractBoardPeriodRealMinutes })).toBe(
false,
);
});
});

View File

@ -104,6 +104,14 @@ const validGameConfig = {
skillPointsPerLevel: 1, skillPointsPerLevel: 1,
pilotHireCost: 1000, pilotHireCost: 1000,
pilotProfitShare: 0.25, pilotProfitShare: 0.25,
independentPilots: {
dispatchOfficePrice: 36_000,
firstLookRealMinutes: 3,
turnaroundGameMinutes: 15,
emptyPeriodsBeforeHome: 3,
maxCatchUpRealDays: 7,
defaultSpendingFloor: 500,
},
tutorialLoanerShipModelId: "company-courier", tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 5, tutorialRunsBeforeDeal: 5,
tutorialXpMultiplier: 2, tutorialXpMultiplier: 2,