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): 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>(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 (
{!summary ? (

Reading the logbook…

) : summary.direct ? (
    {summary.direct.map((entry) => (
  • {directText(entry as LogEntry, state)}
  • ))}
) : ( )} {summary && highlights.length > 0 && (
    {highlights.map((h, i) => (
  • {highlightText(h, state)}
  • ))} {summary.moreHighlights > 0 && (
  • {fill(words.moreHighlights, { count: summary.moreHighlights })}
  • )}
)} {summary && !summary.direct && }
); } /** 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 (
{figures.map(([label, value, tone]) => (
{label}
{value}
))}
); } /** 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 ( ); }