329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|