Step 27.5: client: Service section and tags
CI / check (push) Successful in 8m41s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -2021,3 +2021,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **A service trip is any decision while the ship has an unstarted, reachable booking and isn't at the HQ yet**: only those pilots decide past the fleet's sleep time (still before the 7-day cap). A pilot waiting at the HQ (for funds) follows the sleep rule, so a long wait doesn't fill the catch-up with idle looks.
|
- **A service trip is any decision while the ship has an unstarted, reachable booking and isn't at the HQ yet**: only those pilots decide past the fleet's sleep time (still before the 7-day cap). A pilot waiting at the HQ (for funds) follows the sleep rule, so a long wait doesn't fill the catch-up with idle looks.
|
||||||
- **After the work, the pilot goes back to their orders**; hull wear on the next trips can cross the threshold again and book the next repair.
|
- **After the work, the pilot goes back to their orders**; hull wear on the next trips can cross the threshold again and book the next repair.
|
||||||
|
|
||||||
|
## Step 27.5
|
||||||
|
|
||||||
|
- **The Service section lives in the ship's Upgrades sheet**, under the hull: that sheet is the ship's detail view.
|
||||||
|
- **The tag is a wrapping chip**, not a pill: "Service booked: Cargo Bay Expansion 2 at Ceres Station" doesn't fit a pill on a phone. Cyan when on track, amber for "Repair recommended", waiting for funds or out of reach.
|
||||||
|
- **The slider saves as it moves** (the engine is local, so each step is cheap) and is disabled while auto-repair is off.
|
||||||
|
- **An Independent pilot's line puts the service first** (heading in, in the shop, waiting for funds); out of reach, it shows their usual work, and the ship's tag carries the warning.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { formatLocalTime } from "../screens/DispatchSheet";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { Button, Icon, Panel, Pill } from "../ui";
|
import { Button, Icon, Panel, Pill } from "../ui";
|
||||||
import { goodName } from "./Goods";
|
import { goodName } from "./Goods";
|
||||||
|
import { serviceStatus } from "./Service";
|
||||||
|
|
||||||
const inputClass =
|
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";
|
"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";
|
||||||
|
|
@ -64,6 +65,10 @@ export function DispatchOfficePanel() {
|
||||||
*/
|
*/
|
||||||
export function independentStatus(state: PlayerState, pilot: Pilot): string | null {
|
export function independentStatus(state: PlayerState, pilot: Pilot): string | null {
|
||||||
if (pilot.mode !== "independent") return 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);
|
const flight = state.activeDispatches.find((d) => d.pilotId === pilot.id);
|
||||||
if (flight) {
|
if (flight) {
|
||||||
const where = locationName(flight.destinationId);
|
const where = locationName(flight.destinationId);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||||
|
import { nearestReachableHq, nextUpgradeLevel, repairCost, upgradeCost } from "@deadhead/sim";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { formatCredits, locationName } from "../format";
|
||||||
|
import { shipModel, simShip } from "../game";
|
||||||
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
|
||||||
|
type Booking = NonNullable<Ship["booking"]>;
|
||||||
|
|
||||||
|
/** "Repair + Engine Tuning 2": what a booking will do (CR-027). */
|
||||||
|
export function serviceItems(booking: Pick<Booking, "repair" | "upgradeId" | "upgradeLevel">) {
|
||||||
|
const items: string[] = [];
|
||||||
|
if (booking.repair) items.push("Repair");
|
||||||
|
if (booking.upgradeId) {
|
||||||
|
const name = content.upgrade(booking.upgradeId)?.name ?? booking.upgradeId;
|
||||||
|
items.push(booking.upgradeLevel !== null ? `${name} ${booking.upgradeLevel}` : name);
|
||||||
|
}
|
||||||
|
return items.join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Fleet card and dispatch sheet tag (CR-027 §4); null when there's nothing to say. */
|
||||||
|
export function serviceTag(ship: Ship): string | null {
|
||||||
|
if (ship.booking) {
|
||||||
|
return `Service booked: ${serviceItems(ship.booking)} at ${locationName(ship.booking.hqId)}`;
|
||||||
|
}
|
||||||
|
return ship.repairRecommended ? "Repair recommended" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pilot's service status line (CR-027 §4): heading in, in the shop,
|
||||||
|
* waiting for funds, or out of reach; null without a booking.
|
||||||
|
*/
|
||||||
|
export function serviceStatus(ship: Ship, pilot: Pilot | undefined): string | null {
|
||||||
|
const booking = ship.booking;
|
||||||
|
if (!booking) return null;
|
||||||
|
const hq = locationName(booking.hqId);
|
||||||
|
switch (booking.status) {
|
||||||
|
case "heading":
|
||||||
|
return `Heading to ${hq} for service`;
|
||||||
|
case "in-shop":
|
||||||
|
return pilot?.restingInShopUntil != null || pilot?.resting
|
||||||
|
? `In the shop at ${hq} (resting)`
|
||||||
|
: `In the shop at ${hq}`;
|
||||||
|
case "waiting-funds":
|
||||||
|
return `Waiting for funds: ${serviceItems(booking)} at ${hq}`;
|
||||||
|
case "cant-reach":
|
||||||
|
return `Can't reach ${hq} for service`;
|
||||||
|
case "booked":
|
||||||
|
return `Service booked at ${hq}: starts when the ship is next idle there`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small tag for the Fleet card and the dispatch sheet. */
|
||||||
|
export function ServiceTag({ ship }: { ship: Ship }) {
|
||||||
|
const tag = serviceTag(ship);
|
||||||
|
if (!tag) return null;
|
||||||
|
const warn =
|
||||||
|
!ship.booking ||
|
||||||
|
ship.booking.status === "waiting-funds" ||
|
||||||
|
ship.booking.status === "cant-reach";
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-label={`${ship.name} service`}
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-sm ${warn ? "border-amber-400/40 bg-amber-950/30 text-amber-100" : "border-cyan-400/40 bg-cyan-950/30 text-cyan-100"}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the ship is, or lands (bookings are made from anywhere). */
|
||||||
|
function shipPlace(state: PlayerState, ship: Ship): string | null {
|
||||||
|
if (ship.locationId) return ship.locationId;
|
||||||
|
return state.activeDispatches.find((d) => d.id === ship.dispatchId)?.destinationId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearest HQ the ship can reach under the fuel rules, as the engine picks it. */
|
||||||
|
export function defaultHq(state: PlayerState, ship: Ship): string | null {
|
||||||
|
const place = shipPlace(state, ship);
|
||||||
|
if (!place) return null;
|
||||||
|
const hqs = [...new Set(state.bays.map((b) => b.locationId))];
|
||||||
|
const discovered = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
|
||||||
|
return nearestReachableHq(
|
||||||
|
{
|
||||||
|
...simShip(ship),
|
||||||
|
locationId: place,
|
||||||
|
inFlight: false,
|
||||||
|
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
||||||
|
},
|
||||||
|
hqs,
|
||||||
|
discovered,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What booking `repair` and `upgradeId` would cost now. */
|
||||||
|
function estimate(ship: Ship, repair: boolean, upgradeId: string | null): number {
|
||||||
|
const model = shipModel(ship.modelId);
|
||||||
|
const upgrade = upgradeId ? content.upgrade(upgradeId) : undefined;
|
||||||
|
const level = upgrade ? nextUpgradeLevel(upgrade, model.id, ship.upgrades) : null;
|
||||||
|
return (
|
||||||
|
(repair && ship.hullConditionPct < 100
|
||||||
|
? repairCost(model, ship.hullConditionPct, content.config)
|
||||||
|
: 0) + (level !== null ? upgradeCost(model, level, content.config) : 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choosing the items and the HQ: a new booking or a change (decision 0.5.153). */
|
||||||
|
function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const book = useGameAction("bookService");
|
||||||
|
const model = shipModel(ship.modelId);
|
||||||
|
const hqs = [...new Set(state.bays.map((b) => b.locationId))];
|
||||||
|
const nearest = defaultHq(state, ship);
|
||||||
|
const canRepair = ship.hullConditionPct < 100;
|
||||||
|
const [repair, setRepair] = useState(ship.booking?.repair ?? canRepair);
|
||||||
|
const [upgradeId, setUpgradeId] = useState<string | null>(ship.booking?.upgradeId ?? null);
|
||||||
|
const [hqId, setHqId] = useState(ship.booking?.hqId ?? nearest ?? hqs[0] ?? "");
|
||||||
|
const upgrades = content.upgrades.filter(
|
||||||
|
(u) => nextUpgradeLevel(u, model.id, ship.upgrades) !== null,
|
||||||
|
);
|
||||||
|
const cost = estimate(ship, repair, upgradeId);
|
||||||
|
const nothing = !(repair && canRepair) && !upgradeId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label="Book service" className="mt-2 space-y-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={repair && canRepair}
|
||||||
|
disabled={!canRepair}
|
||||||
|
onChange={(e) => setRepair(e.target.checked)}
|
||||||
|
className="size-4 accent-amber-400"
|
||||||
|
/>
|
||||||
|
Repair to 100%
|
||||||
|
{!canRepair && <span className="text-ink-400">(hull is whole)</span>}
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-ink-400">Upgrade</span>
|
||||||
|
<select
|
||||||
|
aria-label="Upgrade"
|
||||||
|
value={upgradeId ?? ""}
|
||||||
|
onChange={(e) => setUpgradeId(e.target.value || null)}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-space-600 bg-space-950 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{upgrades.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} {nextUpgradeLevel(u, model.id, ship.upgrades)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-ink-400">HQ</span>
|
||||||
|
<select
|
||||||
|
aria-label="HQ"
|
||||||
|
value={hqId}
|
||||||
|
onChange={(e) => setHqId(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-space-600 bg-space-950 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
{hqs.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{locationName(id)}
|
||||||
|
{id === nearest ? " (nearest)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p aria-label="Estimate" className="text-ink-200 tabular-nums">
|
||||||
|
Estimate: {formatCredits(cost)}, charged when the work starts
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={nothing || !hqId || book.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
book.mutate(
|
||||||
|
{ shipId: ship.id, repair: repair && canRepair, upgradeId, hqId },
|
||||||
|
{ onSuccess: onDone },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ship.booking ? "Save booking" : "Book service"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={onDone}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{book.isError && (
|
||||||
|
<p role="alert" className="text-rose-300">
|
||||||
|
{errorMessage(book.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-repair on or off and its threshold, in steps of 5% (decision 0.5.157). */
|
||||||
|
function AutoRepair({ ship }: { ship: Ship }) {
|
||||||
|
const save = useGameAction("setAutoRepair");
|
||||||
|
const { minPct, maxPct, stepPct } = content.config.autoRepair;
|
||||||
|
const { on, thresholdPct } = ship.autoRepair;
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-space-700 pt-3">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={on}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onChange={(e) => save.mutate({ shipId: ship.id, on: e.target.checked, thresholdPct })}
|
||||||
|
className="size-4 accent-amber-400"
|
||||||
|
/>
|
||||||
|
Auto-repair below {thresholdPct}%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
aria-label="Auto-repair threshold"
|
||||||
|
min={minPct}
|
||||||
|
max={maxPct}
|
||||||
|
step={stepPct}
|
||||||
|
value={thresholdPct}
|
||||||
|
disabled={!on}
|
||||||
|
onChange={(e) => save.mutate({ shipId: ship.id, on, thresholdPct: Number(e.target.value) })}
|
||||||
|
className="mt-2 w-full accent-amber-400"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
|
With an Independent pilot, a repair is booked at the nearest HQ when the hull drops below
|
||||||
|
this. With you flying it, the ship shows “Repair recommended”.
|
||||||
|
</p>
|
||||||
|
{save.isError && (
|
||||||
|
<p role="alert" className="mt-1 text-rose-300">
|
||||||
|
{errorMessage(save.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ship's Service section (CR-027 §4): its booking with the estimate and
|
||||||
|
* status, Book service, Change and Cancel, and auto-repair.
|
||||||
|
*/
|
||||||
|
export function ServiceSection({ shipId }: { shipId: string }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const cancel = useGameAction("cancelBooking");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const ship = state.ships.find((s) => s.id === shipId)!;
|
||||||
|
const pilot = state.pilots.find((p) => p.id === ship.pilotId);
|
||||||
|
const booking = ship.booking;
|
||||||
|
const started = booking?.status === "in-shop";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Service"
|
||||||
|
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-display font-bold tracking-wide">Service</span>
|
||||||
|
{booking && (
|
||||||
|
<span className="text-ink-400">{booking.by === "auto" ? "Auto" : "Booked"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{booking && !editing && (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<p className="text-ink-100">
|
||||||
|
{serviceItems(booking)} at {locationName(booking.hqId)} ·{" "}
|
||||||
|
<span className="tabular-nums">{formatCredits(booking.estimate)}</span>
|
||||||
|
</p>
|
||||||
|
<p aria-label="Service status" className="text-ink-300">
|
||||||
|
{serviceStatus(ship, pilot)}
|
||||||
|
</p>
|
||||||
|
{!started && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={cancel.isPending}
|
||||||
|
onClick={() => cancel.mutate({ shipId: ship.id })}
|
||||||
|
>
|
||||||
|
Cancel booking
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!booking && !editing && (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-ink-400">
|
||||||
|
Book a repair or an upgrade from anywhere: the work starts when the ship is next idle at
|
||||||
|
the HQ.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" className="mt-2" onClick={() => setEditing(true)}>
|
||||||
|
Book service
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{editing && <BookingForm ship={ship} onDone={() => setEditing(false)} />}
|
||||||
|
{cancel.isError && (
|
||||||
|
<p role="alert" className="mt-2 text-rose-300">
|
||||||
|
{errorMessage(cancel.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<AutoRepair ship={ship} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ import {
|
||||||
formatTimeOfDay,
|
formatTimeOfDay,
|
||||||
} from "../clock/timeFormat";
|
} from "../clock/timeFormat";
|
||||||
import { usePreferences } from "../state/preferences";
|
import { usePreferences } from "../state/preferences";
|
||||||
|
import { serviceTag } from "../components/Service";
|
||||||
|
|
||||||
/** Short player-facing reasons a ship+pilot cannot take a contract. */
|
/** Short player-facing reasons a ship+pilot cannot take a contract. */
|
||||||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
|
|
@ -177,6 +178,8 @@ export interface PairOption {
|
||||||
modelSprite: string;
|
modelSprite: string;
|
||||||
pilotId: string | null;
|
pilotId: string | null;
|
||||||
pilotName: string | null;
|
pilotName: string | null;
|
||||||
|
/** "Service booked: Repair at Mars" or "Repair recommended" (CR-027); null otherwise. */
|
||||||
|
service: string | null;
|
||||||
/** Why this pair cannot take the contract; empty when it can. */
|
/** Why this pair cannot take the contract; empty when it can. */
|
||||||
problems: string[];
|
problems: string[];
|
||||||
tripMs: number;
|
tripMs: number;
|
||||||
|
|
@ -282,6 +285,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
modelSprite: model.sprite,
|
modelSprite: model.sprite,
|
||||||
pilotId: pilot?.id ?? null,
|
pilotId: pilot?.id ?? null,
|
||||||
pilotName: pilot?.name ?? null,
|
pilotName: pilot?.name ?? null,
|
||||||
|
service: serviceTag(ship),
|
||||||
problems,
|
problems,
|
||||||
tripMs,
|
tripMs,
|
||||||
fuelCost,
|
fuelCost,
|
||||||
|
|
@ -429,6 +433,11 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
<span className="block text-xs text-ink-400">
|
<span className="block text-xs text-ink-400">
|
||||||
{option.modelName} · {option.pilotName ?? "no pilot"}
|
{option.modelName} · {option.pilotName ?? "no pilot"}
|
||||||
</span>
|
</span>
|
||||||
|
{option.service && (
|
||||||
|
<span aria-label="Service" className="block text-xs text-cyan-200">
|
||||||
|
{option.service}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{blocked ? (
|
{blocked ? (
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useClock } from "../clock/clock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel";
|
import { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel";
|
||||||
import { ShipUpgrades } from "./ShipUpgrades";
|
import { ShipUpgrades } from "./ShipUpgrades";
|
||||||
|
import { ServiceTag } from "../components/Service";
|
||||||
import { strandedText } from "./DispatchSheet";
|
import { strandedText } from "./DispatchSheet";
|
||||||
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||||
import { location, lowFuel, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
import { location, lowFuel, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||||
|
|
@ -161,6 +162,7 @@ function ShipRow(props: {
|
||||||
)}
|
)}
|
||||||
<FuelGauge ship={ship} />
|
<FuelGauge ship={ship} />
|
||||||
<HullGauge ship={ship} />
|
<HullGauge ship={ship} />
|
||||||
|
<ServiceTag ship={ship} />
|
||||||
{low && (
|
{low && (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { formatCredits, formatDuration, formatMillionKm } from "../format";
|
||||||
import { shipModel } from "../game";
|
import { shipModel } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { Button, Pill, Sheet } from "../ui";
|
import { Button, Pill, Sheet } from "../ui";
|
||||||
|
import { ServiceSection } from "../components/Service";
|
||||||
|
|
||||||
/** Why an upgrade can't be started now, in plain words (decisions 0.5.64, 0.5.65). */
|
/** Why an upgrade can't be started now, in plain words (decisions 0.5.64, 0.5.65). */
|
||||||
export const UPGRADE_REASONS: Record<UpgradeFailure, string> = {
|
export const UPGRADE_REASONS: Record<UpgradeFailure, string> = {
|
||||||
|
|
@ -137,9 +138,11 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
titleId="upgrades-title"
|
titleId="upgrades-title"
|
||||||
eyebrow={model.name}
|
eyebrow={model.name}
|
||||||
title={ship.name}
|
title={ship.name}
|
||||||
subtitle="Upgrades and hull repairs are done at an HQ. The ship stays in the shop until the work is done."
|
subtitle="Upgrades and hull repairs are done at an HQ: start them here, or book service from anywhere. The ship stays in the shop until the work is done."
|
||||||
>
|
>
|
||||||
<HullRepair shipId={ship.id} atHq={atHq} />
|
<HullRepair shipId={ship.id} atHq={atHq} />
|
||||||
|
{/* Booked from anywhere; the work starts at the HQ (CR-027). */}
|
||||||
|
{state.tutorial === null && <ServiceSection shipId={ship.id} />}
|
||||||
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
||||||
Upgrades
|
Upgrades
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||||
|
import { repairCost } from "@deadhead/sim";
|
||||||
|
import { fireEvent, screen, waitFor, 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 { serviceStatus, serviceTag } from "../src/components/Service";
|
||||||
|
import { formatCredits } from "../src/format";
|
||||||
|
import { pairOptions } from "../src/screens/DispatchSheet";
|
||||||
|
import { pilotExtras } from "./fixtures";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||||
|
|
||||||
|
type Booking = NonNullable<Ship["booking"]>;
|
||||||
|
|
||||||
|
const ship = (over: Partial<Ship> = {}): Ship => ({
|
||||||
|
id: "s1",
|
||||||
|
name: "Dented Wren",
|
||||||
|
modelId: "courier",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "moon",
|
||||||
|
dispatchId: null,
|
||||||
|
pilotId: "p1",
|
||||||
|
lastArrivalAt: 0,
|
||||||
|
...shipExtras("courier"),
|
||||||
|
hullConditionPct: 60,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const booking = (over: Partial<Booking> = {}): Booking => ({
|
||||||
|
repair: true,
|
||||||
|
upgradeId: null,
|
||||||
|
upgradeLevel: null,
|
||||||
|
hqId: "earth",
|
||||||
|
by: "player",
|
||||||
|
estimate: 4_000,
|
||||||
|
status: "heading",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const rosa: Pilot = {
|
||||||
|
...samplePlayerState().pilots[0]!,
|
||||||
|
id: "p1",
|
||||||
|
name: "Rosa",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
locationId: null,
|
||||||
|
...pilotExtras,
|
||||||
|
mode: "independent",
|
||||||
|
};
|
||||||
|
const state = (s: Ship, over: Partial<PlayerState> = {}) =>
|
||||||
|
samplePlayerState({
|
||||||
|
tutorial: null,
|
||||||
|
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
|
||||||
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
|
ships: [s],
|
||||||
|
pilots: [samplePlayerState().pilots[0]!, rosa],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openService(api: ReturnType<typeof fakeApi>) {
|
||||||
|
renderApp(api, "/fleet");
|
||||||
|
const card = (await screen.findByText("Dented Wren")).closest("li")!;
|
||||||
|
await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Dented Wren" }));
|
||||||
|
return within(screen.getByRole("dialog", { name: "Dented Wren" })).getByLabelText("Service");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the Service section (Step 27.5, decision 0.5.153)", () => {
|
||||||
|
it("books service with the estimate and the nearest reachable HQ by default", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => state(ship()),
|
||||||
|
bookService: () => ({ ship: ship({ booking: booking() }) }),
|
||||||
|
});
|
||||||
|
const section = await openService(api);
|
||||||
|
await userEvent.click(within(section).getByRole("button", { name: "Book service" }));
|
||||||
|
const form = within(section).getByRole("group", { name: "Book service" });
|
||||||
|
expect(within(form).getByLabelText("HQ")).toHaveValue("earth");
|
||||||
|
expect(within(form).getByRole("option", { name: "Earth (nearest)" })).toBeInTheDocument();
|
||||||
|
const cost = repairCost(content.shipModel("courier")!, 60, content.config);
|
||||||
|
expect(within(form).getByLabelText("Estimate")).toHaveTextContent(formatCredits(cost));
|
||||||
|
await userEvent.selectOptions(within(form).getByLabelText("Upgrade"), "cargo-bay");
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Book service" }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("bookService", {
|
||||||
|
shipId: "s1",
|
||||||
|
repair: true,
|
||||||
|
upgradeId: "cargo-bay",
|
||||||
|
hqId: "earth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a booking's items, estimate and status, and changes or cancels it", async () => {
|
||||||
|
const booked = ship({
|
||||||
|
booking: booking({ upgradeId: "cargo-bay", upgradeLevel: 1, estimate: 12_345 }),
|
||||||
|
});
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => state(booked),
|
||||||
|
cancelBooking: () => ({ ship: ship() }),
|
||||||
|
bookService: () => ({ ship: booked }),
|
||||||
|
});
|
||||||
|
const section = await openService(api);
|
||||||
|
expect(section).toHaveTextContent(
|
||||||
|
`Repair + Cargo Bay Expansion 1 at Earth · ${formatCredits(12_345)}`,
|
||||||
|
);
|
||||||
|
expect(within(section).getByLabelText("Service status")).toHaveTextContent(
|
||||||
|
"Heading to Earth for service",
|
||||||
|
);
|
||||||
|
await userEvent.click(within(section).getByRole("button", { name: "Cancel booking" }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("cancelBooking", { shipId: "s1" });
|
||||||
|
|
||||||
|
await userEvent.click(within(section).getByRole("button", { name: "Change" }));
|
||||||
|
const form = within(section).getByRole("group", { name: "Book service" });
|
||||||
|
expect(within(form).getByLabelText("Upgrade")).toHaveValue("cargo-bay");
|
||||||
|
await userEvent.selectOptions(within(form).getByLabelText("Upgrade"), "");
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Save booking" }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("bookService", {
|
||||||
|
shipId: "s1",
|
||||||
|
repair: true,
|
||||||
|
upgradeId: null,
|
||||||
|
hqId: "earth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can't change work that has started", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => state(ship({ booking: booking({ status: "in-shop" }) })),
|
||||||
|
});
|
||||||
|
const section = await openService(api);
|
||||||
|
expect(within(section).queryByRole("button", { name: "Change" })).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(section).queryByRole("button", { name: "Cancel booking" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the auto-repair threshold in steps of 5, and turns it off", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => state(ship()),
|
||||||
|
setAutoRepair: () => ({ ship: ship() }),
|
||||||
|
});
|
||||||
|
const section = await openService(api);
|
||||||
|
const slider = within(section).getByLabelText("Auto-repair threshold");
|
||||||
|
expect(slider).toHaveAttribute("step", "5");
|
||||||
|
expect(slider).toHaveAttribute("min", "10");
|
||||||
|
expect(slider).toHaveAttribute("max", "90");
|
||||||
|
fireEvent.change(slider, { target: { value: "55" } });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.call).toHaveBeenCalledWith("setAutoRepair", {
|
||||||
|
shipId: "s1",
|
||||||
|
on: true,
|
||||||
|
thresholdPct: 55,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await userEvent.click(within(section).getByRole("checkbox", { name: /Auto-repair below 40%/ }));
|
||||||
|
expect(api.call).toHaveBeenCalledWith("setAutoRepair", {
|
||||||
|
shipId: "s1",
|
||||||
|
on: false,
|
||||||
|
thresholdPct: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("service tags and status lines (Step 27.5, CR-027 §4)", () => {
|
||||||
|
it("tags the Fleet card with the booking, or Repair recommended", async () => {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () =>
|
||||||
|
state(
|
||||||
|
ship({
|
||||||
|
booking: booking({ upgradeId: "engines", upgradeLevel: 2, repair: false }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
"/fleet",
|
||||||
|
);
|
||||||
|
expect(await screen.findByLabelText("Dented Wren service")).toHaveTextContent(
|
||||||
|
"Service booked: Engine Tuning 2 at Earth",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("words each state", () => {
|
||||||
|
const at = (status: Booking["status"]) => ship({ booking: booking({ status }) });
|
||||||
|
expect(serviceStatus(at("heading"), rosa)).toBe("Heading to Earth for service");
|
||||||
|
expect(serviceStatus(at("in-shop"), { ...rosa, restingInShopUntil: 1 })).toBe(
|
||||||
|
"In the shop at Earth (resting)",
|
||||||
|
);
|
||||||
|
expect(serviceStatus(at("in-shop"), rosa)).toBe("In the shop at Earth");
|
||||||
|
expect(serviceStatus(at("waiting-funds"), rosa)).toBe("Waiting for funds: Repair at Earth");
|
||||||
|
expect(serviceStatus(at("cant-reach"), rosa)).toBe("Can't reach Earth for service");
|
||||||
|
expect(serviceStatus(ship(), rosa)).toBeNull();
|
||||||
|
expect(serviceTag(ship({ repairRecommended: true }))).toBe("Repair recommended");
|
||||||
|
expect(serviceTag(ship())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the service first in an Independent pilot's status line", () => {
|
||||||
|
const heading = ship({ booking: booking() });
|
||||||
|
expect(independentStatus(state(heading), rosa)).toBe("Heading to Earth for service");
|
||||||
|
// Out of reach: the pilot works as usual, and the line says so.
|
||||||
|
const far = ship({ booking: booking({ status: "cant-reach" }) });
|
||||||
|
expect(independentStatus(state(far), { ...rosa, locationId: "moon" })).not.toMatch(/service/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tags the ship in the dispatch sheet", () => {
|
||||||
|
const job: Contract = {
|
||||||
|
id: "c",
|
||||||
|
originId: "moon",
|
||||||
|
destinationId: "earth",
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 5,
|
||||||
|
payout: 1_000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: 0,
|
||||||
|
expiresAt: 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
};
|
||||||
|
const [pair] = pairOptions(state(ship({ booking: booking({ status: "booked" }) })), job, 0);
|
||||||
|
expect(pair!.service).toBe("Service booked: Repair at Earth");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
|
@ -573,6 +573,6 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-
|
||||||
- [x] **Step 27.2 — Sim: routing and triggers.** `packages/sim/src/service.ts`: `canReachHq` (one leg under the fuel rules: the route, landing, no-strand, a full tank where fuel is sold, else the fuel aboard), `nearestReachableHq`, `bookingAllowsJob`, `autoRepairBooks` (crossing below the threshold, on, Independent crew, no repair already booked) and `repairRecommended` (a Manual crew below the threshold). `chooseAction` takes an optional booking: at the HQ the pilot waits for the work; with the HQ reachable, only jobs ending there, else `deadhead-service` straight there (under the fuel rules and spending floor), else wait; out of reach, the usual choice. Tests (`packages/sim/test/service.test.ts`): reach (Mars yes, Ceres no for a Clipper from Earth); only jobs to the booked HQ; the deadhead there; waiting at the HQ; normal work when it's out of reach; the nearest HQ; the auto-repair trigger for each case, adding a repair to an upgrade booking; Repair recommended.
|
- [x] **Step 27.2 — Sim: routing and triggers.** `packages/sim/src/service.ts`: `canReachHq` (one leg under the fuel rules: the route, landing, no-strand, a full tank where fuel is sold, else the fuel aboard), `nearestReachableHq`, `bookingAllowsJob`, `autoRepairBooks` (crossing below the threshold, on, Independent crew, no repair already booked) and `repairRecommended` (a Manual crew below the threshold). `chooseAction` takes an optional booking: at the HQ the pilot waits for the work; with the HQ reachable, only jobs ending there, else `deadhead-service` straight there (under the fuel rules and spending floor), else wait; out of reach, the usual choice. Tests (`packages/sim/test/service.test.ts`): reach (Mars yes, Ceres no for a Clipper from Earth); only jobs to the booked HQ; the deadhead there; waiting at the HQ; normal work when it's out of reach; the nearest HQ; the auto-repair trigger for each case, adding a repair to an upgrade booking; Repair recommended.
|
||||||
- [x] **Step 27.3 — Engine: booking commands and service start.** `packages/engine/src/game/service.ts`: `bookService` (repair and/or one upgrade level at an HQ, default the nearest reachable; errors `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started`), `cancelBooking` (`no-booking`, `service-started`), `setAutoRepair` (10–90% in steps of 5). `tryStartService` runs after every command and catch-up event: a ship idle at its booked HQ and out of the shop starts the work (repair then upgrade, one shop stay), charged then — a player booking whenever credits cover it, an automatic one only above the spending floor — else it waits, logged once. A stay as long as a rest takes the pilot along (CR-023), so a rest due on arrival or already running overlaps it and ends with it. Auto-repair books (or adds a repair to a booking) when damage, wear or the dev tool takes an Independent crew's hull below the threshold. Logbook kind `service` (booked, started, finished, waiting-funds). Ship API: `booking` (items, level, HQ, by, estimate, status `booked`/`heading`/`in-shop`/`waiting-funds`/`cant-reach`), `autoRepair`, `repairRecommended`. Routes `bookService`, `cancelBooking`, `setAutoRepair` (manual decisions). Tests (`packages/engine/test/service.test.ts`): immediate start at the HQ with the charge, the upgrade and the log; start on arrival charged for the landed hull; rest on arrival overlapping, duty reset; a running rest overlapping; waiting for funds then starting; refusals; cancel; the automatic booking, starting or waiting below the floor; Manual crews only recommended; the threshold setting.
|
- [x] **Step 27.3 — Engine: booking commands and service start.** `packages/engine/src/game/service.ts`: `bookService` (repair and/or one upgrade level at an HQ, default the nearest reachable; errors `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started`), `cancelBooking` (`no-booking`, `service-started`), `setAutoRepair` (10–90% in steps of 5). `tryStartService` runs after every command and catch-up event: a ship idle at its booked HQ and out of the shop starts the work (repair then upgrade, one shop stay), charged then — a player booking whenever credits cover it, an automatic one only above the spending floor — else it waits, logged once. A stay as long as a rest takes the pilot along (CR-023), so a rest due on arrival or already running overlaps it and ends with it. Auto-repair books (or adds a repair to a booking) when damage, wear or the dev tool takes an Independent crew's hull below the threshold. Logbook kind `service` (booked, started, finished, waiting-funds). Ship API: `booking` (items, level, HQ, by, estimate, status `booked`/`heading`/`in-shop`/`waiting-funds`/`cant-reach`), `autoRepair`, `repairRecommended`. Routes `bookService`, `cancelBooking`, `setAutoRepair` (manual decisions). Tests (`packages/engine/test/service.test.ts`): immediate start at the HQ with the charge, the upgrade and the log; start on arrival charged for the landed hull; rest on arrival overlapping, duty reset; a running rest overlapping; waiting for funds then starting; refusals; cancel; the automatic booking, starting or waiting below the floor; Manual crews only recommended; the threshold setting.
|
||||||
- [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time.
|
- [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time.
|
||||||
- [ ] Step 27.5 — Client: Service section and tags
|
- [x] **Step 27.5 — Client: Service section and tags.** `apps/web/src/components/Service.tsx`: the Service section in the ship's Upgrades sheet (the booking's items, HQ, estimate and status; Book service with a repair box, an upgrade picker (the next level of each) and an HQ picker defaulting to the nearest reachable (`defaultHq`, the sim's `nearestReachableHq`), with the estimate; Change and Cancel booking until the work starts; the auto-repair box and a 10–90% slider in steps of 5). `serviceTag` on the Fleet card and in the dispatch sheet ("Service booked: Engine Tuning 2 at Earth", "Repair recommended"); `serviceStatus` ("Heading to Earth for service", "In the shop at Earth (resting)", "Waiting for funds: Repair at Earth", "Can't reach Earth for service") leads an Independent pilot's status line. Tests (`apps/web/test/service.test.tsx`): booking with the estimate and the nearest HQ; a booking's items and status, Change and Cancel calling the engine; no change once started; the slider and the box; the Fleet tag; each status wording; the pilot line; the dispatch sheet tag. Screenshots: `docs/screenshots/27-5-*` (Repair recommended, the booking form, a booked ship's card and sheet; phone and desktop).
|
||||||
- [ ] Step 27.6 — Client: Logbook and summaries
|
- [ ] Step 27.6 — Client: Logbook and summaries
|
||||||
- [ ] Step 27.7 — Balance and regression
|
- [ ] Step 27.7 — Balance and regression
|
||||||
|
|
|
||||||