259 lines
9.5 KiB
TypeScript
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't reach anywhere from {locationName(ship.locationId ?? "")} and you
|
|
can'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>
|
|
);
|
|
}
|