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

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