deadhead-space-freight-tycoon/apps/web/src/screens/ShipUpgrades.tsx

247 lines
9.4 KiB
TypeScript

import { content, upgradeMaxLevel, type ShipStat, type Upgrade } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import {
effectiveStats,
nextUpgradeLevel,
upgradeCost,
upgradeShopMs,
validateUpgrade,
type EffectiveStats,
type UpgradeFailure,
repairCost,
repairShopMs,
} from "@deadhead/sim";
import { useState } from "react";
import { useClock } from "../clock/clock";
import { formatCredits, formatDuration, formatMillionKm } from "../format";
import { shipModel } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
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). */
export const UPGRADE_REASONS: Record<UpgradeFailure, string> = {
"tutorial-active": "Finish the company runs first",
"not-upgradable": "Company ship",
"ship-not-idle": "In flight",
"ship-in-shop": "In the shop",
"not-at-hq": "Not at an HQ",
"upgrade-maxed": "Maxed",
"insufficient-funds": "Can't afford",
};
/** How each upgradable stat reads, with its unit. */
const STAT_LABELS: Record<ShipStat, { label: string; format: (s: EffectiveStats) => string }> = {
cruiseSpeedKmPerGameHour: {
label: "Speed",
format: (s) => `${formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h`,
},
hullRating: {
label: "Hull rating",
format: (s) => String(s.hullRating),
},
cargoCapacityTonnes: {
label: "Capacity",
format: (s) => `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`,
},
tankCapacityFu: {
label: "Tank",
format: (s) => `${Math.round(s.tankCapacityFu).toLocaleString("en-US")} fu`,
},
overheadGameHours: {
label: "Takeoff/landing",
format: (s) => `${s.overheadGameHours.toFixed(2)} h`,
},
handlingGameHoursPerTonne: {
label: "Handling",
format: (s) => `${(s.handlingGameHoursPerTonne * 60).toFixed(2)} min/t`,
},
};
/** "Speed 2.00M km/h → 2.20M km/h": a stat now and after the next level. */
export function upgradeEffect(ship: Ship, upgrade: Upgrade): string | null {
const model = shipModel(ship.modelId);
const next = nextUpgradeLevel(upgrade, model.id, ship.upgrades);
if (next === null) return null;
const { label, format } = STAT_LABELS[upgrade.stat];
const before = effectiveStats(model, ship.upgrades, content);
const after = effectiveStats(model, { ...ship.upgrades, [upgrade.id]: next }, content);
return `${label} ${format(before)} → ${format(after)}`;
}
/** The hull and its repair (decision 0.5.148): at an HQ, on the ground, below 100%. */
function HullRepair({ shipId, atHq }: { shipId: string; atHq: boolean }) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const repair = useGameAction("repairShip");
const ship = state.ships.find((s) => s.id === shipId)!;
const pct = ship.hullConditionPct;
const model = shipModel(ship.modelId);
const cost = repairCost(model, pct, content.config);
const inShop = ship.inShopUntil !== null && ship.inShopUntil > now;
const can = atHq && ship.status === "idle" && !inShop && pct < 100;
return (
<section
aria-label="Hull"
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">Hull</span>
<span className="tabular-nums">{Math.round(pct)}%</span>
</div>
{pct >= 100 ? (
<p className="mt-1 text-ink-400">In perfect shape.</p>
) : can ? (
<Button
size="sm"
className="mt-2"
disabled={repair.isPending || state.company.credits < cost}
onClick={() => repair.mutate({ shipId: ship.id })}
>
Repair to 100% · {formatCredits(cost)} ·{" "}
{formatDuration(repairShopMs(pct, content.config))}
</Button>
) : (
<p className="mt-1 text-ink-400">
{ship.repairing
? "Under repair."
: !atHq
? "Repairs are done at one of your HQs."
: "The ship must be on the ground and out of the shop."}
</p>
)}
{can && state.company.credits < cost && (
<p className="mt-1 text-ink-400">Not enough credits for the repair yet.</p>
)}
{repair.isError && (
<p role="alert" className="mt-2 text-rose-300">
{errorMessage(repair.error)}
</p>
)}
</section>
);
}
/** The ship's detail view: its upgrades, the next level of each, and starting one (CR-009). */
export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: () => void }) {
const state = useLoadedPlayer();
// The live ship, so the sheet shows the new level as soon as state refreshes.
const ship = state.ships.find((s) => s.id === shipId)!;
const now = useClock((s) => s.now);
const start = useGameAction("startUpgrade");
const [confirming, setConfirming] = useState<Upgrade | null>(null);
const model = shipModel(ship.modelId);
const atHq = ship.locationId !== null && state.bays.some((b) => b.locationId === ship.locationId);
return (
<Sheet
titleId="upgrades-title"
eyebrow={model.name}
title={ship.name}
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} />
{/* 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 text-ink-300">Upgrades</h3>
<ul className="mt-2 space-y-2">
{content.upgrades.map((upgrade) => {
const level = ship.upgrades[upgrade.id] ?? 0;
const next = nextUpgradeLevel(upgrade, model.id, ship.upgrades);
const check = validateUpgrade(
{
ship: {
id: ship.id,
modelId: ship.modelId,
locationId: ship.locationId ?? "",
inFlight: ship.status === "in-flight",
upgrades: ship.upgrades,
inShopUntil: ship.inShopUntil,
},
upgrade,
atHq,
tutorialActive: state.tutorial !== null,
credits: state.company.credits,
now,
},
content,
);
const max = upgradeMaxLevel(upgrade, model.id);
return (
<li
key={upgrade.id}
aria-label={upgrade.name}
className="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">{upgrade.name}</span>
<span className="text-ink-400">
Level {level}/{max}
</span>
</div>
{next !== null && (
<div className="mt-1 text-ink-200">{upgradeEffect(ship, upgrade)}</div>
)}
{next !== null && (
<div className="mt-1 text-ink-400 tabular-nums">
{formatCredits(upgradeCost(model, next, content.config))} ·{" "}
{formatDuration(upgradeShopMs(next, content.config))} in the shop
</div>
)}
<div className="mt-2 flex items-center justify-between gap-2">
{check.ok ? (
<Button size="sm" onClick={() => setConfirming(upgrade)}>
Upgrade to level {next}
</Button>
) : (
<Pill tone={check.reason === "upgrade-maxed" ? "emerald" : "muted"}>
{UPGRADE_REASONS[check.reason]}
</Pill>
)}
</div>
{confirming?.id === upgrade.id && check.ok && (
<div
role="group"
aria-label={`Start ${upgrade.name} ${check.level}?`}
className="mt-2 rounded-lg bg-space-950/70 p-2"
>
<p className="text-ink-200">
{upgrade.name} {check.level} costs {formatCredits(check.cost)}. {ship.name}{" "}
can&apos;t fly for {formatDuration(check.shopUntil - now)} while it&apos;s in
the shop.
</p>
<div className="mt-2 flex gap-2">
<Button
size="sm"
disabled={start.isPending}
onClick={() =>
start.mutate(
{ shipId: ship.id, upgradeId: upgrade.id },
{ onSettled: () => setConfirming(null) },
)
}
>
Start upgrade
</Button>
<Button size="sm" variant="secondary" onClick={() => setConfirming(null)}>
Cancel
</Button>
</div>
</div>
)}
</li>
);
})}
</ul>
{start.isError && (
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(start.error)}
</p>
)}
<Button variant="secondary" block onClick={onClose} className="mt-4">
Close
</Button>
</Sheet>
);
}