330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
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";
|
||
import { serviceStatus } from "./Service";
|
||
|
||
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;
|
||
// Booked service comes first (CR-027 §4): heading in, in the shop, waiting for funds.
|
||
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
|
||
const service = ship && ship.booking?.status !== "cant-reach" ? serviceStatus(ship, pilot) : null;
|
||
if (service) return service;
|
||
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.restingInShopUntil !== null) {
|
||
return `Resting while in the shop until ${formatLocalTime(pilot.restingInShopUntil)}`;
|
||
}
|
||
if (pilot.resting && pilot.restEndsAt !== null) {
|
||
return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
|
||
}
|
||
if (!pilot.assignedShipId) return "Independent: needs a ship";
|
||
// The fleet has gone quiet: nothing new until the player decides (decision 0.5.137).
|
||
if (state.fleetSleep.asleep) return "Waiting for orders";
|
||
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>
|
||
);
|
||
}
|