Step 29.5: Bridge leads with your ship and Needs you
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
38b636014f
commit
9aada41e8f
|
|
@ -15,8 +15,8 @@ export function GoalsCard() {
|
|||
|
||||
return (
|
||||
<Panel as="section" id="goals" aria-labelledby="goals-title">
|
||||
<h2 id="goals-title" className="flex items-center gap-2 font-display text-lg font-bold">
|
||||
<Icon name="target" className="size-5 text-amber-300" />
|
||||
<h2 id="goals-title" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Icon name="target" className="size-4 text-ink-300" />
|
||||
Goals
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
|
|
@ -28,23 +28,21 @@ export function GoalsCard() {
|
|||
<li
|
||||
key={goal.id}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
ready
|
||||
? "border-emerald-400/50 bg-emerald-500/10 shadow-glow-emerald"
|
||||
: "border-space-700 bg-space-950/40"
|
||||
ready ? "border-earned/45 bg-earned/8" : "border-glass-line"
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
|
||||
ready ? "bg-emerald-400 text-space-950 ring-emerald-300" : "ring-space-500"
|
||||
ready ? "bg-earned text-space-950 ring-earned" : "ring-ink-400"
|
||||
}`}
|
||||
>
|
||||
{ready && <Icon name="check" className="size-3.5" />}
|
||||
</span>
|
||||
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
|
||||
<span className={"text-ink-100"}>
|
||||
{details.title}
|
||||
<span className="ml-2 font-display text-xs font-semibold whitespace-nowrap text-amber-300/90">
|
||||
<span className="ml-2 text-xs font-semibold whitespace-nowrap text-earned">
|
||||
+{formatCredits(details.reward)}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -55,7 +53,7 @@ export function GoalsCard() {
|
|||
size="sm"
|
||||
disabled={claim.isPending}
|
||||
onClick={() => claim.mutate({ goalId: goal.id })}
|
||||
className="shrink-0 animate-glow"
|
||||
className="shrink-0"
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ export function DispatchOfficePanel() {
|
|||
Dispatch Office
|
||||
</p>
|
||||
<p className="mt-1 text-ink-300">
|
||||
Your hired pilots can run jobs on their own. Set them to Independent on{" "}
|
||||
<Link to="/pilots" className="font-semibold text-amber-300 underline">
|
||||
Pilots
|
||||
Your hired pilots can run jobs on their own. Set them to Independent in{" "}
|
||||
<Link to="/pilots" className="font-semibold underline">
|
||||
Crew
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
@ -37,13 +37,15 @@ export function DispatchOfficePanel() {
|
|||
);
|
||||
}
|
||||
return (
|
||||
<Panel tone="amber" className="text-sm" aria-label="Dispatch Office">
|
||||
<p className="font-display font-semibold text-amber-100">Dispatch Office</p>
|
||||
<Panel className="text-sm" aria-label="Dispatch Office">
|
||||
<p className="font-semibold">Dispatch Office</p>
|
||||
<p className="mt-1 text-ink-300">
|
||||
Lets your hired pilots run jobs on their own, so the company keeps earning while you're
|
||||
away.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={state.company.credits < price || buy.isPending}
|
||||
onClick={() => buy.mutate({})}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,8 @@ export function LoanCard() {
|
|||
return (
|
||||
<Panel>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2>
|
||||
<span
|
||||
aria-label="Loan balance"
|
||||
className="font-display font-semibold text-amber-200 tabular-nums"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Loan from Halvorsen</h2>
|
||||
<span aria-label="Loan balance" className="font-semibold tabular-nums">
|
||||
{formatCredits(loanBalance)} left
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -39,9 +36,7 @@ export function LoanCard() {
|
|||
until it's paid off.
|
||||
</p>
|
||||
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
|
||||
<p className="mt-1 text-right font-display text-xs text-emerald-300/80">
|
||||
{paidOff}% paid off
|
||||
</p>
|
||||
<p className="mt-1 text-right text-xs text-ink-300">{paidOff}% paid off</p>
|
||||
|
||||
<form
|
||||
className="mt-2 flex items-center gap-2"
|
||||
|
|
@ -59,9 +54,13 @@ export function LoanCard() {
|
|||
placeholder="Amount"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="min-h-11 min-w-0 flex-1 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display tabular-nums placeholder:text-ink-500 focus:border-amber-400 focus:outline-none"
|
||||
className="min-h-11 min-w-0 flex-1 rounded-xl border border-glass-line bg-black/25 px-3 tabular-nums placeholder:text-ink-400 focus:border-action focus:outline-none"
|
||||
/>
|
||||
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={input === "" || problem !== null || repay.isPending}
|
||||
>
|
||||
Pay down
|
||||
</Button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { Link } from "react-router";
|
||||
import { formatClock, formatCredits, locationName } from "../format";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { Icon } from "../ui";
|
||||
import { readyShips } from "../game";
|
||||
import type { ScreenPath } from "../routes";
|
||||
import { Countdown } from "./Countdown";
|
||||
import { useRadioLater } from "./RadioCalls";
|
||||
|
||||
/** One thing waiting on the player, and the tab whose badge counts it (decision 0.5.168). */
|
||||
export type NeedsYouItem =
|
||||
| {
|
||||
kind: "radio";
|
||||
tab: ScreenPath;
|
||||
count: 1;
|
||||
dispatchId: string;
|
||||
eventId: string;
|
||||
pilotId: string;
|
||||
endsAt: number;
|
||||
}
|
||||
| { kind: "ready"; tab: ScreenPath; count: 1; name: string; locationId: string | null }
|
||||
| { kind: "skills"; tab: ScreenPath; count: number }
|
||||
| { kind: "goal"; tab: ScreenPath; count: 1; goalId: string };
|
||||
|
||||
/** Everything waiting on the player, most urgent first. */
|
||||
export function needsYou(state: PlayerState): NeedsYouItem[] {
|
||||
const items: NeedsYouItem[] = state.radioCalls.map((call) => ({
|
||||
kind: "radio",
|
||||
tab: "/",
|
||||
count: 1,
|
||||
dispatchId: call.dispatchId,
|
||||
eventId: call.eventId,
|
||||
pilotId: call.pilotId,
|
||||
endsAt: call.windowEndsAt,
|
||||
}));
|
||||
if (state.tutorial) {
|
||||
// The company loaner isn't in the ships list.
|
||||
if (!state.tutorial.runInFlight) {
|
||||
items.push({
|
||||
kind: "ready",
|
||||
tab: "/contracts",
|
||||
count: 1,
|
||||
name: "Company loaner",
|
||||
locationId: state.tutorial.loanerLocationId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const ship of readyShips(state)) {
|
||||
items.push({
|
||||
kind: "ready",
|
||||
tab: "/contracts",
|
||||
count: 1,
|
||||
name: ship.name,
|
||||
locationId: ship.locationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const points = state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0);
|
||||
if (points > 0) items.push({ kind: "skills", tab: "/fleet", count: points });
|
||||
for (const goal of state.goals.filter((g) => g.status === "ready")) {
|
||||
items.push({ kind: "goal", tab: "/", count: 1, goalId: goal.id });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const ROW = "flex min-h-14 w-full items-center gap-3 py-2 text-left";
|
||||
|
||||
function Dot({ tone }: { tone: "problem" | "action" | "earned" | "ink" }) {
|
||||
const fill = {
|
||||
problem: "bg-problem",
|
||||
action: "bg-action",
|
||||
earned: "bg-earned",
|
||||
ink: "bg-ink-300",
|
||||
}[tone];
|
||||
return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${fill}`} />;
|
||||
}
|
||||
|
||||
function RowText({ title, detail }: { title: string; detail: React.ReactNode }) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block leading-snug font-medium">{title}</span>
|
||||
<span className="block text-[13px] leading-snug text-ink-300">{detail}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Go = () => <Icon name="arrow" className="size-4 shrink-0 text-ink-400" />;
|
||||
|
||||
/** The Bridge's list of what needs the player (decision 0.5.168). */
|
||||
export function NeedsYou() {
|
||||
const state = useLoadedPlayer();
|
||||
const open = useRadioLater((s) => s.open);
|
||||
const items = needsYou(state);
|
||||
const radio = items.filter((i) => i.kind === "radio");
|
||||
const rest = items.filter((i) => i.kind !== "radio");
|
||||
const pilot = (id: string) => state.pilots.find((p) => p.id === id)?.name ?? "Your pilot";
|
||||
|
||||
return (
|
||||
<section aria-labelledby="needs-you-title">
|
||||
<h2 id="needs-you-title" className="text-sm font-semibold">
|
||||
Needs you
|
||||
</h2>
|
||||
{items.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-300">Nothing right now. Your fleet has it in hand.</p>
|
||||
)}
|
||||
{radio.length > 0 && (
|
||||
<ul aria-label="Radio calls" className="divide-y divide-glass-line">
|
||||
{radio.map((call) => (
|
||||
<li key={call.dispatchId} className={ROW}>
|
||||
<Dot tone="problem" />
|
||||
<RowText
|
||||
title={content.tripEvent(call.eventId)?.title ?? "Radio call"}
|
||||
detail={
|
||||
<>
|
||||
{pilot(call.pilotId)} ·{" "}
|
||||
<Countdown to={call.endsAt} format={formatClock} done="now" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(call.dispatchId)}
|
||||
className="min-h-11 shrink-0 rounded-xl bg-action px-3 text-sm font-semibold text-on-action"
|
||||
>
|
||||
Answer
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<ul aria-label="Other things waiting" className="divide-y divide-glass-line">
|
||||
{rest.map((item) => {
|
||||
if (item.kind === "ready") {
|
||||
const to = state.tutorial
|
||||
? "/contracts"
|
||||
: `/contracts?location=${item.locationId ?? ""}`;
|
||||
return (
|
||||
<li key={`ready-${item.name}`}>
|
||||
<Link to={to} className={ROW}>
|
||||
<Dot tone="action" />
|
||||
<RowText
|
||||
title={`${item.name} is ready`}
|
||||
detail={`At ${locationName(item.locationId ?? "")} · find it a job`}
|
||||
/>
|
||||
<Go />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (item.kind === "skills") {
|
||||
const who = state.pilots
|
||||
.filter((p) => p.skillPointsAvailable > 0)
|
||||
.map((p) => `${p.isAvatar ? "You" : p.name} ${p.skillPointsAvailable}`)
|
||||
.join(" · ");
|
||||
return (
|
||||
<li key="skills">
|
||||
<Link to="/pilots" className={ROW}>
|
||||
<Dot tone="ink" />
|
||||
<RowText
|
||||
title={`${item.count} skill ${item.count === 1 ? "point" : "points"} to spend`}
|
||||
detail={who}
|
||||
/>
|
||||
<Go />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (item.kind === "goal") {
|
||||
const goal = content.onboardingGoal(item.goalId);
|
||||
return (
|
||||
<li key={item.goalId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
document.getElementById("goals")?.scrollIntoView?.({ behavior: "smooth" })
|
||||
}
|
||||
className={ROW}
|
||||
>
|
||||
<Dot tone="earned" />
|
||||
<RowText
|
||||
title={`Goal ready: +${formatCredits(goal?.reward ?? 0)}`}
|
||||
detail={goal?.title ?? ""}
|
||||
/>
|
||||
<Go />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,13 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { readyShips } from "../game";
|
||||
import { needsYou } from "../components/NeedsYou";
|
||||
import type { ScreenPath } from "../routes";
|
||||
|
||||
/**
|
||||
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
|
||||
* to claim and radio calls waiting (decision 0.5.144), ships ready for a job (decision 0.5.134), and unspent skill points
|
||||
* across all pilots (decision 0.5.109).
|
||||
* Counts for the navigation's badges: what the Bridge's Needs you list holds,
|
||||
* per tab (decisions 0.5.49, 0.5.168). Decorative; the list says what they are.
|
||||
*/
|
||||
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
||||
const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length;
|
||||
return {
|
||||
// Goals to claim and radio calls to answer (CR-026).
|
||||
"/": state.goals.filter((g) => g.status === "ready").length + state.radioCalls.length,
|
||||
"/contracts": ready,
|
||||
"/fleet": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0),
|
||||
};
|
||||
const counts: Partial<Record<ScreenPath, number>> = { "/": 0, "/contracts": 0, "/fleet": 0 };
|
||||
for (const item of needsYou(state)) counts[item.tab] = (counts[item.tab] ?? 0) + item.count;
|
||||
return counts;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { FleetAsleep } from "../components/FleetSleep";
|
||||
import { RadioCallsPanel } from "../components/RadioCalls";
|
||||
import { NeedsYou } from "../components/NeedsYou";
|
||||
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
||||
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
||||
import { dispatchProgress } from "@deadhead/sim";
|
||||
|
|
@ -97,27 +97,11 @@ export function Dashboard() {
|
|||
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
|
||||
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
||||
<FleetAsleep />
|
||||
{/* Calls waiting for an answer, each reopening its card (CR-026). */}
|
||||
<RadioCallsPanel />
|
||||
|
||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||
<YourShip className="@2xl:col-span-2" />
|
||||
<Alerts />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 @2xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{state.tutorial && (
|
||||
<Panel tone="amber">
|
||||
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
|
||||
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
|
||||
Flying for {content.tutorialCompanyName}
|
||||
</h2>
|
||||
<Eyebrow>Contract pilot</Eyebrow>
|
||||
<h2 className="mt-0.5 text-lg font-semibold">Flying for {content.tutorialCompanyName}</h2>
|
||||
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
|
||||
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
||||
{runsBeforeDeal}
|
||||
|
|
@ -135,30 +119,36 @@ export function Dashboard() {
|
|||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel
|
||||
as="section"
|
||||
aria-labelledby="on-jobs-title"
|
||||
tone={onJobs.length ? "cyan" : "default"}
|
||||
>
|
||||
<h2 id="on-jobs-title" className="font-display text-xs font-semibold text-cyan-300/80">
|
||||
Ships on jobs
|
||||
<div className="grid gap-4 @2xl:grid-cols-2">
|
||||
<YourShip />
|
||||
<NeedsYou />
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="on-jobs-title">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 id="on-jobs-title" className="text-sm font-semibold">
|
||||
In flight
|
||||
</h2>
|
||||
<span aria-label="Ships in flight" className="text-xs text-ink-300">
|
||||
{flying} flying · {idle} idle
|
||||
</span>
|
||||
</div>
|
||||
{onJobs.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
|
||||
<p className="py-2 text-sm text-ink-300">Nothing in flight.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-3">
|
||||
<ul className="divide-y divide-glass-line">
|
||||
{onJobs.map((d) => {
|
||||
const name = flightShipName(state, d);
|
||||
return (
|
||||
<li key={d.id} aria-label={name} className="text-sm">
|
||||
<li key={d.id} aria-label={name} className="py-2.5 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-semibold text-ink-50">{name}</span>{" "}
|
||||
<span className="font-medium text-ink-50">{name}</span>{" "}
|
||||
<span className="text-ink-300">
|
||||
{locationName(d.originId)} → {locationName(d.destinationId)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-display font-bold whitespace-nowrap text-cyan-200">
|
||||
<span className="font-semibold whitespace-nowrap tabular-nums">
|
||||
<Countdown to={d.arrivalAt} />
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -167,82 +157,58 @@ export function Dashboard() {
|
|||
value={Math.round(dispatchProgress(d, now) * 100)}
|
||||
tone="cyan"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</section>
|
||||
|
||||
{!state.tutorial && (
|
||||
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
|
||||
<Icon name="rest" className="size-5" />
|
||||
{!state.tutorial && resting.length > 0 && (
|
||||
<section aria-label="Pilot rest" className="flex items-center gap-3 text-sm">
|
||||
<Icon name="rest" className="size-5 shrink-0 text-flight" />
|
||||
<p className="min-w-0">
|
||||
<span className="font-medium">Pilots resting: {resting.length}</span>
|
||||
<span className="block text-ink-300">
|
||||
{resting[0]!.name} wakes in <Countdown to={resting[0]!.restEndsAt!} done="now" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-sm font-semibold text-ink-50">
|
||||
Pilots resting: {resting.length}
|
||||
</h2>
|
||||
{resting[0] ? (
|
||||
<p className="text-sm text-sky-200">
|
||||
{resting[0].name} wakes in{" "}
|
||||
<Countdown to={resting[0].restEndsAt!} done="now" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{inShop.length > 0 && (
|
||||
<Panel aria-label="In the shop" tone="amber">
|
||||
<h2 className="font-display text-sm font-semibold text-ink-50">
|
||||
In the shop: {inShop.length}
|
||||
</h2>
|
||||
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
|
||||
<section aria-label="In the shop" className="text-sm">
|
||||
<h2 className="font-semibold">In the shop: {inShop.length}</h2>
|
||||
<ul className="mt-1 space-y-0.5 text-ink-300">
|
||||
{inShop.map((ship) => (
|
||||
<li key={ship.id}>
|
||||
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
|
||||
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
|
||||
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name} {ship.shop?.level}{" "}
|
||||
· ready in <Countdown to={ship.inShopUntil!} done="now" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No ships yet — buy one in the Shipyard.</p>
|
||||
<p className="text-sm text-ink-300">No ships yet — buy one in the Shipyard.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DispatchOfficePanel />
|
||||
{!state.tutorial && (
|
||||
<Panel className="text-sm" aria-label="Logbook">
|
||||
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
|
||||
<Icon name="route" className="size-4 text-cyan-300" />
|
||||
Logbook
|
||||
</p>
|
||||
<p className="mt-1 text-ink-300">
|
||||
Every job, refuel, rest and upgrade from the last 7 days, pilot by pilot.
|
||||
</p>
|
||||
<Link
|
||||
to="/logbook"
|
||||
className="mt-2 inline-block font-semibold text-amber-300 underline"
|
||||
>
|
||||
Open the Logbook
|
||||
</Link>
|
||||
</Panel>
|
||||
)}
|
||||
{!state.tutorial && <GoalsCard />}
|
||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||
</div>
|
||||
</div>
|
||||
<DispatchOfficePanel />
|
||||
{!state.tutorial && (
|
||||
<Link
|
||||
to="/logbook"
|
||||
aria-label="Open the Logbook"
|
||||
className="flex min-h-11 items-center gap-2 text-sm font-medium text-ink-200"
|
||||
>
|
||||
<Icon name="route" className="size-4 text-ink-300" />
|
||||
Open the Logbook
|
||||
</Link>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
|
@ -282,12 +248,12 @@ function YourShip({ className = "" }: { className?: string }) {
|
|||
<Panel
|
||||
as="section"
|
||||
aria-label="Your ship"
|
||||
tone={dispatch ? "cyan" : "default"}
|
||||
className={`flex flex-col gap-3 ${className}`}
|
||||
tone={dispatch || inShop ? "default" : "amber"}
|
||||
className={`flex flex-col gap-3 self-start ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{sprite && (
|
||||
<div className="grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
||||
<div className="grid size-20 shrink-0 place-items-center">
|
||||
<Art
|
||||
sprite={sprite}
|
||||
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
||||
|
|
@ -297,7 +263,7 @@ function YourShip({ className = "" }: { className?: string }) {
|
|||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow>Your ship</Eyebrow>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="truncate font-display text-xl font-bold">{name}</h2>
|
||||
<h2 className="truncate text-lg font-semibold">{name}</h2>
|
||||
{dispatch ? (
|
||||
<Pill tone="cyan" icon="send">
|
||||
In flight
|
||||
|
|
@ -331,7 +297,7 @@ function YourShip({ className = "" }: { className?: string }) {
|
|||
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
|
||||
: "Flying empty"}
|
||||
</span>
|
||||
<span className="font-display text-lg font-bold text-cyan-200">
|
||||
<span className="font-semibold">
|
||||
Arrives in <Countdown to={dispatch.arrivalAt} />
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -428,23 +394,6 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Where in-flight alerts and news will appear; empty for now (decision 0.5.120). */
|
||||
function Alerts() {
|
||||
return (
|
||||
<Panel as="section" aria-labelledby="alerts-title" tone="muted">
|
||||
<h2
|
||||
id="alerts-title"
|
||||
className="flex items-center gap-2 font-display text-sm font-semibold text-ink-50"
|
||||
>
|
||||
<Icon name="alert" className="size-4 text-amber-300" />
|
||||
Alerts
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-ink-300">No alerts.</p>
|
||||
<p className="mt-1 text-xs text-ink-500">In-flight alerts and news will appear here.</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pip per company run toward the deal. */
|
||||
function RunPips({ done, total }: { done: number; total: number }) {
|
||||
return (
|
||||
|
|
@ -452,7 +401,7 @@ function RunPips({ done, total }: { done: number; total: number }) {
|
|||
{Array.from({ length: total }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
|
||||
className={`h-2 flex-1 rounded-full ${i < done ? "bg-action" : "bg-white/14"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,11 +56,9 @@ describe("Dashboard (CR-020)", () => {
|
|||
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
||||
|
||||
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
|
||||
expect(statValue("Ships idle")).toHaveTextContent("1");
|
||||
expect(statValue("Ships in flight")).toHaveTextContent("2");
|
||||
expect(screen.getByLabelText("Ships in flight")).toHaveTextContent("2 flying · 1 idle");
|
||||
|
||||
const jobs = screen.getByRole("region", { name: "Ships on jobs" });
|
||||
const jobs = screen.getByRole("region", { name: "In flight" });
|
||||
const rows = within(jobs).getAllByRole("listitem");
|
||||
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
|
||||
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
|
||||
|
|
@ -297,9 +295,9 @@ describe("your ship on the Dashboard (CR-020)", () => {
|
|||
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
|
||||
});
|
||||
|
||||
it("has an Alerts panel, empty for now", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
|
||||
const alerts = await screen.findByRole("region", { name: "Alerts" });
|
||||
expect(alerts).toHaveTextContent("No alerts.");
|
||||
it("says so when nothing needs the player", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
|
||||
const list = await screen.findByRole("region", { name: "Needs you" });
|
||||
expect(list).toHaveTextContent("Nothing right now.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ describe("app layout", () => {
|
|||
renderApp(signedIn());
|
||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||
const links = within(nav).getAllByRole("link");
|
||||
expect(links.map((l) => l.textContent)).toEqual(["Bridge", "Jobs", "Fleet", "More"]);
|
||||
["Bridge", "Jobs", "Fleet", "More"].forEach((name, i) =>
|
||||
expect(links[i]).toHaveAccessibleName(name),
|
||||
);
|
||||
expect(links[0]).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -260,8 +260,7 @@ describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
|
|||
|
||||
it("is reachable from the Bridge", async () => {
|
||||
renderApp(app(), "/");
|
||||
const card = await screen.findByLabelText("Logbook");
|
||||
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
|
||||
await userEvent.click(await screen.findByRole("link", { name: "Open the Logbook" }));
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import type { PlayerState, RadioCall, Ship } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { needsYou } from "../src/components/NeedsYou";
|
||||
import { tabAttention } from "../src/layout/attention";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const base = samplePlayerState();
|
||||
const avatar = base.pilots[0]!;
|
||||
|
||||
const ship: Ship = {
|
||||
id: "s1",
|
||||
modelId: "courier",
|
||||
name: "Restless Sparrow",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "avatar",
|
||||
};
|
||||
|
||||
const owner = (overrides: Partial<PlayerState> = {}): PlayerState =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: [ship],
|
||||
bays: [{ id: "b", locationId: "earth" }],
|
||||
pilots: [{ ...avatar, assignedShipId: "s1", skillPointsAvailable: 2 }],
|
||||
goals: [{ id: "buy-a-ship", status: "ready" }],
|
||||
...overrides,
|
||||
} as Partial<PlayerState>);
|
||||
|
||||
const call = { dispatchId: "d1", eventId: "debris-field", pilotId: avatar.id } as RadioCall;
|
||||
|
||||
describe("Needs you (decision 0.5.168)", () => {
|
||||
it("orders radio calls, ready ships, skill points, then goals", () => {
|
||||
const items = needsYou(owner({ radioCalls: [{ ...call, windowEndsAt: 1 }] }));
|
||||
expect(items.map((i) => i.kind)).toEqual(["radio", "ready", "skills", "goal"]);
|
||||
});
|
||||
|
||||
it("badges count what the list holds, tab by tab", () => {
|
||||
const state = owner({ radioCalls: [{ ...call, windowEndsAt: 1 }] });
|
||||
const items = needsYou(state);
|
||||
const badges = tabAttention(state);
|
||||
expect(badges).toEqual({ "/": 2, "/contracts": 1, "/fleet": 2 });
|
||||
const total = items.reduce((sum, i) => sum + i.count, 0);
|
||||
expect(Object.values(badges).reduce((sum, n) => sum + n, 0)).toBe(total);
|
||||
});
|
||||
|
||||
it("names who has skill points and opens Crew", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => owner() }));
|
||||
const list = await screen.findByRole("region", { name: "Needs you" });
|
||||
await userEvent.click(within(list).getByRole("link", { name: /2 skill points to spend/ }));
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Crew" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends a ready ship to the jobs where it is", async () => {
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => owner(),
|
||||
getContractBoard: () => ({ contracts: [], periodEndsAt: Date.now() + 60_000 }) as never,
|
||||
}),
|
||||
);
|
||||
const list = await screen.findByRole("region", { name: "Needs you" });
|
||||
expect(within(list).getByRole("link", { name: /Restless Sparrow is ready/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/contracts?location=earth",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -132,7 +132,7 @@ describe("radio calls (Step 26.6, decision 0.5.144)", () => {
|
|||
await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
const panel = screen.getByLabelText("Radio calls");
|
||||
expect(panel).toHaveTextContent("Radio calls (1)");
|
||||
expect(panel).toHaveTextContent("Debris field ahead");
|
||||
await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue