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

189 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>;
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 (
<div className="space-y-3 text-sm">
<p className="text-ink-100">{fill(def.text, { pilot })}</p>
{ship && <p className="text-ink-400">{ship}</p>}
<p aria-label="Time to answer" className="text-amber-200">
<Icon name="clock" className="mr-1.5 inline size-4 align-[-3px]" />
Answer within <Countdown to={call.windowEndsAt} format={formatClock} done="now" /> — then{" "}
{pilot} will {fallback?.label.toLowerCase()}.
</p>
<ul aria-label="Options" className="space-y-2">
{def.options.map((option) => {
const available = call.options.find((o) => o.id === option.id)?.available ?? false;
return (
<li key={option.id}>
<Button
variant={option.id === call.defaultOptionId ? "secondary" : "primary"}
className="w-full flex-col items-start text-left normal-case"
disabled={!available || answer.isPending}
onClick={() => answer.mutate({ dispatchId: call.dispatchId, optionId: option.id })}
>
<span className="font-display tracking-wide">
{option.label}
{option.id === call.defaultOptionId && " (if you don't answer)"}
</span>
<span className="text-xs font-normal tracking-normal normal-case opacity-80">
{available ? optionText(option) : "Not possible on this trip"}
</span>
</Button>
</li>
);
})}
</ul>
{answer.isError && (
<p role="alert" className="text-rose-300">
{errorMessage(answer.error)}
</p>
)}
</div>
);
}
/**
* 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 (
<Sheet
titleId="radio-title"
title={def?.title ?? "Radio call"}
eyebrow={open.length > 1 ? `Radio calls (${open.length})` : "Radio call"}
tone="amber"
layer="z-30"
>
<CallBody call={call} state={state} />
<Button variant="secondary" className="mt-3 w-full" onClick={() => putOff(call.dispatchId)}>
Decide later
</Button>
</Sheet>
);
}
/** 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 (
<Panel tone="amber" className="text-sm" aria-label="Radio calls">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
<Icon name="alert" className="size-4" />
Radio calls ({state.radioCalls.length})
</p>
<ul className="mt-2 space-y-1.5">
{state.radioCalls.map((call) => (
<li key={call.dispatchId} className="flex items-center justify-between gap-2">
<span>
{content.tripEvent(call.eventId)?.title} · {pilotName(state, call.pilotId)} ·{" "}
<Countdown to={call.windowEndsAt} format={formatClock} done="now" />
</span>
<Button size="sm" onClick={() => open(call.dispatchId)}>
Answer
</Button>
</li>
))}
</ul>
</Panel>
);
}
/**
* 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<number>());
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]);
}