deadhead-space-freight-tycoon/apps/web/src/screens/DevTools.tsx

232 lines
7.8 KiB
TypeScript

import type { PlayerState, RouteRequest } from "@deadhead/schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useApi } from "../api/context";
import { serverNow, syncClock } from "../clock/clock";
import { locationName } from "../format";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Button } from "../ui";
const MINUTE = 60_000;
const selectClass =
"min-w-0 flex-1 rounded-lg border border-glass-line bg-space-900 px-2 py-1.5 text-sm text-ink-100";
/**
* Settings → Developer (non-production builds only, CR-011): fast-forward the
* server clock, add credits, land every flight now, and adjust pilots, ships
* and discoveries for testing.
*/
export function DevTools() {
const api = useApi();
const queryClient = useQueryClient();
const state = useLoadedPlayer();
const refresh = async () => {
await syncClock(api);
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
await queryClient.invalidateQueries({ queryKey: ["board"] });
};
/** The pilot, ship and discovery tools answer with the new state. */
const applyState = async (next: PlayerState) => {
queryClient.setQueryData(PLAYER_STATE_KEY, next);
await queryClient.invalidateQueries({ queryKey: ["board"] });
};
const advance = useMutation({
mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }),
onSuccess: refresh,
});
const credits = useMutation({
mutationFn: () => api.call("devAddCredits", { amount: 10_000 }),
onSuccess: refresh,
});
const pilotTool = useMutation({
mutationFn: (input: RouteRequest<"devPilot">) => api.call("devPilot", input),
onSuccess: applyState,
});
const shipTool = useMutation({
mutationFn: (input: RouteRequest<"devShip">) => api.call("devShip", input),
onSuccess: applyState,
});
const discoverAll = useMutation({
mutationFn: () => api.call("devDiscoverAll", {}),
onSuccess: applyState,
});
const [pilotId, setPilotId] = useState(state.pilots[0]?.id ?? "");
const [shipId, setShipId] = useState(state.ships[0]?.id ?? "");
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
const [destination, setDestination] = useState(discovered[0] ?? "earth");
const pilot = state.pilots.find((p) => p.id === pilotId);
const ship = state.ships.find((s) => s.id === shipId);
const lastArrival = Math.max(0, ...state.activeDispatches.map((d) => d.arrivalAt));
const busy =
advance.isPending ||
credits.isPending ||
pilotTool.isPending ||
shipTool.isPending ||
discoverAll.isPending;
const error =
advance.error ?? credits.error ?? pilotTool.error ?? shipTool.error ?? discoverAll.error;
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => advance.mutate(10 * MINUTE)}
>
Advance 10 minutes
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => advance.mutate(60 * MINUTE)}
>
Advance 1 hour
</Button>
<Button variant="secondary" size="sm" disabled={busy} onClick={() => credits.mutate()}>
Add 10,000 credits
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || lastArrival <= serverNow()}
onClick={() =>
advance.mutate(Math.max(1000, Math.ceil(lastArrival - serverNow()) + 1000))
}
>
Finish all flights
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || discovered.length === state.locations.length}
onClick={() => discoverAll.mutate()}
>
Discover everywhere
</Button>
</div>
{state.pilots.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs text-ink-400">Pilot</legend>
<select
aria-label="Pilot"
value={pilotId}
onChange={(e) => setPilotId(e.target.value)}
className={selectClass}
>
{state.pilots.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · level {p.level} · {p.xp} XP
</option>
))}
</select>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={busy || !pilot}
onClick={() => pilotTool.mutate({ pilotId, action: "level-up" })}
>
Level up
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || !pilot || (pilot.dutyHours === 0 && !pilot.resting)}
onClick={() => pilotTool.mutate({ pilotId, action: "clear-rest" })}
>
Clear duty and rest
</Button>
</div>
</fieldset>
)}
{state.ships.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs text-ink-400">Ship</legend>
<select
aria-label="Ship"
value={shipId}
onChange={(e) => setShipId(e.target.value)}
className={selectClass}
>
{state.ships.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {s.modelId} · {s.locationId ? locationName(s.locationId) : "in flight"}
</option>
))}
</select>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={busy || !ship}
onClick={() => shipTool.mutate({ shipId, action: "max-upgrades" })}
>
Max upgrades
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || !ship || Object.keys(ship.upgrades).length === 0}
onClick={() => shipTool.mutate({ shipId, action: "reset-upgrades" })}
>
Reset upgrades
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || !ship || ship.inShopUntil === null}
onClick={() => shipTool.mutate({ shipId, action: "finish-shop" })}
>
Finish shop work
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || !ship}
onClick={() => shipTool.mutate({ shipId, action: "damage-hull" })}
>
Damage hull
</Button>
</div>
<div className="flex gap-2">
<select
aria-label="Move to"
value={destination}
onChange={(e) => setDestination(e.target.value)}
className={selectClass}
>
{discovered.map((id) => (
<option key={id} value={id}>
{locationName(id)}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
disabled={busy || !ship || ship.status !== "idle" || ship.locationId === destination}
onClick={() => shipTool.mutate({ shipId, action: "move", locationId: destination })}
>
Move here
</Button>
</div>
</fieldset>
)}
{error && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(error)}
</p>
)}
</div>
);
}