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

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