deadhead-space-freight-tycoon/apps/web/src/components/AwaySummary.tsx

329 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { content } from "@deadhead/content";
import type { LogEntry, Pilot, PlayerState } from "@deadhead/schema";
import {
buildAwaySummary,
flightNet,
type AwayHighlight,
type AwayProblem,
type AwaySummary as Summary,
} from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
import { fill, formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Sheet } from "../ui";
import { SleepBanner } from "./FleetSleep";
const words = content.awayWording;
export const logbookKey = ["logbook"] as const;
/** An absence's length: "2 d 3 h", "9 h 40 m", "12 m". */
export function formatAbsence(ms: number): string {
const minutes = Math.max(1, Math.round(ms / 60_000));
const days = Math.floor(minutes / 1440);
const hours = Math.floor((minutes % 1440) / 60);
const mins = minutes % 60;
if (days > 0) return `${days} d ${hours} h`;
if (hours > 0) return `${hours} h ${mins} m`;
return `${mins} m`;
}
/** Credits with their sign: "+12,300 cr", "−410 cr". */
export function signedCredits(credits: number): string {
return credits < 0 ? `−${formatCredits(-credits)}` : `+${formatCredits(credits)}`;
}
const plural = (count: number, one: string, many = `${one}s`) =>
`${count} ${count === 1 ? one : many}`;
/** Where a pilot is now, in a few words (a pilot line's last part). */
export function pilotNow(state: PlayerState, pilot: Pilot): string {
const flight = state.activeDispatches.find((d) => d.pilotId === pilot.id);
if (flight) return `Flying to ${locationName(flight.destinationId)}`;
const place = locationName(pilot.locationId ?? "");
if (pilot.restingInShopUntil !== null) return `In the shop at ${place}`;
if (pilot.resting) return `Resting at ${place}`;
if (pilot.mode === "independent" && state.fleetSleep.asleep) return "Waiting for orders";
return `At ${place}`;
}
/** Names for ids, from the state and content. */
function namer(state: PlayerState) {
return {
pilot: (id: string | null) => state.pilots.find((p) => p.id === id)?.name ?? "A pilot",
ship: (id: string | null, tutorial = false) =>
tutorial ? "Company loaner" : (state.ships.find((s) => s.id === id)?.name ?? "A ship"),
good: (id: string | null) => (id ? (content.good(id)?.name ?? id) : "cargo"),
};
}
function problemText(problem: AwayProblem, name: ReturnType<typeof namer>): string {
if (problem.kind === "below-floor") {
return fill(words.highlights.belowFloor, { floor: formatCredits(problem.floor) });
}
const template = {
"no-jobs": words.highlights.noJobs,
"low-fuel": words.highlights.lowFuel,
"emergency-fuel": words.highlights.emergencyFuel,
}[problem.kind];
return fill(template, {
ship: name.ship(problem.shipId),
place: locationName(problem.locationId),
});
}
/** One highlight in words (decision 0.5.140). */
export function highlightText(highlight: AwayHighlight, state: PlayerState): string {
const name = namer(state);
switch (highlight.kind) {
case "discovery":
return fill(words.highlights.discovery, { place: locationName(highlight.locationId) });
case "level-up":
return fill(words.highlights.levelUp, {
pilot: name.pilot(highlight.pilotId),
level: highlight.level,
points: plural(highlight.skillPoints, "skill point"),
});
case "goal-ready":
return fill(words.highlights.goalReady, {
goal: content.onboardingGoal(highlight.goalId)?.title ?? highlight.goalId,
});
case "scripted":
return (
content.scriptedTutorialContracts.find((c) => c.id === highlight.scriptedId)
?.flavorMessage ?? ""
);
case "best-job":
return fill(words.highlights.bestJob, {
good: name.good(highlight.goodId),
place: locationName(highlight.destinationId),
credits: formatCredits(highlight.payout),
pilot: name.pilot(highlight.pilotId),
});
case "problem":
return problemText(highlight.problem, name);
}
}
/** A short absence's event in words ("Courier delivered Mail to the Moon, +410 cr"). */
export function directText(entry: LogEntry, state: PlayerState): string {
const name = namer(state);
switch (entry.kind) {
case "flight":
return entry.deadhead
? fill(words.direct.deadhead, {
ship: name.ship(entry.shipId, entry.tutorial),
place: locationName(entry.destinationId),
fuel: formatCredits(entry.fuelCost),
})
: fill(words.direct.delivery, {
ship: name.ship(entry.shipId, entry.tutorial),
good: name.good(entry.goodId),
place: locationName(entry.destinationId),
credits: signedCredits(flightNet(entry)),
});
case "upgrade":
return fill(words.direct.upgrade, {
ship: name.ship(entry.shipId),
upgrade: content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId,
level: entry.level,
});
case "discovery":
return fill(words.direct.discovery, {
pilot: name.pilot(entry.pilotId),
place: locationName(entry.locationId),
});
case "level-up":
return fill(words.direct.levelUp, {
pilot: name.pilot(entry.pilotId),
level: entry.level,
points: plural(entry.skillPoints, "skill point"),
});
default:
return "";
}
}
/** The summary for the absence now (decision 0.5.140). */
export function useAwaySummary(
state: PlayerState,
refresh: string,
enabled = true,
): Summary | null {
const api = useApi();
const book = useQuery({
queryKey: [...logbookKey, refresh],
queryFn: () => api.call("getLogbook", {}),
enabled,
});
if (!book.data) return null;
return buildAwaySummary({
...book.data.absence,
entries: book.data.entries,
pilots: state.pilots.map((p) => ({ id: p.id, name: p.name, now: pilotNow(state, p) })),
problems: book.data.problems,
});
}
/**
* "While you were away" (Step 9.2, decision 0.5.140): highlights on one phone
* screen, the detail in the Logbook. Opens on unseen deliveries and finished
* upgrades, as before.
*/
export function AwaySummary() {
const state = useLoadedPlayer();
const acknowledge = useGameAction("acknowledgeSummaries");
// Hide as soon as the player dismisses; the refetch clears the list for real.
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
const navigate = useNavigate();
const now = useClock((s) => s.now);
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
// Upgrades finished and not yet seen: "out of the shop" (CR-009).
const outOfShop = state.ships.filter(
(s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`),
);
const open = summaries.length > 0 || outOfShop.length > 0;
const refresh = [...summaries.map((s) => s.dispatchId), ...outOfShop.map((s) => s.id)].join();
const summary = useAwaySummary(state, refresh, open);
if (!open) return null;
const dismiss = () => {
const ids = summaries.map((s) => s.dispatchId);
const shipIds = outOfShop.map((s) => s.id);
setDismissed(new Set([...dismissed, ...ids, ...shipIds.map((id) => `ship:${id}`)]));
acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) });
};
const title = fill(words.title, { duration: summary ? formatAbsence(summary.durationMs) : "…" });
const highlights = summary?.highlights ?? [];
return (
<Sheet titleId="away-title" title={title}>
<div className="space-y-3 text-sm">
<SleepBanner />
{!summary ? (
<p className="text-ink-400">Reading the logbook…</p>
) : summary.direct ? (
<ul aria-label="What happened" className="space-y-1.5">
{summary.direct.map((entry) => (
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
<Icon name="send" className="mt-0.5 size-4 shrink-0 text-cyan-300" />
{directText(entry as LogEntry, state)}
</li>
))}
</ul>
) : (
<Totals summary={summary} />
)}
{summary && highlights.length > 0 && (
<ul aria-label="Highlights" className="space-y-1.5">
{highlights.map((h, i) => (
<li key={i} className="flex items-start gap-2 text-amber-100">
<Icon
name={h.kind === "problem" ? "alert" : "star"}
className="mt-0.5 size-4 shrink-0 text-amber-300"
/>
{highlightText(h, state)}
</li>
))}
{summary.moreHighlights > 0 && (
<li className="pl-6 text-ink-400">
{fill(words.moreHighlights, { count: summary.moreHighlights })}
</li>
)}
</ul>
)}
{summary && !summary.direct && <PilotLines summary={summary} />}
</div>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
className="flex-1"
onClick={() => {
dismiss();
navigate("/logbook?range=absence");
}}
>
{words.viewLogbook}
</Button>
<Button className="flex-1" onClick={dismiss}>
{words.done}
</Button>
</div>
</Sheet>
);
}
/** One row of company figures: net, deliveries, discoveries and loan repaid (if any). */
function Totals({ summary }: { summary: Summary }) {
const { net, deliveries, discoveries, loanRepaid } = summary.totals;
const figures: [string, string, string][] = [
[words.totals.net, signedCredits(net), net < 0 ? "text-rose-300" : "text-emerald-300"],
[words.totals.deliveries, String(deliveries), "text-ink-50"],
];
if (discoveries > 0)
figures.push([words.totals.discoveries, String(discoveries), "text-cyan-200"]);
if (loanRepaid > 0)
figures.push([words.totals.loanRepaid, formatCredits(loanRepaid), "text-ink-50"]);
return (
<dl
aria-label="Company totals"
className="grid grid-flow-col gap-2 rounded-xl border border-cyan-400/30 bg-cyan-950/30 p-3"
>
{figures.map(([label, value, tone]) => (
<div key={label}>
<dt className="font-display text-[10px] tracking-wider text-ink-400 uppercase">
{label}
</dt>
<dd className={`font-display text-base font-bold tabular-nums ${tone}`}>{value}</dd>
</div>
))}
</dl>
);
}
/** One line per active pilot, at most 5, then the rest and the idle ones summed. */
function PilotLines({ summary }: { summary: Summary }) {
if (summary.pilots.length === 0 && summary.idlePilots === 0) return null;
return (
<ul aria-label="Pilots" className="space-y-1 text-ink-200">
{summary.pilots.map((line) => (
<li key={line.pilotId} className="flex items-baseline gap-2 truncate">
<span className="truncate">
{fill(words.pilotLine, {
pilot: line.name,
jobs: plural(line.deliveries, "job"),
credits: signedCredits(line.net),
now: line.now,
})}
</span>
{line.leveledUp && (
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 text-[10px] font-semibold text-amber-200 uppercase">
{words.leveledUp}
</span>
)}
</li>
))}
{summary.morePilots && (
<li className="text-ink-400">
{fill(words.morePilots, {
count: summary.morePilots.count,
credits: signedCredits(summary.morePilots.net),
})}
</li>
)}
{summary.idlePilots > 0 && (
<li className="text-ink-400">
{summary.idlePilots === 1
? words.idlePilot
: fill(words.idlePilots, { count: summary.idlePilots })}
</li>
)}
</ul>
);
}