Step 21.6: client: Dispatch Office and pilot controls
CI / check (push) Successful in 6m44s
Details
CI / check (push) Successful in 6m44s
Details
Buy the Dispatch Office on the Bridge; set hired pilots Independent, see what they're doing and edit their orders on Pilots; the spending floor in Settings; an Independent badge on Fleet (CR-021). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
32603d5d7e
commit
956a621b12
|
|
@ -1713,3 +1713,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **Two sessions recorded a CR-020 the same evening**: the Bridge dashboard and HQs tab (recorded 22:19, built as Phase 20, decisions 0.5.118–0.5.121), and Independent Pilots (recorded 22:59 in another session working in the same checkout). The first one keeps CR-020. Independent Pilots becomes **CR-021 / Phase 21**, decisions **0.5.122–0.5.130**. Its first five steps had already been committed and pushed as "Step 20.1"–"Step 20.5"; they are Steps 21.1–21.5 in the plan, progress and here, and the code and test comments now say CR-021. History isn't rewritten.
|
- **Two sessions recorded a CR-020 the same evening**: the Bridge dashboard and HQs tab (recorded 22:19, built as Phase 20, decisions 0.5.118–0.5.121), and Independent Pilots (recorded 22:59 in another session working in the same checkout). The first one keeps CR-020. Independent Pilots becomes **CR-021 / Phase 21**, decisions **0.5.122–0.5.130**. Its first five steps had already been committed and pushed as "Step 20.1"–"Step 20.5"; they are Steps 21.1–21.5 in the plan, progress and here, and the code and test comments now say CR-021. History isn't rewritten.
|
||||||
- **"The HQ screen" in CR-021 is the Bridge** (the old HQ tab, now the Dashboard). The new HQs tab is information only (decision 0.5.121), so the Dispatch Office is bought on the Bridge.
|
- **"The HQ screen" in CR-021 is the Bridge** (the old HQ tab, now the Dashboard). The new HQs tab is information only (decision 0.5.121), so the Dispatch Office is bought on the Bridge.
|
||||||
|
|
||||||
|
## Step 21.6
|
||||||
|
|
||||||
|
- **The Dispatch Office lives on the Bridge** (the old HQ screen, see "CR-021 renumbered"), in the right-hand column above Goals. It's hidden during the tutorial.
|
||||||
|
- **The mode toggle reads "When you send them / Independent"** rather than "Manual / Independent", so it doesn't sound like the ships' Manual refuel setting.
|
||||||
|
- **Orders are edited as a draft and saved together** with "Save orders": several checkboxes changing one at a time would otherwise each fire a command.
|
||||||
|
- **"Anywhere you know" is `destinationIds: null`**, so places discovered later are included. Unticking it starts from every place known now.
|
||||||
|
- **The Fleet badge sits on the pilot line**: beside the status pill on a phone it truncated the ship's name.
|
||||||
|
- **The spending floor's button is "Save floor"**, distinct from the company name's "Save".
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Pilot, PilotOrders, PlayerState } from "@deadhead/schema";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { formatCredits, locationName } from "../format";
|
||||||
|
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||||
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Button, Icon, Panel, Pill } from "../ui";
|
||||||
|
import { goodName } from "./Goods";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
|
||||||
|
|
||||||
|
/** The one-time Dispatch Office on the Bridge (decision 0.5.123). */
|
||||||
|
export function DispatchOfficePanel() {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const buy = useGameAction("buyDispatchOffice");
|
||||||
|
if (state.tutorial) return null;
|
||||||
|
const price = content.config.independentPilots.dispatchOfficePrice;
|
||||||
|
|
||||||
|
if (state.dispatchOffice.owned) {
|
||||||
|
return (
|
||||||
|
<Panel tone="cyan" className="text-sm" aria-label="Dispatch Office">
|
||||||
|
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-cyan-100">
|
||||||
|
<Icon name="send" className="size-4" />
|
||||||
|
Dispatch Office
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-ink-300">
|
||||||
|
Your hired pilots can run jobs on their own. Set them to Independent on{" "}
|
||||||
|
<Link to="/pilots" className="font-semibold text-amber-300 underline">
|
||||||
|
Pilots
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Panel tone="amber" feature className="text-sm" aria-label="Dispatch Office">
|
||||||
|
<p className="font-display font-semibold tracking-wide text-amber-100">Dispatch Office</p>
|
||||||
|
<p className="mt-1 text-ink-300">
|
||||||
|
Lets your hired pilots run jobs on their own, so the company keeps earning while you're
|
||||||
|
away.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
disabled={state.company.credits < price || buy.isPending}
|
||||||
|
onClick={() => buy.mutate({})}
|
||||||
|
>
|
||||||
|
Buy Dispatch Office ({formatCredits(price)})
|
||||||
|
</Button>
|
||||||
|
{buy.isError && (
|
||||||
|
<p role="alert" className="mt-2 text-rose-300">
|
||||||
|
{errorMessage(buy.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an Independent pilot is doing (CR-021 §2): flying a job, resting, or
|
||||||
|
* waiting for new contracts; null for a Manual pilot.
|
||||||
|
*/
|
||||||
|
export function independentStatus(state: PlayerState, pilot: Pilot): string | null {
|
||||||
|
if (pilot.mode !== "independent") return null;
|
||||||
|
const flight = state.activeDispatches.find((d) => d.pilotId === pilot.id);
|
||||||
|
if (flight) {
|
||||||
|
const where = locationName(flight.destinationId);
|
||||||
|
return flight.contract
|
||||||
|
? `Independent: flying ${goodName(flight.contract)} to ${where}`
|
||||||
|
: `Independent: flying empty to ${where}`;
|
||||||
|
}
|
||||||
|
if (pilot.resting && pilot.restEndsAt !== null) {
|
||||||
|
return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
|
||||||
|
}
|
||||||
|
if (!pilot.assignedShipId) return "Independent: needs a ship";
|
||||||
|
return `Waiting for new contracts at ${locationName(pilot.locationId ?? "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual or Independent, the status line, and the orders (decisions 0.5.122–0.5.124). */
|
||||||
|
export function PilotIndependence({ pilot }: { pilot: Pilot }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const setMode = useGameAction("setPilotMode");
|
||||||
|
// The player is an owner-operator: never Independent (decision 0.5.122).
|
||||||
|
if (pilot.isAvatar || state.tutorial) return null;
|
||||||
|
const owned = state.dispatchOffice.owned;
|
||||||
|
const status = independentStatus(state, pilot);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Flies
|
||||||
|
</legend>
|
||||||
|
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
|
||||||
|
{(["manual", "independent"] as const).map((mode) => (
|
||||||
|
<label
|
||||||
|
key={mode}
|
||||||
|
className={`flex-1 px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2 ${owned ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`mode-${pilot.id}`}
|
||||||
|
value={mode}
|
||||||
|
checked={pilot.mode === mode}
|
||||||
|
disabled={!owned || setMode.isPending}
|
||||||
|
onChange={() => setMode.mutate({ pilotId: pilot.id, mode })}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{mode === "manual" ? "When you send them" : "Independent"}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!owned && (
|
||||||
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
|
Buy a Dispatch Office on the Bridge to let {pilot.name} run jobs on their own.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
{status && (
|
||||||
|
<p aria-label={`${pilot.name} status`} className="mt-2 text-cyan-100">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{setMode.isError && (
|
||||||
|
<p role="alert" className="mt-1 text-rose-300">
|
||||||
|
{errorMessage(setMode.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{owned && <OrdersEditor pilot={pilot} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pilot's orders (decision 0.5.124), edited as a draft and saved together. */
|
||||||
|
function OrdersEditor({ pilot }: { pilot: Pilot }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const save = useGameAction("setPilotOrders");
|
||||||
|
const [draft, setDraft] = useState<PilotOrders>(pilot.orders);
|
||||||
|
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||||
|
const allPlaces = draft.destinationIds === null;
|
||||||
|
const toggle = (list: readonly string[], id: string, on: boolean) =>
|
||||||
|
on ? [...new Set([...list, id])] : list.filter((x) => x !== id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer font-display text-xs tracking-wider text-amber-200 uppercase">
|
||||||
|
Orders
|
||||||
|
</summary>
|
||||||
|
<form
|
||||||
|
aria-label={`Orders for ${pilot.name}`}
|
||||||
|
className="mt-2 space-y-3 text-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate({ pilotId: pilot.id, orders: draft });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="text-ink-400">Destinations</legend>
|
||||||
|
<label className="mt-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allPlaces}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, destinationIds: e.target.checked ? null : discovered })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Anywhere you know
|
||||||
|
</label>
|
||||||
|
{!allPlaces && (
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-1">
|
||||||
|
{discovered.map((id) => (
|
||||||
|
<label key={id} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.destinationIds!.includes(id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
destinationIds: toggle(draft.destinationIds!, id, e.target.checked),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{locationName(id)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="text-ink-400">Cargo</legend>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-1">
|
||||||
|
{content.cargoTypes.map((c) => (
|
||||||
|
<label key={c.id} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.cargoClassIds.includes(c.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
cargoClassIds: toggle(draft.cargoClassIds, c.id, e.target.checked),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.takePriority}
|
||||||
|
onChange={(e) => setDraft({ ...draft, takePriority: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Take Priority jobs
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-ink-400">Least profit per job (cr)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={50}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft.minProfit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
minProfit: Math.max(0, Math.floor(Number(e.target.value) || 0)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`${inputClass} w-28`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-ink-400">Home HQ</span>
|
||||||
|
<select
|
||||||
|
value={draft.homeLocationId}
|
||||||
|
onChange={(e) => setDraft({ ...draft, homeLocationId: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{state.bays.map((b) => (
|
||||||
|
<option key={b.id} value={b.locationId}>
|
||||||
|
{locationName(b.locationId)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button size="sm" type="submit" disabled={save.isPending}>
|
||||||
|
Save orders
|
||||||
|
</Button>
|
||||||
|
{save.isSuccess && !save.isPending && (
|
||||||
|
<span role="status" className="ml-2 text-xs text-emerald-300">
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{save.isError && (
|
||||||
|
<p role="alert" className="text-rose-300">
|
||||||
|
{errorMessage(save.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings → Company: the credits pilots never spend the company below (decision 0.5.127). */
|
||||||
|
export function SpendingFloor() {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const save = useGameAction("setSpendingFloor");
|
||||||
|
const [draft, setDraft] = useState(String(state.dispatchOffice.spendingFloor));
|
||||||
|
if (state.tutorial) return null;
|
||||||
|
const value = Math.floor(Number(draft));
|
||||||
|
const valid = draft.trim() !== "" && Number.isFinite(value) && value >= 0;
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-wrap items-end justify-between gap-2 text-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (valid) save.mutate({ credits: value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex-1">
|
||||||
|
<span className="text-ink-400">Pilots' spending floor (cr)</span>
|
||||||
|
<span className="block text-xs text-ink-500">
|
||||||
|
Independent pilots never buy fuel that would leave the company below this.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className={`${inputClass} mt-1 w-32`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button size="sm" type="submit" disabled={!valid || save.isPending}>
|
||||||
|
Save floor
|
||||||
|
</Button>
|
||||||
|
{save.isError && (
|
||||||
|
<p role="alert" className="w-full text-rose-300">
|
||||||
|
{errorMessage(save.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Fleet card's badge for a ship whose pilot flies on their own. */
|
||||||
|
export function IndependentBadge({ pilot }: { pilot: Pilot | undefined }) {
|
||||||
|
if (pilot?.mode !== "independent") return null;
|
||||||
|
return (
|
||||||
|
<Pill tone="cyan" icon="send">
|
||||||
|
Independent
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
|
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
||||||
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
||||||
import { dispatchProgress } from "@deadhead/sim";
|
import { dispatchProgress } from "@deadhead/sim";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
@ -216,6 +217,7 @@ export function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<DispatchOfficePanel />
|
||||||
{!state.tutorial && <GoalsCard />}
|
{!state.tutorial && <GoalsCard />}
|
||||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, Ship } from "@deadhead/schema";
|
import { IndependentBadge } from "../components/IndependentPilots";
|
||||||
|
import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
|
||||||
import {
|
import {
|
||||||
dispatchProgress,
|
dispatchProgress,
|
||||||
tripFuel,
|
tripFuel,
|
||||||
|
|
@ -69,6 +70,7 @@ export function Fleet() {
|
||||||
ship={ship}
|
ship={ship}
|
||||||
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
||||||
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
||||||
|
pilot={state.pilots.find((p) => p.id === ship.pilotId)}
|
||||||
discoveredIds={discoveredIds}
|
discoveredIds={discoveredIds}
|
||||||
onDeadhead={() => setDeadheading(ship)}
|
onDeadhead={() => setDeadheading(ship)}
|
||||||
onUpgrades={() => setUpgrading(ship.id)}
|
onUpgrades={() => setUpgrading(ship.id)}
|
||||||
|
|
@ -88,6 +90,7 @@ function ShipRow(props: {
|
||||||
ship: Ship;
|
ship: Ship;
|
||||||
dispatch: Dispatch | undefined;
|
dispatch: Dispatch | undefined;
|
||||||
pilotName: string | null;
|
pilotName: string | null;
|
||||||
|
pilot: Pilot | undefined;
|
||||||
discoveredIds: ReadonlySet<string>;
|
discoveredIds: ReadonlySet<string>;
|
||||||
onDeadhead: () => void;
|
onDeadhead: () => void;
|
||||||
onUpgrades: () => void;
|
onUpgrades: () => void;
|
||||||
|
|
@ -127,14 +130,18 @@ function ShipRow(props: {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
|
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
|
||||||
<div className="mt-1 text-sm text-ink-200">
|
<div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-ink-200">
|
||||||
{dispatch
|
<span>
|
||||||
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
{dispatch
|
||||||
: `At ${locationName(ship.locationId ?? "")}`}
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||||
{" · "}
|
: `At ${locationName(ship.locationId ?? "")}`}
|
||||||
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
|
{" · "}
|
||||||
{pilotName ?? "No pilot"}
|
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
|
||||||
|
{pilotName ?? "No pilot"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{/* A pilot who flies on their own (CR-021). */}
|
||||||
|
<IndependentBadge pilot={props.pilot} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
|
import { PilotIndependence } from "../components/IndependentPilots";
|
||||||
import type { Pilot } from "@deadhead/schema";
|
import type { Pilot } from "@deadhead/schema";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MS_PER_HOUR } from "@deadhead/sim";
|
import { MS_PER_HOUR } from "@deadhead/sim";
|
||||||
|
|
@ -216,6 +217,8 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PilotIndependence pilot={pilot} />
|
||||||
|
|
||||||
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
|
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
|
||||||
|
|
||||||
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { SpendingFloor } from "../components/IndependentPilots";
|
||||||
import { resetMapView } from "../map/camera";
|
import { resetMapView } from "../map/camera";
|
||||||
import { About } from "./AboutSettings";
|
import { About } from "./AboutSettings";
|
||||||
import { CompanyName, NewCompany } from "./CompanySettings";
|
import { CompanyName, NewCompany } from "./CompanySettings";
|
||||||
|
|
@ -24,6 +25,7 @@ export function Settings() {
|
||||||
|
|
||||||
<Section title="Company">
|
<Section title="Company">
|
||||||
<CompanyName />
|
<CompanyName />
|
||||||
|
<SpendingFloor />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ExportSave />
|
<ExportSave />
|
||||||
<ImportSave hasCompany />
|
<ImportSave hasCompany />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Dispatch, Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||||
|
import { screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { independentStatus } from "../src/components/IndependentPilots";
|
||||||
|
import { formatLocalTime } from "../src/screens/DispatchSheet";
|
||||||
|
import { pilotExtras } from "./fixtures";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||||
|
|
||||||
|
const NOW = Date.now();
|
||||||
|
|
||||||
|
const ship = (id: string, pilotId: string | null): Ship => ({
|
||||||
|
id,
|
||||||
|
modelId: "courier",
|
||||||
|
name: `Ship ${id}`,
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dispatchId: null,
|
||||||
|
lastArrivalAt: 0,
|
||||||
|
...shipExtras("courier"),
|
||||||
|
pilotId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hired = (over: Partial<Pilot> = {}): Pilot => ({
|
||||||
|
id: "p1",
|
||||||
|
name: "Ada Vance",
|
||||||
|
isAvatar: false,
|
||||||
|
isVeteran: false,
|
||||||
|
xp: 0,
|
||||||
|
level: 0,
|
||||||
|
skills: {},
|
||||||
|
skillPointsAvailable: 0,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dutyHours: 0,
|
||||||
|
dutyLimit: 12,
|
||||||
|
resting: false,
|
||||||
|
restEndsAt: null,
|
||||||
|
...pilotExtras,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function owner(over: Partial<PlayerState> = {}): PlayerState {
|
||||||
|
const base = samplePlayerState({ tutorial: null });
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
company: { ...base.company, credits: 100_000 },
|
||||||
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
|
ships: [ship("s0", "avatar"), ship("s1", "p1")],
|
||||||
|
pilots: [{ ...base.pilots[0]!, assignedShipId: "s0", locationId: "earth" }, hired()],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedOffice = { dispatchOffice: { owned: true, spendingFloor: 500 } };
|
||||||
|
|
||||||
|
describe("Dispatch Office and pilot controls (Step 21.6, decisions 0.5.122–0.5.124)", () => {
|
||||||
|
it("disables Independent with a reason before the Dispatch Office, and hides it for you", async () => {
|
||||||
|
renderApp(fakeApi({ getPlayerState: () => owner() }), "/pilots");
|
||||||
|
const card = (await screen.findByText("Ada Vance")).closest("li")!;
|
||||||
|
expect(within(card).getByRole("radio", { name: "Independent" })).toBeDisabled();
|
||||||
|
expect(card).toHaveTextContent("Buy a Dispatch Office on the Bridge");
|
||||||
|
const yours = screen.getByText("Captain").closest("li")!;
|
||||||
|
expect(within(yours).queryByRole("radio", { name: "Independent" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a pilot Independent once the office is bought", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => owner(ownedOffice),
|
||||||
|
setPilotMode: ({ pilotId, mode }) => ({ pilot: hired({ id: pilotId, mode }) }),
|
||||||
|
});
|
||||||
|
renderApp(api, "/pilots");
|
||||||
|
const card = (await screen.findByText("Ada Vance")).closest("li")!;
|
||||||
|
await userEvent.click(within(card).getByRole("radio", { name: "Independent" }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("setPilotMode", { pilotId: "p1", mode: "independent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves edited orders through the engine", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => owner(ownedOffice),
|
||||||
|
setPilotOrders: ({ pilotId, orders }) => ({ pilot: hired({ id: pilotId, orders }) }),
|
||||||
|
});
|
||||||
|
renderApp(api, "/pilots");
|
||||||
|
const card = (await screen.findByText("Ada Vance")).closest("li")!;
|
||||||
|
await userEvent.click(within(card).getByText("Orders"));
|
||||||
|
const form = within(card).getByRole("form", { name: "Orders for Ada Vance" });
|
||||||
|
await userEvent.click(within(form).getByRole("checkbox", { name: "Take Priority jobs" }));
|
||||||
|
await userEvent.click(within(form).getByRole("checkbox", { name: "Anywhere you know" }));
|
||||||
|
await userEvent.click(within(form).getByRole("checkbox", { name: "Earth" }));
|
||||||
|
await userEvent.clear(within(form).getByRole("spinbutton"));
|
||||||
|
await userEvent.type(within(form).getByRole("spinbutton"), "300");
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Save orders" }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("setPilotOrders", {
|
||||||
|
pilotId: "p1",
|
||||||
|
orders: {
|
||||||
|
...pilotExtras.orders,
|
||||||
|
destinationIds: ["moon"],
|
||||||
|
takePriority: false,
|
||||||
|
minProfit: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes what an Independent pilot is doing", () => {
|
||||||
|
const flight: Dispatch = {
|
||||||
|
id: "d1",
|
||||||
|
shipId: "s1",
|
||||||
|
pilotId: "p1",
|
||||||
|
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: 3_000,
|
||||||
|
requiredSkill: { skillId: "expensive-cargo", level: 1 },
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: NOW,
|
||||||
|
expiresAt: NOW + 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
},
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
tutorial: false,
|
||||||
|
departureAt: NOW,
|
||||||
|
arrivalAt: NOW + 120_000,
|
||||||
|
distanceMillionKm: 0.38,
|
||||||
|
fuelCost: 50,
|
||||||
|
fuelBurnedFu: 5,
|
||||||
|
expectedPayout: 3_000,
|
||||||
|
seed: 1,
|
||||||
|
status: "in-flight",
|
||||||
|
resolvedAt: null,
|
||||||
|
};
|
||||||
|
const independent = hired({ mode: "independent" });
|
||||||
|
expect(
|
||||||
|
independentStatus(owner({ activeDispatches: [flight] }), {
|
||||||
|
...independent,
|
||||||
|
status: "in-flight",
|
||||||
|
locationId: null,
|
||||||
|
}),
|
||||||
|
).toBe("Independent: flying Medical Supplies to Moon");
|
||||||
|
expect(independentStatus(owner(), { ...independent, locationId: "mars" })).toBe(
|
||||||
|
"Waiting for new contracts at Mars",
|
||||||
|
);
|
||||||
|
const wakes = NOW + 30 * 60_000;
|
||||||
|
expect(independentStatus(owner(), { ...independent, resting: true, restEndsAt: wakes })).toBe(
|
||||||
|
`Resting until ${formatLocalTime(wakes)}`,
|
||||||
|
);
|
||||||
|
expect(independentStatus(owner(), hired())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Independent badge only on ships crewed by Independent pilots", async () => {
|
||||||
|
const state = owner({
|
||||||
|
pilots: [{ ...owner().pilots[0]! }, hired({ mode: "independent" })],
|
||||||
|
});
|
||||||
|
renderApp(fakeApi({ getPlayerState: () => state }), "/fleet");
|
||||||
|
const theirs = (await screen.findByText("Ship s1")).closest("li")!;
|
||||||
|
const yours = screen.getByText("Ship s0").closest("li")!;
|
||||||
|
expect(within(theirs).getByText("Independent")).toBeInTheDocument();
|
||||||
|
expect(within(yours).queryByText("Independent")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sells the Dispatch Office on the Bridge and sets the spending floor in Settings", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => owner(),
|
||||||
|
buyDispatchOffice: () => ({
|
||||||
|
company: owner().company,
|
||||||
|
dispatchOffice: { owned: true, spendingFloor: 500 },
|
||||||
|
}),
|
||||||
|
setSpendingFloor: ({ credits }) => ({
|
||||||
|
company: owner().company,
|
||||||
|
dispatchOffice: { owned: false, spendingFloor: credits },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { unmount } = renderApp(api, "/");
|
||||||
|
const office = await screen.findByLabelText("Dispatch Office");
|
||||||
|
expect(office).toHaveTextContent("Lets your hired pilots run jobs on their own");
|
||||||
|
const price = content.config.independentPilots.dispatchOfficePrice.toLocaleString("en-US");
|
||||||
|
await userEvent.click(
|
||||||
|
within(office).getByRole("button", { name: `Buy Dispatch Office (${price} cr)` }),
|
||||||
|
);
|
||||||
|
expect(api.call).toHaveBeenCalledWith("buyDispatchOffice", {});
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderApp(api, "/settings");
|
||||||
|
const floor = await screen.findByRole("spinbutton");
|
||||||
|
await userEvent.clear(floor);
|
||||||
|
await userEvent.type(floor, "2000");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save floor" }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("setSpendingFloor", { credits: 2000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 684 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
|
|
@ -467,7 +467,13 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
|
||||||
- [x] **Step 21.3 — Engine: Dispatch Office, modes, orders.** Save version 5: pilots get `mode` (manual/independent) and `orders` (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets `dispatchOffice` and `npcSpendingFloor`. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: `Pilot.mode` and `Pilot.orders`; `PlayerState.dispatchOffice` holds `owned` and `spendingFloor`. Commands (`packages/engine/src/game/independent.ts`): `buyDispatchOffice` (after the tutorial, once, for its price), `setPilotMode` (Independent needs the office and is never the avatar), `setPilotOrders` (checked against locations, cargo classes and the player's bays), and `setSpendingFloor`. New error codes: `dispatch-office-required`, `dispatch-office-owned`, `avatar-not-independent`, `unknown-location`, `unknown-cargo-class`. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (`packages/engine/test/independent-commands.test.ts`): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. `packages/schema/test/save.test.ts`: 4 → 5 as above. Web and schema fixtures gained the new fields (`pilotExtras` in `apps/web/test/fixtures.ts`).
|
- [x] **Step 21.3 — Engine: Dispatch Office, modes, orders.** Save version 5: pilots get `mode` (manual/independent) and `orders` (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets `dispatchOffice` and `npcSpendingFloor`. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: `Pilot.mode` and `Pilot.orders`; `PlayerState.dispatchOffice` holds `owned` and `spendingFloor`. Commands (`packages/engine/src/game/independent.ts`): `buyDispatchOffice` (after the tutorial, once, for its price), `setPilotMode` (Independent needs the office and is never the avatar), `setPilotOrders` (checked against locations, cargo classes and the player's bays), and `setSpendingFloor`. New error codes: `dispatch-office-required`, `dispatch-office-owned`, `avatar-not-independent`, `unknown-location`, `unknown-cargo-class`. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (`packages/engine/test/independent-commands.test.ts`): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. `packages/schema/test/save.test.ts`: 4 → 5 as above. Web and schema fixtures gained the new fields (`pilotExtras` in `apps/web/test/fixtures.ts`).
|
||||||
- [x] **Step 21.4 — Engine: event-queue catch-up.** `catchUp` in `packages/engine/src/game/catchup.ts` (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's `resolveDue` now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' `nextDecisionAt` and `emptyPeriods`, and dispatches' `independent` flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (`packages/engine/test/catch-up.test.ts`): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.
|
- [x] **Step 21.4 — Engine: event-queue catch-up.** `catchUp` in `packages/engine/src/game/catchup.ts` (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's `resolveDue` now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' `nextDecisionAt` and `emptyPeriods`, and dispatches' `independent` flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (`packages/engine/test/catch-up.test.ts`): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.
|
||||||
- [x] **Step 21.5 — Engine: live play.** While the app is open, the resolution loop's pass is the catch-up of Step 21.4, so pilots decide every few seconds. `ApiClient.setViewedContract` (the engine client calls `GameEngine.setViewedContract`) is set by the dispatch sheet while it's open and cleared when it closes; catch-up passes it to the sim, which skips that contract. Tests (`packages/engine/test/live-play.test.ts`): with the best contract open a pilot takes the next one; after it closes, another pilot takes it; the loop's tick makes an Independent pilot's decision and refreshes the screen. `apps/web/test/viewed-contract.test.tsx`: opening the dispatch sheet reports its contract, and Cancel clears it.
|
- [x] **Step 21.5 — Engine: live play.** While the app is open, the resolution loop's pass is the catch-up of Step 21.4, so pilots decide every few seconds. `ApiClient.setViewedContract` (the engine client calls `GameEngine.setViewedContract`) is set by the dispatch sheet while it's open and cleared when it closes; catch-up passes it to the sim, which skips that contract. Tests (`packages/engine/test/live-play.test.ts`): with the best contract open a pilot takes the next one; after it closes, another pilot takes it; the loop's tick makes an Independent pilot's decision and refreshes the screen. `apps/web/test/viewed-contract.test.tsx`: opening the dispatch sheet reports its contract, and Cancel clears it.
|
||||||
- [ ] Step 21.6 — Client: Dispatch Office and pilot controls
|
- [x] **Step 21.6 — Client: Dispatch Office and pilot controls.** `apps/web/src/components/IndependentPilots.tsx`:
|
||||||
|
- **`DispatchOfficePanel`** on the Bridge: what it does and "Buy Dispatch Office (36,000 cr)"; once owned, a pointer to Pilots.
|
||||||
|
- **`PilotIndependence`** on each hired pilot's card (none for the avatar): "Flies: When you send them / Independent", disabled before the office with "Buy a Dispatch Office on the Bridge…"; the status line from `independentStatus` ("Independent: flying Medical Supplies to Moon", "Waiting for new contracts at Mars", "Resting until 14:20"); and an **Orders** editor (anywhere you know or chosen places, cargo classes, Priority, least profit, home HQ), saved together.
|
||||||
|
- **`SpendingFloor`** in Settings → Company.
|
||||||
|
- **`IndependentBadge`** on Fleet cards, beside the pilot's name.
|
||||||
|
|
||||||
|
Screenshots: `docs/screenshots/21-6-dispatch-office-desktop.png`, `21-6-dispatch-office-owned-phone.png`, `21-6-pilot-orders-phone.png`, `21-6-fleet-badge-phone.png`. Tests (`apps/web/test/independent-controls.test.tsx`): the toggle disabled with its reason before the office and absent for the avatar; Independent sent to the engine once bought; edited orders saved; each status line; the badge only on an Independent crew; buying the office on the Bridge and saving the spending floor.
|
||||||
- [ ] Step 21.7 — Client: board and logbook
|
- [ ] Step 21.7 — Client: board and logbook
|
||||||
- [ ] Step 21.8 — Balance
|
- [ ] Step 21.8 — Balance
|
||||||
- [ ] Step 21.9 — Plan amendments and regression
|
- [ ] Step 21.9 — Plan amendments and regression
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue