189 lines
7.7 KiB
TypeScript
189 lines
7.7 KiB
TypeScript
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]);
|
||
}
|