deadhead-space-freight-tycoon/apps/web/src/components/Fuel.tsx

259 lines
9.5 KiB
TypeScript

import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { emergencyFuel, fuelPriceAt, refuel } from "@deadhead/sim";
import { useState } from "react";
import { formatCredits, locationName } from "../format";
import { simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Meter, Sheet } from "../ui";
/** Fuel in whole fu, e.g. "1,170 fu" (CR-015). */
export function formatFu(fu: number): string {
return `${Math.round(fu).toLocaleString("en-US")} fu`;
}
/** Credits per fu at a location (decision 0.5.96). */
export function fuelPriceOf(locationId: string): number {
return fuelPriceAt(content.location(locationId)!, content.config);
}
/** "16 cr/fu"; prices are whole credits in the base game but may not be. */
export function formatFuelPrice(price: number): string {
return `${Number.isInteger(price) ? price : price.toFixed(1)} cr/fu`;
}
/** The location (or locations) selling the cheapest fuel (decision 0.5.96). */
export function cheapestFuelIds(): Set<string> {
const selling = content.locations.filter((l) => l.sellsFuel);
const lowest = Math.min(...selling.map((l) => l.fuelPrice));
return new Set(selling.filter((l) => l.fuelPrice === lowest).map((l) => l.id));
}
/** A location's fuel price; the cheapest in the system is highlighted (Step 15.7). */
export function FuelPrice({ locationId }: { locationId: string }) {
const location = content.location(locationId);
if (!location?.sellsFuel) return <span className="text-ink-400">None sold</span>;
const cheapest = cheapestFuelIds().has(locationId);
return (
<span className={cheapest ? "font-semibold text-emerald-300" : undefined}>
{formatFuelPrice(fuelPriceOf(locationId))}
{cheapest && " · cheapest"}
</span>
);
}
const POLICY_LABELS = { auto: "Auto top-up", manual: "Manual refuel" } as const;
/** A ship's fuel: fu, percent and refuel policy (Step 15.7). */
export function FuelGauge({ ship }: { ship: Ship }) {
const capacity = ship.stats.tankCapacityFu;
const percent = capacity > 0 ? Math.round((ship.fuelFu / capacity) * 100) : 0;
return (
<div>
<Meter
label={`${ship.name} fuel`}
value={ship.fuelFu}
max={capacity}
tone={percent < 25 ? "rose" : "default"}
size="sm"
/>
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
<span aria-label={`${ship.name} fuel level`} className="tabular-nums">
{formatFu(ship.fuelFu)} · {percent}%
</span>
<span>{POLICY_LABELS[ship.refuelPolicy]}</span>
</div>
</div>
);
}
/**
* "Refuel here first", priced: how much fuel it buys and what it costs, with
* the same sim rules the engine charges by; `affordable` is false when the
* credits don't cover even what the leg needs.
*/
export function refuelQuote(
ship: Ship,
neededFu: number,
credits: number,
): {
request: { shipId: string; amountFu?: number };
boughtFu: number;
cost: number;
affordable: boolean;
} {
const request = refuelForLeg(ship, neededFu, credits);
const space = ship.stats.tankCapacityFu - ship.fuelFu;
const bought = refuel(
{ fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
ship.stats.tankCapacityFu,
request.amountFu ?? space,
fuelPriceOf(ship.locationId ?? ""),
);
const boughtFu = bought.tank.fuelFu - ship.fuelFu;
return { request, boughtFu, cost: bought.cost, affordable: bought.cost <= credits };
}
/**
* What "Refuel here first" buys (Step 15.7): a full tank when the credits
* cover it, otherwise just enough for the leg.
*/
export function refuelForLeg(
ship: Ship,
neededFu: number,
credits: number,
): { shipId: string; amountFu?: number } {
const price = fuelPriceOf(ship.locationId ?? "");
const space = ship.stats.tankCapacityFu - ship.fuelFu;
if (credits >= Math.round(space * price)) return { shipId: ship.id };
return { shipId: ship.id, amountFu: Math.max(0, neededFu - ship.fuelFu) };
}
/** The emergency fuel offer for a ship, or null (decision 0.5.99). */
export function emergencyOffer(state: PlayerState, ship: Ship) {
if (ship.status !== "idle" || !ship.locationId) return null;
return emergencyFuel(
{
ship: simShip(ship),
credits: state.company.credits,
discoveredIds: new Set(state.locations.filter((l) => l.discovered).map((l) => l.id)),
},
content,
);
}
/** A ship's fuel: refuel here (fill or an amount), refuel policy, and emergency fuel (Step 15.7). */
export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const refuel = useGameAction("refuelShip");
const policy = useGameAction("setRefuelPolicy");
const emergency = useGameAction("orderEmergencyFuel");
const [amount, setAmount] = useState("");
const ship = state.ships.find((s) => s.id === shipId);
if (!ship) return null;
const here = ship.status === "idle" && ship.locationId ? content.location(ship.locationId) : null;
const price = here ? fuelPriceOf(here.id) : 0;
const space = Math.max(0, ship.stats.tankCapacityFu - ship.fuelFu);
const fillCost = Math.round(space * price);
const offer = emergencyOffer(state, ship);
const amountFu = Number(amount);
const error = refuel.error ?? policy.error ?? emergency.error;
return (
<Sheet titleId="fuel-title" eyebrow="Fuel" title={ship.name}>
<div className="mt-3">
<FuelGauge ship={ship} />
<p className="mt-1 text-xs text-ink-400">
Average price paid: {formatFuelPrice(Math.round(ship.fuelAvgPrice * 10) / 10)}
</p>
</div>
{!here ? (
<p className="mt-3 text-sm text-ink-400">Refuel when the ship has landed.</p>
) : !here.sellsFuel ? (
<p className="mt-3 text-sm text-ink-400">{here.name} sells no fuel.</p>
) : (
<section aria-label="Refuel" className="mt-3 space-y-2 text-sm">
<p className="text-ink-200">
Fuel at {here.name}: {formatFuelPrice(price)}
</p>
<Button
block
disabled={space <= 0 || refuel.isPending || state.company.credits < fillCost}
onClick={() => refuel.mutate({ shipId: ship.id })}
>
{space <= 0 ? "Tank full" : `Fill (${formatFu(space)}, ${formatCredits(fillCost)})`}
</Button>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (amountFu > 0) refuel.mutate({ shipId: ship.id, amountFu });
}}
>
<label className="flex-1">
<span className="sr-only">Fuel to buy (fu)</span>
<input
type="number"
min={1}
step={1}
max={Math.floor(space)}
inputMode="numeric"
placeholder="Amount (fu)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-ink-50 focus:border-action focus:outline-none"
/>
</label>
<Button
type="submit"
variant="secondary"
disabled={!(amountFu > 0) || space <= 0 || refuel.isPending}
>
Buy
{amountFu > 0
? ` (${formatCredits(Math.round(Math.min(amountFu, space) * price))})`
: ""}
</Button>
</form>
</section>
)}
<fieldset className="mt-4 text-sm">
<legend className="text-ink-400">Refuel policy</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-glass-line">
{(["manual", "auto"] as const).map((value) => (
<label
key={value}
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
>
<input
type="radio"
name={`policy-${ship.id}`}
value={value}
checked={ship.refuelPolicy === value}
onChange={() => policy.mutate({ shipId: ship.id, policy: value })}
className="sr-only"
/>
{POLICY_LABELS[value]}
</label>
))}
</div>
<p className="mt-1 text-xs text-ink-400">
{ship.refuelPolicy === "auto"
? "Fills the tank on every landing, as far as your credits go."
: "The default: never buys fuel on its own. Refuel here, or with Refuel here first when a job needs it."}
</p>
</fieldset>
{offer && (
<div className="mt-4 rounded-xl border border-rose-400/40 bg-rose-950/30 p-3 text-sm">
<p className="text-rose-200">
{ship.name} can&apos;t reach anywhere from {locationName(ship.locationId ?? "")} and you
can&apos;t afford the fuel.
</p>
<Button
block
variant="danger"
className="mt-2"
disabled={emergency.isPending}
onClick={() => emergency.mutate({ shipId: ship.id })}
>
Emergency fuel (added to loan): {formatFu(offer.amountFu)}, {formatCredits(offer.cost)}
</Button>
</div>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(error)}
</p>
)}
<Button variant="secondary" block className="mt-4" onClick={onClose}>
Done
</Button>
</Sheet>
);
}