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

294 lines
10 KiB
TypeScript

import { content } from "@deadhead/content";
import type { LogEntry, PlayerState } from "@deadhead/schema";
import { flightNet } from "@deadhead/sim";
import { useEffect, useState } from "react";
import { useClock } from "../clock/clock";
import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { Button, Icon } from "../ui";
import { pilotNow } from "./AwaySummary";
import { eventResultText } from "./EventResults";
import { formatSleepTime } from "./FleetSleep";
import { serviceEntryText } from "./Service";
/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
export interface PilotLog {
/** Null for the company: goals, and anything without a pilot. */
pilotId: string | null;
name: string;
/** Newest first. */
entries: LogEntry[];
xp: number;
/** Where they are or what they're doing now; empty for the company. */
now: string;
}
export interface Logbook {
totals: {
jobs: number;
deadheads: number;
payout: number;
pilotShare: number;
fuelCost: number;
loanRepaid: number;
/** Payout less pilot shares, fuel used and the loan (decision 0.5.142). */
net: number;
xp: number;
};
pilots: PilotLog[];
}
/**
* The logbook for some entries (decision 0.5.141): by pilot in roster order,
* then the company's own, newest first, with totals that are the sums of the
* entries. Pilots with no entries are left out.
*/
export function pilotLogbook(state: PlayerState, entries: readonly LogEntry[]): Logbook {
const newest = [...entries].sort((a, b) => b.at - a.at || (a.id < b.id ? 1 : -1));
const known = new Set(state.pilots.map((p) => p.id));
const pilots: PilotLog[] = [];
const xpOf = (list: LogEntry[]) =>
list.reduce((sum, e) => sum + (e.kind === "flight" ? e.xp : 0), 0);
for (const pilot of state.pilots) {
const own = newest.filter((e) => e.pilotId === pilot.id);
if (own.length === 0) continue;
pilots.push({
pilotId: pilot.id,
name: pilot.name,
entries: own,
xp: xpOf(own),
now: pilotNow(state, pilot),
});
}
const company = newest.filter((e) => e.pilotId === null || !known.has(e.pilotId));
if (company.length > 0) {
pilots.push({ pilotId: null, name: "Company", entries: company, xp: 0, now: "" });
}
const flights = entries.filter(
(e): e is Extract<LogEntry, { kind: "flight" }> => e.kind === "flight",
);
const jobs = flights.filter((f) => !f.deadhead);
return {
totals: {
jobs: jobs.length,
deadheads: flights.length - jobs.length,
payout: jobs.reduce((sum, f) => sum + f.payout, 0),
pilotShare: flights.reduce((sum, f) => sum + f.pilotPay, 0),
fuelCost: flights.reduce((sum, f) => sum + f.fuelCost, 0),
loanRepaid: flights.reduce((sum, f) => sum + f.loanRepayment, 0),
net: flights.reduce((sum, f) => sum + flightNet(f), 0),
xp: xpOf(flights),
},
pilots,
};
}
const REFUEL_SOURCE = {
"top-up": "Auto top-up",
bought: "bought",
pilot: "bought by the pilot",
emergency: "emergency fuel, on the loan",
} as const;
const shipName = (state: PlayerState, id: string | null) =>
state.ships.find((s) => s.id === id)?.name ?? "A ship";
/** One logbook line, with every figure (CR-021 §2's detail, decision 0.5.141). */
function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
const route = (from: string, to: string) => (
<span className="text-ink-400">
{locationName(from)} → {locationName(to)}
</span>
);
const fuel = (cost: number) => <span className="text-ink-100">fuel {formatCredits(cost)}</span>;
switch (entry.kind) {
case "flight":
return entry.deadhead ? (
<>
<Icon name="send" className="size-4 text-ink-300" />
<span>Deadhead</span>
{route(entry.originId, entry.destinationId)}
{fuel(entry.fuelCost)}
</>
) : (
<>
<span className="font-semibold">
{(entry.goodId && content.good(entry.goodId)?.name) || "Cargo"}
</span>
{route(entry.originId, entry.destinationId)}
<span className="text-emerald-300">{formatCredits(entry.payout)}</span>
{entry.pilotPay > 0 && (
<span className="text-ink-400">share {formatCredits(entry.pilotPay)}</span>
)}
{fuel(entry.fuelCost)}
{entry.loanRepayment > 0 && (
<span className="text-ink-400">lender {formatCredits(entry.loanRepayment)}</span>
)}
{entry.xp > 0 && <span className="text-violet-300">+{entry.xp} XP</span>}
</>
);
case "refuel":
return (
<span className="text-ink-100">
{Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "}
{formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]})
</span>
);
case "rest":
return (
<span className="flex items-center gap-1 text-ink-300">
<Icon name="rest" className="size-4" />
Rested until {formatLocalTime(entry.until)}
</span>
);
case "upgrade":
return (
<span className="text-ink-100">
{shipName(state, entry.shipId)}:{" "}
{content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished
</span>
);
case "discovery":
return <span className="text-ink-100">Discovered {locationName(entry.locationId)}</span>;
case "level-up":
return (
<span className="text-ink-100">
Reached level {entry.level} · {entry.skillPoints}{" "}
{entry.skillPoints === 1 ? "skill point" : "skill points"}
</span>
);
case "event":
return (
<span className={entry.good ? "text-emerald-200" : "text-ink-100"}>
{eventResultText(entry, state)}
</span>
);
case "repair":
return (
<span className="text-ink-100">
{shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "}
{formatCredits(entry.cost)}
</span>
);
case "service":
return (
<span className={entry.phase === "waiting-funds" ? "text-ink-100" : "text-ink-100"}>
{serviceEntryText(entry, shipName(state, entry.shipId))}
</span>
);
case "goal-ready":
return (
<span className="text-ink-100">
Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId}
</span>
);
}
}
/** A line's time: the time of day today, with the date on earlier days. */
function entryTime(at: number, now: number): string {
return new Date(at).toDateString() === new Date(now).toDateString()
? formatLocalTime(at)
: formatSleepTime(at);
}
/** How many lines a pilot's log shows before "Show more". */
const PAGE = 50;
function PilotSection({ log, state, open }: { log: PilotLog; state: PlayerState; open: boolean }) {
const [shown, setShown] = useState(PAGE);
const now = useClock((c) => c.now);
return (
<details open={open} className="rounded-xl border border-glass-line bg-black/25 p-3">
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
<span className="font-display font-bold">{log.name}</span>
{log.xp > 0 && <span className="text-xs text-violet-300">+{log.xp} XP</span>}
</summary>
{log.now && (
<p aria-label={`${log.name} now`} className="mt-1 text-ink-100">
{log.now}
</p>
)}
<ul aria-label={`${log.name} log`} className="mt-2 space-y-1 text-ink-200">
{log.entries.slice(0, shown).map((e) => (
<li key={e.id} className="flex flex-wrap items-baseline gap-x-2">
<time className="text-xs text-ink-500 tabular-nums">{entryTime(e.at, now)}</time>
<EntryLine entry={e} state={state} />
</li>
))}
</ul>
{log.entries.length > shown && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setShown(shown + PAGE)}
>
Show more ({log.entries.length - shown})
</Button>
)}
</details>
);
}
/** The logbook's figures and its pilots' sections (decision 0.5.141). */
export function PilotLogbook({ logbook, state }: { logbook: Logbook; state: PlayerState }) {
const { totals } = logbook;
return (
<section aria-label="Pilot logbook" className="space-y-3 text-sm">
<div
aria-label="Logbook totals"
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-cyan-400/40 bg-cyan-950/30 p-3 sm:grid-cols-4"
>
<span>{totals.jobs} jobs</span>
<span>{totals.deadheads} deadheads</span>
<span className="text-emerald-300">{formatCredits(totals.payout)} paid</span>
<span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span>
<span className="text-ink-100">{formatCredits(totals.fuelCost)} fuel</span>
<span className="text-ink-300">{formatCredits(totals.loanRepaid)} to the lender</span>
<span className={totals.net < 0 ? "text-rose-300" : "text-emerald-300"}>
{formatCredits(totals.net)} net
</span>
<span className="text-violet-300">+{totals.xp} XP</span>
</div>
{logbook.pilots.length === 0 ? (
<p className="text-ink-400">Nothing logged in this time.</p>
) : (
logbook.pilots.map((log) => (
<PilotSection
key={log.pilotId ?? "company"}
log={log}
state={state}
open={logbook.pilots.length === 1}
/>
))
)}
</section>
);
}
/**
* "Catching up on your fleet…" (CR-021): shown only when a catch-up has been
* running for more than `delayMs`, so quick passes never flash it.
*/
export function CatchingUp({ busy, delayMs = 500 }: { busy: boolean; delayMs?: number }) {
const [shown, setShown] = useState(false);
useEffect(() => {
if (!busy) {
setShown(false);
return;
}
const timer = setTimeout(() => setShown(true), delayMs);
return () => clearTimeout(timer);
}, [busy, delayMs]);
if (!shown) return null;
return (
<p
role="status"
className="fixed top-3 left-1/2 z-40 -translate-x-1/2 rounded-full border border-cyan-400/50 bg-white/5 px-4 py-1.5 font-display text-sm text-ink-100 shadow-panel"
>
Catching up on your fleet…
</p>
);
}