import { content, type EventEffects, type TripEventDef } from "@deadhead/content"; import type { PlayerState, RadioCall } from "@deadhead/schema"; import { useEffect, useRef } from "react"; import { create } from "zustand"; import { useClock } from "../clock/clock"; import { fill, formatClock, formatCredits } from "../format"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { Button, Icon, Panel, Sheet } from "../ui"; import { Countdown } from "./Countdown"; /** Calls the player has put off for now ("Decide later"); the Bridge still lists them. */ export const useRadioLater = create<{ later: ReadonlySet; putOff: (dispatchId: string) => void; open: (dispatchId: string) => void; }>((set) => ({ later: new Set(), putOff: (id) => set((s) => ({ later: new Set([...s.later, id]) })), open: (id) => set((s) => ({ later: new Set([...s.later].filter((x) => x !== id)) })), })); const range = ([low, high]: readonly [number, number], unit: string) => low === high ? `${low}${unit}` : `${low}–${high}${unit}`; /** What some effects do, in a few words: "+3 h", "hull −10–20%", "+15–40% of the pay". */ export function effectsText(effects: EventEffects): string[] { const parts: string[] = []; if (effects.delayGameHours) { const [low, high] = effects.delayGameHours; parts.push( high < 0 ? `${range([-high, -low], " h")} sooner` : `+${range(effects.delayGameHours, " h")}`, ); } if (effects.hullLoss) parts.push(`hull −${range(effects.hullLoss, "%")}`); if (effects.payLossPct) parts.push(`lose ${effects.payLossPct}% of the pay`); if (effects.feePct) { const min = effects.feeMin ? ` (at least ${formatCredits(effects.feeMin)})` : ""; parts.push(`fee ${effects.feePct}% of the pay${min}`); } if (effects.bonusPct) parts.push(`+${range(effects.bonusPct, "%")} of the pay`); if (effects.xpBonusPct) parts.push(`+${effects.xpBonusPct}% XP`); if (effects.extraFuelPct) parts.push(`${effects.extraFuelPct}% more fuel`); return parts; } /** An option's possible effects: what always happens, then each chance (CR-026 §3). */ export function optionText(option: TripEventDef["options"][number]): string { const parts = effectsText(option.effects); for (const chance of option.chances) { parts.push( `${Math.round(chance.chance * 100)}% chance: ${effectsText(chance.effects).join(", ")}`, ); } return parts.length > 0 ? parts.join(" · ") : "Nothing else happens"; } const pilotName = (state: PlayerState, id: string) => state.pilots.find((p) => p.id === id)?.name ?? "Your pilot"; /** One radio call: what's happening, the options and what they might do, and the time left. */ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) { const answer = useGameAction("answerRadioCall"); const def = content.tripEvent(call.eventId); if (!def) return null; const pilot = pilotName(state, call.pilotId); const ship = state.ships.find((s) => s.id === call.shipId)?.name; const fallback = def.options.find((o) => o.id === call.defaultOptionId); return (

{fill(def.text, { pilot })}

{ship &&

{ship}

}

Answer within — then{" "} {pilot} will {fallback?.label.toLowerCase()}.

{answer.isError && (

{errorMessage(answer.error)}

)}
); } /** * The radio call on top of the queue (decision 0.5.144), as a sheet: "Radio * calls (2)" when more are waiting. "Decide later" tucks it away; the Bridge * lists every waiting call. */ export function RadioCallSheet() { const state = useLoadedPlayer(); const later = useRadioLater((s) => s.later); const putOff = useRadioLater((s) => s.putOff); const now = useClock((s) => s.now); const open = state.radioCalls.filter((c) => c.windowEndsAt > now); const call = open.find((c) => !later.has(c.dispatchId)); // After "While you were away" is closed, as the deal waits (one sheet at a time). const awayOpen = state.summaries.length > 0 || state.ships.some((sh) => sh.shop !== null && sh.shop.until <= now); if (!call || awayOpen) return null; const def = content.tripEvent(call.eventId); return ( 1 ? `Radio calls (${open.length})` : "Radio call"} tone="amber" layer="z-30" > ); } /** The Bridge's list of waiting calls, each reopening its card (decision 0.5.144). */ export function RadioCallsPanel() { const state = useLoadedPlayer(); const open = useRadioLater((s) => s.open); if (state.radioCalls.length === 0) return null; return (

Radio calls ({state.radioCalls.length})

); } /** * Refetches when a radio call comes in and when a waiting one's window ends, * so calls appear and go on time (decision 0.5.144). Each moment once. */ export function useRefetchOnRadio(state: PlayerState | null, refetch: () => unknown) { const now = useClock((s) => s.now); const done = useRef(new Set()); useEffect(() => { if (!state) return; const moments = [ ...(state.nextRadioCallAt !== null ? [state.nextRadioCallAt] : []), ...state.radioCalls.map((c) => c.windowEndsAt), ].filter((t) => t <= now && !done.current.has(t)); if (moments.length === 0) return; for (const t of moments) done.current.add(t); void refetch(); }, [state, now, refetch]); }