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

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-amber-200">
{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-cyan-100">
<Icon name="send" className="size-4 shrink-0 text-cyan-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-space-700/70 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>
);
}