249 lines
9.5 KiB
TypeScript
249 lines
9.5 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-glass-line bg-white/5 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
|
|
variant="secondary"
|
|
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-glass-line bg-white/5 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-black/25 p-2"
|
|
>
|
|
<p className="text-ink-200">
|
|
{upgrade.name} {check.level} costs {formatCredits(check.cost)}. {ship.name}{" "}
|
|
can't fly for {formatDuration(check.shopUntil - now)} while it's in
|
|
the shop.
|
|
</p>
|
|
<div className="mt-2 flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
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>
|
|
);
|
|
}
|