185 lines
7.4 KiB
TypeScript
185 lines
7.4 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 { formatCredits, formatDuration, locationName } from "../format";
|
|
import { goodName } from "../components/Goods";
|
|
import { distanceAt, tripRealMs } from "../game";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
import { usePreferences } from "../state/preferences";
|
|
import { RouteStrip } from "../components/RouteStrip";
|
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
|
|
|
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
|
|
|
/** The id of the tutorial intro card in dismissed tips. */
|
|
export const INTRO_TIP = "tutorial-intro";
|
|
|
|
/** The company board during the tutorial (Step 9.3). */
|
|
export function TutorialBoard() {
|
|
const api = useApi();
|
|
const state = useLoadedPlayer();
|
|
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;
|
|
// A one-time tip: dismissible, and back with "Show tips again" (CR-011).
|
|
const introDismissed = usePreferences((p) => p.dismissedTips.includes(INTRO_TIP));
|
|
const dismissTip = usePreferences((p) => p.dismissTip);
|
|
|
|
if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
|
|
if (board.isError) {
|
|
return (
|
|
<div role="alert" className="flex items-center gap-3 p-4 text-rose-300">
|
|
Could not load contracts.{" "}
|
|
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{firstRun && !introDismissed && (
|
|
<Panel aria-label="Intro" tone="amber" className="flex items-center gap-3 text-sm">
|
|
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
|
|
<div>
|
|
<p className="font-display text-base font-bold text-ink-100">
|
|
{content.tutorialCompanyName} needs a pilot.
|
|
</p>
|
|
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Dismiss tip"
|
|
onClick={() => dismissTip(INTRO_TIP)}
|
|
className="ml-auto self-start rounded-md p-1 text-ink-400 hover:text-ink-100"
|
|
>
|
|
<Icon name="close" className="size-4" />
|
|
</button>
|
|
</Panel>
|
|
)}
|
|
<p className="text-sm text-ink-300">
|
|
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
|
</p>
|
|
{runInFlight && (
|
|
<p className="flex items-center gap-2 rounded-lg border border-cyan-400/40 bg-cyan-950/50 px-3 py-2 text-sm text-ink-100">
|
|
<Icon name="send" className="size-4 shrink-0 text-ink-300" />
|
|
Your run is in flight. Wait for it to land.
|
|
</p>
|
|
)}
|
|
{fly.isError && (
|
|
<p role="alert" className="text-sm text-rose-300">
|
|
{errorMessage(fly.error)}
|
|
</p>
|
|
)}
|
|
{board.data.contracts.length === 0 && (
|
|
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
|
|
)}
|
|
<ul className="grid gap-3 @2xl:grid-cols-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,
|
|
);
|
|
const xp = deliveryXp(
|
|
distanceAt(contract.originId, contract.destinationId),
|
|
contract.tier,
|
|
content.config,
|
|
);
|
|
return (
|
|
<Panel
|
|
as="li"
|
|
key={contract.id}
|
|
data-suggested={suggested || undefined}
|
|
data-scripted={scripted ? true : undefined}
|
|
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
|
|
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="font-display font-bold">
|
|
{scripted ? (
|
|
<span className="text-fuchsia-200">{scripted.name}</span>
|
|
) : (
|
|
<>
|
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
|
</>
|
|
)}
|
|
{suggested && (
|
|
<Pill tone="amber" icon="star" className="ml-2 align-middle">
|
|
Suggested
|
|
</Pill>
|
|
)}
|
|
</span>
|
|
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
|
|
<Icon name="cargo" className="size-4 text-ink-400" />
|
|
{goodName(contract)} · {contract.massTonnes} t
|
|
</span>
|
|
</div>
|
|
{scripted && (
|
|
<p className="text-xs text-fuchsia-200">
|
|
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
|
double pay, no questions asked
|
|
</p>
|
|
)}
|
|
<RouteStrip
|
|
originId={contract.originId}
|
|
destinationId={contract.destinationId}
|
|
size="sm"
|
|
/>
|
|
<div className="mt-auto flex items-end justify-between gap-2 border-t border-glass-line pt-2">
|
|
<dl className="grid flex-1 grid-cols-3 gap-2 text-sm">
|
|
<div>
|
|
<dt className="font-display text-xs text-ink-400">Pay</dt>
|
|
<dd
|
|
aria-label="Pay"
|
|
className="font-display text-base font-bold text-emerald-300 tabular-nums"
|
|
>
|
|
{formatCredits(contract.payout)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-display text-xs text-ink-400">Time</dt>
|
|
<dd aria-label="Trip time" className="font-display text-base tabular-nums">
|
|
{formatDuration(tripMs)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-display text-xs text-ink-400">XP</dt>
|
|
<dd
|
|
aria-label="XP"
|
|
className="font-display text-base text-violet-300 tabular-nums"
|
|
>
|
|
+{xp}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<Button
|
|
size="sm"
|
|
disabled={runInFlight || fly.isPending}
|
|
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
|
className="shrink-0"
|
|
>
|
|
Fly it
|
|
</Button>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|