deadhead-space-freight-tycoon/apps/web/src/components/IndependentPilots.tsx

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&apos;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&apos; 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>
);
}