245 lines
9.1 KiB
TypeScript
245 lines
9.1 KiB
TypeScript
import { content, type ShipModel } from "@deadhead/content";
|
|
import { fullTankReach } from "@deadhead/sim";
|
|
import { useState } from "react";
|
|
import { formatCredits, formatMillionKm, locationName } from "../format";
|
|
import { shipReach, shipStatsOf } from "../game";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
|
|
|
|
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
|
|
|
|
const MODELS = content.purchasableShipModels;
|
|
/** How far a full tank flies from Earth, in million km (decision 0.5.95). */
|
|
const reachFromEarth = (model: ShipModel) =>
|
|
fullTankReach(model, content.location("earth")!, content.config);
|
|
const best = {
|
|
speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)),
|
|
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)),
|
|
range: Math.max(...MODELS.map(reachFromEarth)),
|
|
};
|
|
/** Reach spans 5 to 1,250M km, so its bar uses a log scale to stay readable. */
|
|
const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range);
|
|
|
|
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
|
|
export function Market() {
|
|
const state = useLoadedPlayer();
|
|
const buy = useGameAction("buyShip");
|
|
const [buying, setBuying] = useState<ShipModel | null>(null);
|
|
|
|
return (
|
|
<Screen>
|
|
<ScreenHeader
|
|
title="Shipyard"
|
|
icon="market"
|
|
subtitle="New ships are delivered with no pilot to one of your HQs."
|
|
/>
|
|
{buy.isError && (
|
|
<p role="alert" className="text-sm text-rose-300">
|
|
{errorMessage(buy.error)}
|
|
</p>
|
|
)}
|
|
{buy.isSuccess && (
|
|
<p
|
|
role="status"
|
|
className="flex items-center gap-2 rounded-lg border border-emerald-400/40 bg-emerald-950/50 px-3 py-2 text-sm text-emerald-200"
|
|
>
|
|
<Icon name="check" className="size-4 shrink-0" />
|
|
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
|
</p>
|
|
)}
|
|
<ul className="grid gap-4 @2xl:grid-cols-3">
|
|
{MODELS.map((model) => {
|
|
const affordable = state.company.credits >= model.price;
|
|
return (
|
|
<Panel as="li" key={model.id} className="flex flex-col text-sm">
|
|
<div className="relative -mt-1 grid h-32 place-items-center">
|
|
<Art
|
|
sprite={model.sprite}
|
|
className="relative size-28 drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex items-baseline justify-between gap-2">
|
|
<span className="text-lg font-semibold">{model.name}</span>
|
|
<span
|
|
className={`font-semibold tabular-nums ${affordable ? "text-ink-50" : "text-ink-300"}`}
|
|
>
|
|
{formatCredits(model.price)}
|
|
</span>
|
|
</div>
|
|
<p className="text-ink-300 italic">{model.role}</p>
|
|
<dl className="mt-3 space-y-2 text-ink-200">
|
|
<Spec
|
|
icon="speed"
|
|
label="Speed"
|
|
value={`${formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h`}
|
|
share={model.cruiseSpeedKmPerGameHour / best.speed}
|
|
/>
|
|
<Spec
|
|
icon="cargo"
|
|
label="Capacity"
|
|
value={`${model.cargoCapacityTonnes} t`}
|
|
share={model.cargoCapacityTonnes / best.capacity}
|
|
/>
|
|
<Spec
|
|
icon="range"
|
|
label="Reach"
|
|
value={formatMillionKm(reachFromEarth(model))}
|
|
share={rangeShare(reachFromEarth(model))}
|
|
/>
|
|
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
|
|
</dl>
|
|
<Reach model={model} />
|
|
<OwnedUpgraded modelId={model.id} />
|
|
<Button
|
|
aria-label={`Buy ${model.name}`}
|
|
variant="secondary"
|
|
block
|
|
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
|
onClick={() => setBuying(model)}
|
|
className="mt-4"
|
|
>
|
|
{affordable ? "Buy" : "Not enough credits"}
|
|
</Button>
|
|
</Panel>
|
|
);
|
|
})}
|
|
</ul>
|
|
{buying && (
|
|
<DeliveryPicker
|
|
model={buying}
|
|
pending={buy.isPending}
|
|
onBuy={(bayId) =>
|
|
buy.mutate({ modelId: buying.id, bayId }, { onSettled: () => setBuying(null) })
|
|
}
|
|
onClose={() => setBuying(null)}
|
|
/>
|
|
)}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
/** One spec line: label and value (dt directly before dd), with a bar relative to the best model. */
|
|
function Spec(props: { icon: IconName; label: string; value: string; share?: number }) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<dt className="flex items-center gap-1.5 font-display text-xs text-ink-400">
|
|
<Icon name={props.icon} className="size-3.5" />
|
|
{props.label}
|
|
</dt>
|
|
<dd className="font-display font-semibold tabular-nums">{props.value}</dd>
|
|
</div>
|
|
{props.share !== undefined && (
|
|
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-black/25">
|
|
<div
|
|
className="h-full rounded-full bg-ink-300"
|
|
style={{ width: `${Math.max(4, props.share * 100)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const names = (ids: string[]) => ids.map(locationName).join(", ");
|
|
|
|
/**
|
|
* Where this model can fly from the player's HQs (CR-012), e.g.
|
|
* "From Earth: Moon, Mars · By hopping: Ceres". Computed with the sim.
|
|
*/
|
|
function Reach({ model }: { model: ShipModel }) {
|
|
const state = useLoadedPlayer();
|
|
const origins = [...new Set(state.bays.map((b) => b.locationId))];
|
|
const reach = shipReach(model, origins);
|
|
const parts = [
|
|
...reach.fromOrigins.map(
|
|
(leg) =>
|
|
`From ${locationName(leg.originId)}: ${leg.destinationIds.length > 0 ? names(leg.destinationIds) : "nowhere"}`,
|
|
),
|
|
...(reach.byHopping.length > 0 ? [`By hopping: ${names(reach.byHopping)}`] : []),
|
|
];
|
|
if (parts.length === 0) return null;
|
|
return (
|
|
<p aria-label="Reach" className="mt-3 text-xs text-ink-300">
|
|
<span className="font-display text-ink-400">Reach </span>
|
|
{parts.join(" · ")}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */
|
|
function DeliveryPicker(props: {
|
|
model: ShipModel;
|
|
pending: boolean;
|
|
onBuy: (bayId: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const state = useLoadedPlayer();
|
|
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
|
|
|
|
return (
|
|
<Sheet
|
|
titleId="delivery-title"
|
|
eyebrow="Delivery"
|
|
title="Deliver to which HQ?"
|
|
subtitle={`Your ${props.model.name} will wait there, idle and without a pilot.`}
|
|
>
|
|
<fieldset className="mt-4 space-y-2">
|
|
<legend className="sr-only">HQ</legend>
|
|
{state.bays.map((bay) => {
|
|
const sprite = content.location(bay.locationId)?.sprite;
|
|
return (
|
|
<label
|
|
key={bay.id}
|
|
className="flex cursor-pointer items-center gap-3 rounded-xl border border-glass-line bg-white/5 p-3 text-sm transition has-checked:border-amber-400 has-checked:bg-amber-500/10 has-checked:shadow-glow-amber"
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="hq"
|
|
checked={bayId === bay.id}
|
|
onChange={() => setBayId(bay.id)}
|
|
className="size-4 accent-action"
|
|
/>
|
|
{sprite && <Art sprite={sprite} className="size-8" />}
|
|
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</fieldset>
|
|
<Button
|
|
block
|
|
disabled={bayId === null || props.pending}
|
|
onClick={() => bayId && props.onBuy(bayId)}
|
|
className="mt-4"
|
|
>
|
|
Buy for {formatCredits(props.model.price)}
|
|
</Button>
|
|
<Button variant="secondary" block onClick={props.onClose} className="mt-2">
|
|
Cancel
|
|
</Button>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
/** The player's upgraded ships of a model, with their effective stats, to compare (CR-009). */
|
|
function OwnedUpgraded({ modelId }: { modelId: string }) {
|
|
const state = useLoadedPlayer();
|
|
const upgraded = state.ships.filter(
|
|
(s) => s.modelId === modelId && Object.keys(s.upgrades).length > 0,
|
|
);
|
|
if (upgraded.length === 0) return null;
|
|
return (
|
|
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-ink-100">
|
|
{upgraded.map((ship) => {
|
|
const s = shipStatsOf(ship);
|
|
return (
|
|
<li key={ship.id}>
|
|
Your {ship.name}: {formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h ·{" "}
|
|
{Math.round(s.cargoCapacityTonnes * 10) / 10} t · {formatMillionKm(reachFromEarth(s))}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|