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 => 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) => ( {locationName(from)} → {locationName(to)} ); const fuel = (cost: number) => fuel {formatCredits(cost)}; switch (entry.kind) { case "flight": return entry.deadhead ? ( <> Deadhead {route(entry.originId, entry.destinationId)} {fuel(entry.fuelCost)} ) : ( <> {(entry.goodId && content.good(entry.goodId)?.name) || "Cargo"} {route(entry.originId, entry.destinationId)} {formatCredits(entry.payout)} {entry.pilotPay > 0 && ( share {formatCredits(entry.pilotPay)} )} {fuel(entry.fuelCost)} {entry.loanRepayment > 0 && ( lender {formatCredits(entry.loanRepayment)} )} {entry.xp > 0 && +{entry.xp} XP} ); case "refuel": return ( {Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "} {formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]}) ); case "rest": return ( Rested until {formatLocalTime(entry.until)} ); case "upgrade": return ( {shipName(state, entry.shipId)}:{" "} {content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished ); case "discovery": return Discovered {locationName(entry.locationId)}; case "level-up": return ( Reached level {entry.level} · {entry.skillPoints}{" "} {entry.skillPoints === 1 ? "skill point" : "skill points"} ); case "event": return ( {eventResultText(entry, state)} ); case "repair": return ( {shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "} {formatCredits(entry.cost)} ); case "service": return ( {serviceEntryText(entry, shipName(state, entry.shipId))} ); case "goal-ready": return ( Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId} ); } } /** 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 (
{log.name} {log.xp > 0 && +{log.xp} XP} {log.now && (

{log.now}

)}
    {log.entries.slice(0, shown).map((e) => (
  • ))}
{log.entries.length > shown && ( )}
); } /** 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 (
{totals.jobs} jobs {totals.deadheads} deadheads {formatCredits(totals.payout)} paid {formatCredits(totals.pilotShare)} pilot shares {formatCredits(totals.fuelCost)} fuel {formatCredits(totals.loanRepaid)} to the lender {formatCredits(totals.net)} net +{totals.xp} XP
{logbook.pilots.length === 0 ? (

Nothing logged in this time.

) : ( logbook.pilots.map((log) => ( )) )}
); } /** * "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 (

Catching up on your fleet…

); }