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 (
|
return (
|
||||||
<Panel as="section" id="goals" aria-labelledby="goals-title">
|
<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">
|
<h2 id="goals-title" className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<Icon name="target" className="size-5 text-amber-300" />
|
<Icon name="target" className="size-4 text-ink-300" />
|
||||||
Goals
|
Goals
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
|
|
@ -28,23 +28,21 @@ export function GoalsCard() {
|
||||||
<li
|
<li
|
||||||
key={goal.id}
|
key={goal.id}
|
||||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||||
ready
|
ready ? "border-earned/45 bg-earned/8" : "border-glass-line"
|
||||||
? "border-emerald-400/50 bg-emerald-500/10 shadow-glow-emerald"
|
|
||||||
: "border-space-700 bg-space-950/40"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
|
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" />}
|
{ready && <Icon name="check" className="size-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
|
<span className={"text-ink-100"}>
|
||||||
{details.title}
|
{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)}
|
+{formatCredits(details.reward)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -55,7 +53,7 @@ export function GoalsCard() {
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={claim.isPending}
|
disabled={claim.isPending}
|
||||||
onClick={() => claim.mutate({ goalId: goal.id })}
|
onClick={() => claim.mutate({ goalId: goal.id })}
|
||||||
className="shrink-0 animate-glow"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
Claim
|
Claim
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ export function DispatchOfficePanel() {
|
||||||
Dispatch Office
|
Dispatch Office
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-ink-300">
|
<p className="mt-1 text-ink-300">
|
||||||
Your hired pilots can run jobs on their own. Set them to Independent on{" "}
|
Your hired pilots can run jobs on their own. Set them to Independent in{" "}
|
||||||
<Link to="/pilots" className="font-semibold text-amber-300 underline">
|
<Link to="/pilots" className="font-semibold underline">
|
||||||
Pilots
|
Crew
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -37,13 +37,15 @@ export function DispatchOfficePanel() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Panel tone="amber" className="text-sm" aria-label="Dispatch Office">
|
<Panel className="text-sm" aria-label="Dispatch Office">
|
||||||
<p className="font-display font-semibold text-amber-100">Dispatch Office</p>
|
<p className="font-semibold">Dispatch Office</p>
|
||||||
<p className="mt-1 text-ink-300">
|
<p className="mt-1 text-ink-300">
|
||||||
Lets your hired pilots run jobs on their own, so the company keeps earning while you're
|
Lets your hired pilots run jobs on their own, so the company keeps earning while you're
|
||||||
away.
|
away.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
disabled={state.company.credits < price || buy.isPending}
|
disabled={state.company.credits < price || buy.isPending}
|
||||||
onClick={() => buy.mutate({})}
|
onClick={() => buy.mutate({})}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,8 @@ export function LoanCard() {
|
||||||
return (
|
return (
|
||||||
<Panel>
|
<Panel>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2>
|
<h2 className="text-sm font-semibold">Loan from Halvorsen</h2>
|
||||||
<span
|
<span aria-label="Loan balance" className="font-semibold tabular-nums">
|
||||||
aria-label="Loan balance"
|
|
||||||
className="font-display font-semibold text-amber-200 tabular-nums"
|
|
||||||
>
|
|
||||||
{formatCredits(loanBalance)} left
|
{formatCredits(loanBalance)} left
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,9 +36,7 @@ export function LoanCard() {
|
||||||
until it's paid off.
|
until it's paid off.
|
||||||
</p>
|
</p>
|
||||||
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
|
<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">
|
<p className="mt-1 text-right text-xs text-ink-300">{paidOff}% paid off</p>
|
||||||
{paidOff}% paid off
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="mt-2 flex items-center gap-2"
|
className="mt-2 flex items-center gap-2"
|
||||||
|
|
@ -59,9 +54,13 @@ export function LoanCard() {
|
||||||
placeholder="Amount"
|
placeholder="Amount"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
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
|
Pay down
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 type { PlayerState } from "@deadhead/schema";
|
||||||
import { readyShips } from "../game";
|
import { needsYou } from "../components/NeedsYou";
|
||||||
import type { ScreenPath } from "../routes";
|
import type { ScreenPath } from "../routes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
|
* Counts for the navigation's badges: what the Bridge's Needs you list holds,
|
||||||
* to claim and radio calls waiting (decision 0.5.144), ships ready for a job (decision 0.5.134), and unspent skill points
|
* per tab (decisions 0.5.49, 0.5.168). Decorative; the list says what they are.
|
||||||
* across all pilots (decision 0.5.109).
|
|
||||||
*/
|
*/
|
||||||
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
||||||
const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length;
|
const counts: Partial<Record<ScreenPath, number>> = { "/": 0, "/contracts": 0, "/fleet": 0 };
|
||||||
return {
|
for (const item of needsYou(state)) counts[item.tab] = (counts[item.tab] ?? 0) + item.count;
|
||||||
// Goals to claim and radio calls to answer (CR-026).
|
return counts;
|
||||||
"/": state.goals.filter((g) => g.status === "ready").length + state.radioCalls.length,
|
|
||||||
"/contracts": ready,
|
|
||||||
"/fleet": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { FleetAsleep } from "../components/FleetSleep";
|
import { FleetAsleep } from "../components/FleetSleep";
|
||||||
import { RadioCallsPanel } from "../components/RadioCalls";
|
import { NeedsYou } from "../components/NeedsYou";
|
||||||
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
||||||
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
||||||
import { dispatchProgress } from "@deadhead/sim";
|
import { dispatchProgress } from "@deadhead/sim";
|
||||||
|
|
@ -97,27 +97,11 @@ export function Dashboard() {
|
||||||
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
|
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
|
||||||
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
||||||
<FleetAsleep />
|
<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 && (
|
{state.tutorial && (
|
||||||
<Panel tone="amber">
|
<Panel tone="amber">
|
||||||
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
|
<Eyebrow>Contract pilot</Eyebrow>
|
||||||
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
|
<h2 className="mt-0.5 text-lg font-semibold">Flying for {content.tutorialCompanyName}</h2>
|
||||||
Flying for {content.tutorialCompanyName}
|
|
||||||
</h2>
|
|
||||||
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
|
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
|
||||||
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
||||||
{runsBeforeDeal}
|
{runsBeforeDeal}
|
||||||
|
|
@ -135,30 +119,36 @@ export function Dashboard() {
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Panel
|
<div className="grid gap-4 @2xl:grid-cols-2">
|
||||||
as="section"
|
<YourShip />
|
||||||
aria-labelledby="on-jobs-title"
|
<NeedsYou />
|
||||||
tone={onJobs.length ? "cyan" : "default"}
|
</div>
|
||||||
>
|
|
||||||
<h2 id="on-jobs-title" className="font-display text-xs font-semibold text-cyan-300/80">
|
<section aria-labelledby="on-jobs-title">
|
||||||
Ships on jobs
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 id="on-jobs-title" className="text-sm font-semibold">
|
||||||
|
In flight
|
||||||
</h2>
|
</h2>
|
||||||
|
<span aria-label="Ships in flight" className="text-xs text-ink-300">
|
||||||
|
{flying} flying · {idle} idle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{onJobs.length === 0 ? (
|
{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) => {
|
{onJobs.map((d) => {
|
||||||
const name = flightShipName(state, d);
|
const name = flightShipName(state, d);
|
||||||
return (
|
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">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="min-w-0 truncate">
|
<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">
|
<span className="text-ink-300">
|
||||||
{locationName(d.originId)} → {locationName(d.destinationId)}
|
{locationName(d.originId)} → {locationName(d.destinationId)}
|
||||||
</span>
|
</span>
|
||||||
</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} />
|
<Countdown to={d.arrivalAt} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -167,82 +157,58 @@ export function Dashboard() {
|
||||||
value={Math.round(dispatchProgress(d, now) * 100)}
|
value={Math.round(dispatchProgress(d, now) * 100)}
|
||||||
tone="cyan"
|
tone="cyan"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1"
|
className="mt-1.5"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</section>
|
||||||
|
|
||||||
{!state.tutorial && (
|
{!state.tutorial && resting.length > 0 && (
|
||||||
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
|
<section aria-label="Pilot rest" className="flex items-center gap-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<Icon name="rest" className="size-5 shrink-0 text-flight" />
|
||||||
<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">
|
<p className="min-w-0">
|
||||||
<Icon name="rest" className="size-5" />
|
<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>
|
</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>
|
||||||
) : (
|
</section>
|
||||||
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inShop.length > 0 && (
|
{inShop.length > 0 && (
|
||||||
<Panel aria-label="In the shop" tone="amber">
|
<section aria-label="In the shop" className="text-sm">
|
||||||
<h2 className="font-display text-sm font-semibold text-ink-50">
|
<h2 className="font-semibold">In the shop: {inShop.length}</h2>
|
||||||
In the shop: {inShop.length}
|
<ul className="mt-1 space-y-0.5 text-ink-300">
|
||||||
</h2>
|
|
||||||
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
|
|
||||||
{inShop.map((ship) => (
|
{inShop.map((ship) => (
|
||||||
<li key={ship.id}>
|
<li key={ship.id}>
|
||||||
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
|
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name} {ship.shop?.level}{" "}
|
||||||
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
|
· ready in <Countdown to={ship.inShopUntil!} done="now" />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Panel>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!state.tutorial && state.ships.length === 0 && (
|
{!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 && <GoalsCard />}
|
||||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||||
</div>
|
<DispatchOfficePanel />
|
||||||
</div>
|
{!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>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -282,12 +248,12 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
<Panel
|
<Panel
|
||||||
as="section"
|
as="section"
|
||||||
aria-label="Your ship"
|
aria-label="Your ship"
|
||||||
tone={dispatch ? "cyan" : "default"}
|
tone={dispatch || inShop ? "default" : "amber"}
|
||||||
className={`flex flex-col gap-3 ${className}`}
|
className={`flex flex-col gap-3 self-start ${className}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{sprite && (
|
{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
|
<Art
|
||||||
sprite={sprite}
|
sprite={sprite}
|
||||||
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<Eyebrow>Your ship</Eyebrow>
|
<Eyebrow>Your ship</Eyebrow>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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 ? (
|
{dispatch ? (
|
||||||
<Pill tone="cyan" icon="send">
|
<Pill tone="cyan" icon="send">
|
||||||
In flight
|
In flight
|
||||||
|
|
@ -331,7 +297,7 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
|
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
|
||||||
: "Flying empty"}
|
: "Flying empty"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-lg font-bold text-cyan-200">
|
<span className="font-semibold">
|
||||||
Arrives in <Countdown to={dispatch.arrivalAt} />
|
Arrives in <Countdown to={dispatch.arrivalAt} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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. */
|
/** One pip per company run toward the deal. */
|
||||||
function RunPips({ done, total }: { done: number; total: number }) {
|
function RunPips({ done, total }: { done: number; total: number }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -452,7 +401,7 @@ function RunPips({ done, total }: { done: number; total: number }) {
|
||||||
{Array.from({ length: total }, (_, i) => (
|
{Array.from({ length: total }, (_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,9 @@ describe("Dashboard (CR-020)", () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
||||||
|
|
||||||
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
|
expect(screen.getByLabelText("Ships in flight")).toHaveTextContent("2 flying · 1 idle");
|
||||||
expect(statValue("Ships idle")).toHaveTextContent("1");
|
|
||||||
expect(statValue("Ships in flight")).toHaveTextContent("2");
|
|
||||||
|
|
||||||
const jobs = screen.getByRole("region", { name: "Ships on jobs" });
|
const jobs = screen.getByRole("region", { name: "In flight" });
|
||||||
const rows = within(jobs).getAllByRole("listitem");
|
const rows = within(jobs).getAllByRole("listitem");
|
||||||
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
|
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
|
||||||
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
|
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");
|
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has an Alerts panel, empty for now", async () => {
|
it("says so when nothing needs the player", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
|
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
|
||||||
const alerts = await screen.findByRole("region", { name: "Alerts" });
|
const list = await screen.findByRole("region", { name: "Needs you" });
|
||||||
expect(alerts).toHaveTextContent("No alerts.");
|
expect(list).toHaveTextContent("Nothing right now.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ describe("app layout", () => {
|
||||||
renderApp(signedIn());
|
renderApp(signedIn());
|
||||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||||
const links = within(nav).getAllByRole("link");
|
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(links[0]).toHaveAttribute("aria-current", "page");
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
|
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 () => {
|
it("is reachable from the Bridge", async () => {
|
||||||
renderApp(app(), "/");
|
renderApp(app(), "/");
|
||||||
const card = await screen.findByLabelText("Logbook");
|
await userEvent.click(await screen.findByRole("link", { name: "Open the Logbook" }));
|
||||||
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
|
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" }));
|
await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
const panel = screen.getByLabelText("Radio calls");
|
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" }));
|
await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
|
||||||
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
|
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue