158 lines
6.0 KiB
TypeScript
158 lines
6.0 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import { deliveryXp, loanerModel } from "@deadhead/sim";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useApi } from "../api/context";
|
|
import { useClock } from "../clock/clock";
|
|
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
|
|
import { distanceAt, tripRealMs } from "../game";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
|
|
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
|
|
|
/** The company board during the tutorial (Step 9.3). */
|
|
export function TutorialBoard() {
|
|
const api = useApi();
|
|
const state = useLoadedPlayer();
|
|
const now = useClock((s) => s.now);
|
|
const board = useQuery({
|
|
queryKey: TUTORIAL_BOARD_KEY,
|
|
queryFn: () => api.call("getTutorialBoard", {}),
|
|
});
|
|
const fly = useGameAction("createDispatch");
|
|
const runInFlight = state.tutorial?.runInFlight ?? false;
|
|
const loaner = loanerModel(content);
|
|
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
|
|
|
|
if (board.isPending) return <p className="p-4 text-slate-400">Loading contracts…</p>;
|
|
if (board.isError) {
|
|
return (
|
|
<div role="alert" className="p-4 text-red-400">
|
|
Could not load contracts.{" "}
|
|
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{firstRun && (
|
|
<div
|
|
aria-label="Intro"
|
|
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
|
>
|
|
<p className="font-semibold text-amber-200">Halvorsen Lunar Freight needs a pilot.</p>
|
|
<p className="text-slate-300">Their ship, your hands. Get the crates to the Moon.</p>
|
|
</div>
|
|
)}
|
|
<p className="text-sm text-slate-400">
|
|
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
|
</p>
|
|
{runInFlight && (
|
|
<p className="rounded-lg bg-slate-800 p-2 text-sm">
|
|
Your run is in flight. Wait for it to land.
|
|
</p>
|
|
)}
|
|
{fly.isError && (
|
|
<p role="alert" className="text-sm text-red-400">
|
|
{errorMessage(fly.error)}
|
|
</p>
|
|
)}
|
|
{board.data.contracts.length === 0 && (
|
|
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
|
|
)}
|
|
<ul className="space-y-2">
|
|
{board.data.contracts.map((contract, index) => {
|
|
// On the first run, suggest the lightest (fastest) contract.
|
|
const suggested = firstRun && index === 0;
|
|
const scripted = contract.scriptedId
|
|
? content.scriptedTutorialContracts.find((s) => s.id === contract.scriptedId)
|
|
: undefined;
|
|
const tripMs = tripRealMs(
|
|
loaner,
|
|
contract.originId,
|
|
contract.destinationId,
|
|
contract.massTonnes,
|
|
now,
|
|
);
|
|
const xp = deliveryXp(
|
|
distanceAt(contract.originId, contract.destinationId, now),
|
|
contract.tier,
|
|
content.config,
|
|
);
|
|
const border = scripted
|
|
? "border-fuchsia-500 bg-fuchsia-500/10"
|
|
: suggested
|
|
? "border-amber-400 ring-1 ring-amber-400"
|
|
: "border-slate-800";
|
|
return (
|
|
<li
|
|
key={contract.id}
|
|
data-suggested={suggested || undefined}
|
|
data-scripted={scripted ? true : undefined}
|
|
className={`rounded-lg border p-3 ${border}`}
|
|
>
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="font-medium">
|
|
{scripted ? (
|
|
<span className="text-fuchsia-300">{scripted.name}</span>
|
|
) : (
|
|
<>
|
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
|
</>
|
|
)}
|
|
{suggested && (
|
|
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
|
Suggested
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-sm text-slate-400">
|
|
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
|
|
</span>
|
|
</div>
|
|
{scripted && (
|
|
<p className="mt-1 text-xs text-fuchsia-200">
|
|
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
|
double pay, no questions asked
|
|
</p>
|
|
)}
|
|
<div className="mt-2 flex items-center justify-between gap-2">
|
|
<dl className="grid flex-1 grid-cols-3 text-sm">
|
|
<div>
|
|
<dt className="text-xs text-slate-400">Pay</dt>
|
|
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
|
|
{formatCredits(contract.payout)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-slate-400">Time</dt>
|
|
<dd aria-label="Trip time" className="tabular-nums">
|
|
{formatDuration(tripMs)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-slate-400">XP</dt>
|
|
<dd aria-label="XP" className="tabular-nums">
|
|
+{xp}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<button
|
|
type="button"
|
|
disabled={runInFlight || fly.isPending}
|
|
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
|
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-slate-950 disabled:opacity-40"
|
|
>
|
|
Fly it
|
|
</button>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|