294 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|