diff --git a/apps/mapgen-studio/src/app/StudioShell.tsx b/apps/mapgen-studio/src/app/StudioShell.tsx index 18556049b0..b7441c1f1b 100644 --- a/apps/mapgen-studio/src/app/StudioShell.tsx +++ b/apps/mapgen-studio/src/app/StudioShell.tsx @@ -17,13 +17,11 @@ import type { GenerationStatus, OverlayOption, PipelineConfig, - RecipeSettings, RenderModeOption, SpaceOption, StageOption, StepOption, VariantOption, - WorldSettings, } from "../ui/types"; import { useBrowserRunner } from "../features/browserRunner/useBrowserRunner"; @@ -33,27 +31,21 @@ import { buildRunInGameClientSnapshot, buildRunInGameFingerprint, buildRunInGameSourceSnapshot, - parseRunInGameClientSnapshot, relationForRunInGameOperation, - type RunInGameClientSnapshot, type RunInGameCurrentRelation, - type RunInGameSourceSnapshot, } from "../features/runInGame/clientState"; import { formatRunInGameDiagnostics, - isRunInGameTerminalPhase, runInGameRequiresProcessRestart, type RunInGameOperationStatus, } from "../features/runInGame/status"; import { createStudioCiv7ControlOrpcClient } from "../features/runInGame/civ7ControlOrpcClient"; import { - DEFAULT_CIV7_STUDIO_SETUP_CONFIG, getLocalPlayerSetup, normalizeStudioSetupConfig, optionRowsFromParameter, studioSetupConfigFromLiveSnapshot, updateStudioSetupSavedConfig, - type Civ7SavedSetupConfigFile, type Civ7SetupSnapshotLike, type Civ7StudioSetupConfig, } from "../features/civ7Setup/setupConfig"; @@ -103,11 +95,6 @@ import { parsePresetKey, type PresetKey } from "../features/presets/types"; import { usePresets } from "../features/presets/usePresets"; import { migratePipelineConfig } from "../features/configMigrations/pipelineConfig"; import { - loadStudioAuthoringState, - saveStudioAuthoringState, -} from "../features/studioState/persistence"; -import { - DEFAULT_STUDIO_RECIPE_ID, findRecipeArtifacts, getRecipeArtifacts, STUDIO_RECIPE_OPTIONS, @@ -117,28 +104,22 @@ import { getOverlaySuggestions } from "../recipes/overlaySuggestions"; import { orpcClient } from "../lib/orpc"; import { useViewStore } from "../stores/viewStore"; +import { useAuthoringStore } from "../stores/authoringStore"; +import { useRunStore } from "../stores/runStore"; +import { useSetupDataQueries } from "./hooks/useSetupDataQueries"; +import { useOperationStatusPolls } from "./hooks/useOperationStatusPolls"; import { isAbortLikeError } from "../shared/async"; import { clampNumber } from "../shared/number"; import { toConfigId, saveRepoBackedConfig, fetchMapConfigSaveDeployStatus, - MAP_CONFIG_SAVE_LAST_REQUEST_KEY, } from "../features/mapConfigSave/api"; import { runCurrentConfigInGame, fetchRunInGameStatus } from "../features/runInGame/api"; -import { - RUN_IN_GAME_LAST_REQUEST_KEY, - RUN_IN_GAME_LAST_SNAPSHOT_KEY, - RUN_IN_GAME_LAST_SOURCE_KEY, - readStoredRunInGameSourceSnapshot, -} from "../features/runInGame/sourceSnapshotStorage"; -import { liveSourceMatchesStudio, type LastRunSnapshot } from "../features/runInGame/liveSource"; +import { liveSourceMatchesStudio } from "../features/runInGame/liveSource"; import { fetchCiv7SetupConfig, - fetchCiv7SavedSetupConfigs, - fetchCiv7SetupCatalog, requestCiv7Autoplay, - type Civ7SetupCatalog, } from "../features/civ7Setup/api"; import { findSetupParameterLike, @@ -189,11 +170,28 @@ export function StudioShell(props: StudioShellProps) { const toast = useToast(); const { themePreference, isLightMode, cyclePreference } = props; const theme = useMemo(() => createTheme(isLightMode), [isLightMode]); - const initialAuthoringStateRef = useRef | undefined>(undefined); - if (initialAuthoringStateRef.current === undefined) { - initialAuthoringStateRef.current = loadStudioAuthoringState(); - } - const initialAuthoringState = initialAuthoringStateRef.current; + + // Authoring state is owned by `authoringStore` (Zustand persist, architecture/10 §3). + // The store seeds itself from the reference persistence (`loadStudioAuthoringState`) + // and persists through the same serializer — so the on-disk schema is byte-identical + // and the prior manual `saveStudioAuthoringState` effect is gone. Setters mirror the + // `useState` (value-or-updater) signature, so the call sites below are unchanged. + const worldSettings = useAuthoringStore((s) => s.worldSettings); + const setWorldSettings = useAuthoringStore((s) => s.setWorldSettings); + const recipeSettings = useAuthoringStore((s) => s.recipeSettings); + const setRecipeSettings = useAuthoringStore((s) => s.setRecipeSettings); + const setupConfig = useAuthoringStore((s) => s.setupConfig); + const setSetupConfig = useAuthoringStore((s) => s.setSetupConfig); + const pipelineConfig = useAuthoringStore((s) => s.pipelineConfig); + const setPipelineConfig = useAuthoringStore((s) => s.setPipelineConfig); + const overridesDisabled = useAuthoringStore((s) => s.overridesDisabled); + const setOverridesDisabled = useAuthoringStore((s) => s.setOverridesDisabled); + const repoBackedPresetOverridesByRecipe = useAuthoringStore( + (s) => s.repoBackedPresetOverridesByRecipe, + ); + const setRepoBackedPresetOverridesByRecipe = useAuthoringStore( + (s) => s.setRepoBackedPresetOverridesByRecipe, + ); const deckApiRef = useRef(null); const [deckApiReadyTick, setDeckApiReadyTick] = useState(0); @@ -234,20 +232,6 @@ export function StudioShell(props: StudioShellProps) { const autoRunTimerRef = useRef(null); const autoRunPendingRef = useRef(false); - const [worldSettings, setWorldSettings] = useState(() => initialAuthoringState?.worldSettings ?? { - mapSize: "MAPSIZE_STANDARD", - playerCount: 6, - resources: "balanced", - }); - - const [recipeSettings, setRecipeSettings] = useState(() => initialAuthoringState?.recipeSettings ?? { - recipe: DEFAULT_STUDIO_RECIPE_ID, - preset: "none", - seed: "123", - }); - const [setupConfig, setSetupConfig] = useState(() => - initialAuthoringState?.setupConfig ?? DEFAULT_CIV7_STUDIO_SETUP_CONFIG - ); const [presetError, setPresetError] = useState(null); const [saveDialogState, setSaveDialogState] = useState<{ open: boolean; label: string; description?: string }>({ open: false, @@ -259,12 +243,11 @@ export function StudioShell(props: StudioShellProps) { const lastAppliedPresetRef = useRef(null); const lastPresetKeyRef = useRef(recipeSettings.preset); const lastRecipeIdRef = useRef(recipeSettings.recipe); - const [repoBackedPresetOverridesByRecipe, setRepoBackedPresetOverridesByRecipe] = useState< - Record> - >(() => initialAuthoringState?.repoBackedPresetOverridesByRecipe ?? {}); - const [lastRunInGameSource, setLastRunInGameSource] = useState(() => - readStoredRunInGameSourceSnapshot() - ); + // `lastRunInGameSource` is owned by `runStore` (persisted via the existing + // RUN_IN_GAME_LAST_SOURCE_KEY bridge); the prior `readStoredRunInGameSourceSnapshot` + // mount read now seeds the store directly. + const lastRunInGameSource = useRunStore((s) => s.lastRunInGameSource); + const setLastRunInGameSource = useRunStore((s) => s.setLastRunInGameSource); const [runInGameOperation, setRunInGameOperation] = useState(null); const overlaySuggestions = useMemo(() => getOverlaySuggestions(recipeSettings.recipe), [recipeSettings.recipe]); @@ -314,13 +297,10 @@ export function StudioShell(props: StudioShellProps) { livePresets, }); const isLocalPresetSelected = parsePresetKey(recipeSettings.preset).kind === "local"; - const [pipelineConfig, setPipelineConfig] = useState(() => { - if (initialAuthoringState?.pipelineConfig) return initialAuthoringState.pipelineConfig; - const artifacts = recipeArtifacts; - return buildDefaultConfig(artifacts.configSchema, artifacts.uiMeta, artifacts.defaultConfig); - }); - const [overridesDisabled, setOverridesDisabled] = useState(() => initialAuthoringState?.overridesDisabled ?? false); - const [lastRunSnapshot, setLastRunSnapshot] = useState(null); + // `lastRunSnapshot` is session-only run state owned by `runStore` (not persisted — + // parity with the prior in-memory `useState`). + const lastRunSnapshot = useRunStore((s) => s.lastRunSnapshot); + const setLastRunSnapshot = useRunStore((s) => s.setLastRunSnapshot); useEffect(() => { const previousPreset = lastPresetKeyRef.current; @@ -394,16 +374,8 @@ export function StudioShell(props: StudioShellProps) { toast(loadWarning, { variant: "info" }); }, [loadWarning, toast]); - useEffect(() => { - saveStudioAuthoringState({ - worldSettings, - recipeSettings, - setupConfig, - pipelineConfig, - overridesDisabled, - repoBackedPresetOverridesByRecipe, - }); - }, [overridesDisabled, pipelineConfig, recipeSettings, repoBackedPresetOverridesByRecipe, setupConfig, worldSettings]); + // Authoring persistence is now driven by `authoringStore`'s `persist` middleware + // (same serializer, same key, same schema) — the prior manual save effect is removed. const vizIngestRef = useRef<(event: VizEvent) => void>(() => {}); const handleVizEvent = useCallback((event: VizEvent) => { @@ -418,8 +390,14 @@ export function StudioShell(props: StudioShellProps) { const browserRunning = browserRunner.state.running; const [localError, setLocalError] = useState(null); const [saveDeployOperation, setSaveDeployOperation] = useState(null); - const [runInGameSnapshot, setRunInGameSnapshot] = useState(null); - const [lastSaveDeployConfig, setLastSaveDeployConfig] = useState(null); + // `runInGameSnapshot` is owned by `runStore` (persisted via RUN_IN_GAME_LAST_SNAPSHOT_KEY); + // `lastSaveDeployConfig` is session-only run state owned by `runStore` (not persisted). + const runInGameSnapshot = useRunStore((s) => s.runInGameSnapshot); + const setRunInGameSnapshot = useRunStore((s) => s.setRunInGameSnapshot); + const lastSaveDeployConfig = useRunStore((s) => s.lastSaveDeployConfig); + const setLastSaveDeployConfig = useRunStore((s) => s.setLastSaveDeployConfig); + const setRunInGameRequestId = useRunStore((s) => s.setRunInGameRequestId); + const setSaveDeployRequestId = useRunStore((s) => s.setSaveDeployRequestId); const lastRunInGameToastRef = useRef(null); const liveStatusFailureCountRef = useRef(0); const liveSnapshotFailureCountRef = useRef(0); @@ -439,19 +417,11 @@ export function StudioShell(props: StudioShellProps) { updatedAt?: string; error?: string; }>({ status: "idle" }); - const [savedSetupConfigs, setSavedSetupConfigs] = useState<{ - status: "idle" | "ok" | "error"; - directory?: string; - configurations: ReadonlyArray; - updatedAt?: string; - error?: string; - }>({ status: "idle", configurations: [] }); - const [setupCatalog, setSetupCatalog] = useState<{ - status: "idle" | "ok" | "error"; - catalog?: Civ7SetupCatalog; - updatedAt?: string; - error?: string; - }>({ status: "idle" }); + // Saved configs + setup catalog are READ through oRPC-native TanStack Query + // (architecture/10 §2). The query layer owns retry + refetch-on-focus (query client + // defaults), replacing the prior hand-rolled load/retry/focus effect; the derived view + // shapes are unchanged so `setupControlOptions` below consumes them as before. + const { savedSetupConfigs, setupCatalog } = useSetupDataQueries(); const [autoplayActionRunning, setAutoplayActionRunning] = useState(false); const saveDeployRunning = saveDeployOperation?.status === "running"; const runInGameRunning = runInGameOperation?.status === "running"; @@ -693,66 +663,9 @@ export function StudioShell(props: StudioShellProps) { }; }, []); - useEffect(() => { - let cancelled = false; - let retryTimer: number | null = null; - - const load = async () => { - const [configs, catalog] = await Promise.all([ - fetchCiv7SavedSetupConfigs(), - fetchCiv7SetupCatalog(), - ]); - if (cancelled) return; - if (configs.ok) { - setSavedSetupConfigs({ - status: "ok", - directory: configs.directory, - configurations: configs.configurations, - updatedAt: configs.observedAt, - }); - } else { - setSavedSetupConfigs({ - status: "error", - configurations: [], - error: configs.error, - updatedAt: configs.observedAt ?? new Date().toISOString(), - }); - } - if (catalog.ok) { - setSetupCatalog({ - status: "ok", - catalog: catalog.catalog, - updatedAt: catalog.catalog.observedAt, - }); - } else { - setSetupCatalog({ - status: "error", - error: catalog.error, - updatedAt: catalog.observedAt ?? new Date().toISOString(), - }); - } - - if (!configs.ok || !catalog.ok) { - retryTimer = window.setTimeout(load, document.hidden ? 10000 : 3000); - } - }; - - const loadNow = () => { - if (retryTimer !== null) { - window.clearTimeout(retryTimer); - retryTimer = null; - } - void load(); - }; - - void load(); - window.addEventListener("focus", loadNow); - return () => { - cancelled = true; - if (retryTimer !== null) window.clearTimeout(retryTimer); - window.removeEventListener("focus", loadNow); - }; - }, []); + // Saved configs + setup catalog now load via `useSetupDataQueries` (TanStack Query); + // the prior hand-rolled load/retry/focus-refetch effect is replaced by the query + // client's `retry` + `refetchOnWindowFocus` defaults. useEffect(() => { const el = containerRef.current; @@ -980,22 +893,16 @@ export function StudioShell(props: StudioShellProps) { const initial = createMapConfigSaveDeployStatus({ requestId, phase: "queued" }); setSaveDeployOperation(initial); - try { - localStorage.setItem(MAP_CONFIG_SAVE_LAST_REQUEST_KEY, requestId); - } catch { - // Server status remains authoritative while the dev server is alive. - } + // The save/deploy request-id bridge is owned by `runStore` (persisted via the + // existing MAP_CONFIG_SAVE_LAST_REQUEST_KEY through the store's fan-out adapter). + setSaveDeployRequestId(requestId); const result = await saveRepoBackedConfig({ ...args, requestId, onStatus: (status) => { setSaveDeployOperation((current) => current?.requestId === requestId ? status : current); - try { - localStorage.setItem(MAP_CONFIG_SAVE_LAST_REQUEST_KEY, status.requestId); - } catch { - // Server status remains authoritative while the dev server is alive. - } + setSaveDeployRequestId(status.requestId); }, }); setSaveDeployOperation((current) => { @@ -1011,7 +918,7 @@ export function StudioShell(props: StudioShellProps) { }); if (result.ok) setLastSaveDeployConfig(stripSchemaMetadataRoot(args.config)); return result; - }, [browserRunning, runInGameRunning, saveDeployRunning]); + }, [browserRunning, runInGameRunning, saveDeployRunning, setLastSaveDeployConfig, setSaveDeployRequestId]); const handleSaveDialogConfirm = useCallback( async (args: { label: string; description?: string }) => { @@ -1492,12 +1399,10 @@ export function StudioShell(props: StudioShellProps) { return; } setRunInGameOperation(result); - try { - localStorage.setItem(RUN_IN_GAME_LAST_REQUEST_KEY, result.requestId); - } catch { - // Server status remains authoritative while the dev server is alive. - } - }, []); + // The request-id bridge is owned by `runStore` (persisted via the existing + // RUN_IN_GAME_LAST_REQUEST_KEY through the store's fan-out adapter). + setRunInGameRequestId(result.requestId); + }, [setRunInGameRequestId]); const refreshMapConfigSaveDeployStatus = useCallback(async (requestId: string) => { const result = await fetchMapConfigSaveDeployStatus(requestId); @@ -1515,25 +1420,22 @@ export function StudioShell(props: StudioShellProps) { return; } setSaveDeployOperation(result); - try { - localStorage.setItem(MAP_CONFIG_SAVE_LAST_REQUEST_KEY, result.requestId); - } catch { - // Server status remains authoritative while the dev server is alive. - } - }, []); + // The request-id bridge is owned by `runStore` (persisted via the existing + // MAP_CONFIG_SAVE_LAST_REQUEST_KEY through the store's fan-out adapter). + setSaveDeployRequestId(result.requestId); + }, [setSaveDeployRequestId]); + + // Mount-time restore: the run-in-game snapshot + the active request ids are already + // seeded into `runStore` from the existing localStorage bridge at store init, so this + // effect just resumes the status poll for any persisted request id (running the same + // `refreshRunInGameStatus` it did before). `runInGameSnapshot` is read live from the + // store, so the prior `setRunInGameSnapshot(snapshot)` rehydration is no longer needed. + const restoredRunInGameRequestIdRef = useRef(useRunStore.getState().runInGameRequestId); + const restoredSaveDeployRequestIdRef = useRef(useRunStore.getState().saveDeployRequestId); useEffect(() => { let cancelled = false; - let requestId: string | null = null; - let snapshot: RunInGameClientSnapshot | null = null; - try { - requestId = localStorage.getItem(RUN_IN_GAME_LAST_REQUEST_KEY); - snapshot = parseRunInGameClientSnapshot(localStorage.getItem(RUN_IN_GAME_LAST_SNAPSHOT_KEY)); - } catch { - requestId = null; - snapshot = null; - } - if (snapshot) setRunInGameSnapshot(snapshot); + const requestId = restoredRunInGameRequestIdRef.current; if (!requestId) return undefined; void refreshRunInGameStatus(requestId).then(() => { if (cancelled) return; @@ -1544,52 +1446,24 @@ export function StudioShell(props: StudioShellProps) { }, [refreshRunInGameStatus]); useEffect(() => { - let requestId: string | null = null; - try { - requestId = localStorage.getItem(MAP_CONFIG_SAVE_LAST_REQUEST_KEY); - } catch { - requestId = null; - } + const requestId = restoredSaveDeployRequestIdRef.current; if (!requestId) return undefined; void refreshMapConfigSaveDeployStatus(requestId); return undefined; }, [refreshMapConfigSaveDeployStatus]); - useEffect(() => { - if (!saveDeployOperation || saveDeployOperation.status !== "running") return undefined; - let cancelled = false; - const timer = window.setTimeout(() => { - if (!cancelled) void refreshMapConfigSaveDeployStatus(saveDeployOperation.requestId); - }, document.hidden ? 3000 : 1000); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [refreshMapConfigSaveDeployStatus, saveDeployOperation]); - - useEffect(() => { - if (!runInGameOperation) return undefined; - if (isRunInGameTerminalPhase(runInGameOperation.phase)) { - if (lastRunInGameToastRef.current !== runInGameOperation.requestId) { - lastRunInGameToastRef.current = runInGameOperation.requestId; - if (runInGameOperation.status === "complete") { - toast(`Run in Game complete: ${runInGameOperation.materialization?.mapScript ?? runInGameOperation.requestId}`, { variant: "success" }); - } else if (runInGameOperation.status !== "running") { - toast(`Run in Game ${runInGameOperation.status}: ${runInGameOperation.error ?? runInGameOperation.requestId}`, { variant: "error" }); - } - } - return undefined; - } - - let cancelled = false; - const timer = window.setTimeout(() => { - if (!cancelled) void refreshRunInGameStatus(runInGameOperation.requestId); - }, document.hidden ? 3000 : 1000); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [refreshRunInGameStatus, runInGameOperation, toast]); + // Run-in-game + save-deploy status polling is now driven by TanStack Query + // `refetchInterval` (same cadence, terminal stop, and 404 mapping — see + // `useOperationStatusPolls`); the prior self-rescheduling `setTimeout` effects and the + // terminal-toast effect are consolidated there. + useOperationStatusPolls({ + runInGameOperation, + saveDeployOperation, + refreshRunInGameStatus, + refreshMapConfigSaveDeployStatus, + lastRunInGameToastRef, + toast, + }); const handleRunInGame = useCallback(async (options?: { restartCivProcess?: boolean }) => { if (runInGameRunning || saveDeployRunning) return; @@ -1670,13 +1544,10 @@ export function StudioShell(props: StudioShellProps) { selectedConfig, }); setLastRunInGameSource(sourceSnapshot); - try { - localStorage.setItem(RUN_IN_GAME_LAST_REQUEST_KEY, result.requestId); - localStorage.setItem(RUN_IN_GAME_LAST_SNAPSHOT_KEY, JSON.stringify(snapshot)); - localStorage.setItem(RUN_IN_GAME_LAST_SOURCE_KEY, JSON.stringify(sourceSnapshot)); - } catch { - // Server status remains authoritative while the dev server is alive. - } + // The run-in-game bridge (request id + snapshot + source) is owned by `runStore`; + // the store's fan-out persist adapter writes the same RUN_IN_GAME_LAST_REQUEST_KEY / + // _SNAPSHOT_KEY / _SOURCE_KEY localStorage entries (same serializers) on each set. + setRunInGameRequestId(result.requestId); toast(`Run in Game started: ${result.materialization?.mapScript ?? result.requestId}`, { variant: "info" }); }, [ pipelineConfig, @@ -1687,6 +1558,9 @@ export function StudioShell(props: StudioShellProps) { runInGameMaterializationMode, runInGameRunning, saveDeployRunning, + setLastRunInGameSource, + setRunInGameRequestId, + setRunInGameSnapshot, setupConfig, toast, worldSettings, diff --git a/apps/mapgen-studio/src/app/hooks/useOperationStatusPolls.ts b/apps/mapgen-studio/src/app/hooks/useOperationStatusPolls.ts new file mode 100644 index 0000000000..9b7c6b9a4d --- /dev/null +++ b/apps/mapgen-studio/src/app/hooks/useOperationStatusPolls.ts @@ -0,0 +1,110 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { + isRunInGameTerminalPhase, + type RunInGameOperationStatus, +} from "../../features/runInGame/status"; +import type { MapConfigSaveDeployStatus } from "../../features/mapConfigSave/status"; +import type { ToastFn } from "./useToast"; + +/** + * `useOperationStatusPolls` — the run-in-game + save-deploy STATUS POLLS, realised as + * TanStack Query `refetchInterval` polls (architecture/10 §2/§3) instead of the prior + * self-rescheduling `setTimeout` effects. + * + * PARITY (architecture/10 §7 — run-in-game/save status is do-not-break): this MOVES the + * scheduling, it does NOT rewrite the fetch/merge logic. Each poll's `queryFn` calls the + * SAME `refresh*` callback the `setTimeout` did (which performs the oRPC status read and + * merges the result — including the 404 → synthetic `uncertain` / `operation-status-missing` + * mapping — into the operation state). Cadence is preserved exactly: + * - `enabled` only while there is an active, non-terminal/running operation (matches the + * prior effect's early-return guards), so a terminal operation STOPS polling; + * - `refetchInterval` is `document.hidden ? 3000 : 1000` ms, the prior `setTimeout` delay; + * - the query is seeded with `initialData` (the current operation) and `staleTime` equal to + * the interval, so there is NO immediate mount fetch — the first refetch fires after the + * interval, exactly as the prior "schedule a timeout, then refresh" flow did. The start / + * save mutations seed the operation state synchronously (unchanged), so the poll picks up + * from the seeded value without an extra round-trip. + * + * The run-in-game terminal TOAST is a derived-state effect, NOT part of the poll — it stays + * here as a small effect on the operation (verbatim from the prior poll effect's terminal + * branch) so the poll hook owns the full run-in-game status lifecycle. + */ +export function useOperationStatusPolls(args: { + runInGameOperation: RunInGameOperationStatus | null; + saveDeployOperation: MapConfigSaveDeployStatus | null; + refreshRunInGameStatus: (requestId: string) => Promise; + refreshMapConfigSaveDeployStatus: (requestId: string) => Promise; + lastRunInGameToastRef: React.MutableRefObject; + toast: ToastFn; +}): void { + const { + runInGameOperation, + saveDeployOperation, + refreshRunInGameStatus, + refreshMapConfigSaveDeployStatus, + lastRunInGameToastRef, + toast, + } = args; + + const pollDelayMs = (): number => (document.hidden ? 3000 : 1000); + + // --- run-in-game status poll --------------------------------------------------------- + const runInGameActive = + runInGameOperation !== null && !isRunInGameTerminalPhase(runInGameOperation.phase); + const runInGameRequestId = runInGameActive ? runInGameOperation.requestId : null; + + useQuery({ + queryKey: ["studio", "runInGame", "status-poll", runInGameRequestId], + queryFn: async () => { + if (runInGameRequestId) await refreshRunInGameStatus(runInGameRequestId); + return Date.now(); + }, + enabled: runInGameRequestId !== null, + // No immediate mount fetch: the seeded operation is fresh for one interval, so the first + // network refresh is the `refetchInterval`-driven one (prior `setTimeout` cadence). + initialData: () => Date.now(), + staleTime: pollDelayMs(), + refetchInterval: () => (runInGameRequestId !== null ? pollDelayMs() : false), + refetchIntervalInBackground: true, + gcTime: 0, + }); + + // Terminal toast (derived-state effect, verbatim from the prior poll effect's terminal + // branch) — fires once per terminal requestId. + useEffect(() => { + if (!runInGameOperation) return; + if (!isRunInGameTerminalPhase(runInGameOperation.phase)) return; + if (lastRunInGameToastRef.current === runInGameOperation.requestId) return; + lastRunInGameToastRef.current = runInGameOperation.requestId; + if (runInGameOperation.status === "complete") { + toast( + `Run in Game complete: ${runInGameOperation.materialization?.mapScript ?? runInGameOperation.requestId}`, + { variant: "success" }, + ); + } else if (runInGameOperation.status !== "running") { + toast(`Run in Game ${runInGameOperation.status}: ${runInGameOperation.error ?? runInGameOperation.requestId}`, { + variant: "error", + }); + } + }, [lastRunInGameToastRef, runInGameOperation, toast]); + + // --- save-deploy status poll --------------------------------------------------------- + const saveDeployActive = saveDeployOperation !== null && saveDeployOperation.status === "running"; + const saveDeployRequestId = saveDeployActive ? saveDeployOperation.requestId : null; + + useQuery({ + queryKey: ["studio", "mapConfigSave", "status-poll", saveDeployRequestId], + queryFn: async () => { + if (saveDeployRequestId) await refreshMapConfigSaveDeployStatus(saveDeployRequestId); + return Date.now(); + }, + enabled: saveDeployRequestId !== null, + initialData: () => Date.now(), + staleTime: pollDelayMs(), + refetchInterval: () => (saveDeployRequestId !== null ? pollDelayMs() : false), + refetchIntervalInBackground: true, + gcTime: 0, + }); +} diff --git a/apps/mapgen-studio/src/app/hooks/useSetupDataQueries.ts b/apps/mapgen-studio/src/app/hooks/useSetupDataQueries.ts new file mode 100644 index 0000000000..2d0bbd7050 --- /dev/null +++ b/apps/mapgen-studio/src/app/hooks/useSetupDataQueries.ts @@ -0,0 +1,96 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { Civ7SetupCatalog } from "../../features/civ7Setup/api"; +import type { Civ7SavedSetupConfigFile } from "../../features/civ7Setup/setupConfig"; +import { orpc } from "../../lib/orpc"; + +/** + * `useSetupDataQueries` — the saved-configs + setup-catalog READ surface, realised as + * oRPC-native TanStack Query (architecture/10 §2: `orpc...queryOptions()` into + * `useQuery`). It replaces the hand-rolled `StudioShell` load/retry/focus-refetch effect. + * + * Parity: + * - Retry-on-failure and refetch-on-window-focus are provided by the shared query + * client defaults (`src/lib/query.ts`: `retry: 1`, `refetchOnWindowFocus: true`), + * matching the legacy retry timer + `window.addEventListener("focus", …)`. + * - The derived view shapes (`{ status, directory, configurations, updatedAt, error }` / + * `{ status, catalog, updatedAt, error }`) are byte-for-byte the same objects + * `setupControlOptions` and the rest of the shell consumed before — only their source + * moved from `useState` to query state. `status` is `"idle"` until the first settle, + * then `"ok"` / `"error"`; `updatedAt`/`observedAt` fall back to the body value then + * `new Date().toISOString()`, exactly as the prior wrappers did. + * + * The oRPC client throws `ORPCError` on failure, which `useQuery` surfaces as `error`; + * the non-uniform status code is not consumed by these reads (the legacy wrappers only + * read `error`/`observedAt` here), so it is intentionally not threaded through. + */ + +export type SavedSetupConfigsView = { + status: "idle" | "ok" | "error"; + directory?: string; + configurations: ReadonlyArray; + updatedAt?: string; + error?: string; +}; + +export type SetupCatalogView = { + status: "idle" | "ok" | "error"; + catalog?: Civ7SetupCatalog; + updatedAt?: string; + error?: string; +}; + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +export function useSetupDataQueries(): { + savedSetupConfigs: SavedSetupConfigsView; + setupCatalog: SetupCatalogView; +} { + const savedConfigsQuery = useQuery(orpc.civ7.savedConfigs.queryOptions({ input: {} })); + const setupCatalogQuery = useQuery(orpc.civ7.setupCatalog.queryOptions({ input: {} })); + + const savedSetupConfigs = useMemo(() => { + if (savedConfigsQuery.isError) { + return { + status: "error", + configurations: [], + error: errorMessage(savedConfigsQuery.error, "Civ7 saved configurations unavailable"), + updatedAt: new Date().toISOString(), + }; + } + const body = savedConfigsQuery.data; + if (!body) { + return { status: "idle", configurations: [] }; + } + return { + status: "ok", + directory: body.directory ?? "", + configurations: body.configurations as unknown as ReadonlyArray, + updatedAt: body.observedAt ?? new Date().toISOString(), + }; + }, [savedConfigsQuery.data, savedConfigsQuery.error, savedConfigsQuery.isError]); + + const setupCatalog = useMemo(() => { + if (setupCatalogQuery.isError) { + return { + status: "error", + error: errorMessage(setupCatalogQuery.error, "Civ7 setup catalog unavailable"), + updatedAt: new Date().toISOString(), + }; + } + const body = setupCatalogQuery.data; + if (!body) { + return { status: "idle" }; + } + const catalog = body.catalog as unknown as Civ7SetupCatalog; + return { + status: "ok", + catalog, + updatedAt: catalog.observedAt, + }; + }, [setupCatalogQuery.data, setupCatalogQuery.error, setupCatalogQuery.isError]); + + return { savedSetupConfigs, setupCatalog }; +} diff --git a/apps/mapgen-studio/src/features/runInGame/api.ts b/apps/mapgen-studio/src/features/runInGame/api.ts index 12b3700903..9faa9ca8b2 100644 --- a/apps/mapgen-studio/src/features/runInGame/api.ts +++ b/apps/mapgen-studio/src/features/runInGame/api.ts @@ -70,7 +70,11 @@ export async function runCurrentConfigInGame(args: { // The legacy handler posted `selectedConfig` verbatim (its `id` may be absent // for disposable runs) and `parseRunInGameSetupRequest` tolerates that, so we // pass it through the permissive (`.catchall`) start input unchanged. - const request = { + // The request envelope type-checks directly against the start input now that + // `selectedConfig.id` is optional in the contract (a disposable run sends + // `selectedConfig` without an `id`). No `as unknown as Parameters<…>` cast — the + // `assertNoRawControlFields`-protected payload is fully input-typed end to end. + const request: Parameters[0] = { recipeId: args.recipeId, seed: args.seed, mapSize: args.mapSize, @@ -79,10 +83,10 @@ export async function runCurrentConfigInGame(args: { setupConfig: normalizeStudioSetupConfig(args.setupConfig), materialization: { mode: args.materializationMode }, ...(args.restartCivProcess ? { recovery: { restartCivProcess: true } } : {}), - selectedConfig: args.selectedConfig, + ...(args.selectedConfig ? { selectedConfig: args.selectedConfig } : {}), config: args.config, sourceSnapshot: args.sourceSnapshot, - } as unknown as Parameters[0]; + }; const body = await orpcClient.runInGame.start(request); return body as RunInGameOperationStatus; } catch (err) { diff --git a/apps/mapgen-studio/src/stores/authoringStore.ts b/apps/mapgen-studio/src/stores/authoringStore.ts new file mode 100644 index 0000000000..146e39d890 --- /dev/null +++ b/apps/mapgen-studio/src/stores/authoringStore.ts @@ -0,0 +1,186 @@ +import { create } from "zustand"; +import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; + +import type { BuiltInPreset } from "../recipes/catalog"; +import { + DEFAULT_STUDIO_RECIPE_ID, + getRecipeArtifacts, +} from "../recipes/catalog"; +import { + DEFAULT_CIV7_STUDIO_SETUP_CONFIG, + type Civ7StudioSetupConfig, +} from "../features/civ7Setup/setupConfig"; +import { + buildDefaultConfig, +} from "../features/configOverrides/configBuilders"; +import { + STUDIO_AUTHORING_STATE_KEY, + loadStudioAuthoringState, + saveStudioAuthoringState, + type StudioAuthoringStateSnapshot, +} from "../features/studioState/persistence"; +import type { PipelineConfig, RecipeSettings, WorldSettings } from "../ui/types"; + +/** + * `authoringStore` — the single persisted owner of AUTHORING state + * (architecture/10 §3). It replaces the six `StudioShell` `useState`s + * (`worldSettings`, `recipeSettings`, `setupConfig`, `pipelineConfig`, + * `overridesDisabled`, `repoBackedPresetOverridesByRecipe`) and the manual + * `saveStudioAuthoringState` persistence `useEffect`. + * + * HARD-CORE PARITY (FRAME §4, architecture/10 §6): the on-disk localStorage schema + * is a contract. This store does NOT reimplement (de)serialization — it delegates + * to the EXISTING reference impl in `features/studioState/persistence.ts`: + * - hydration reads through `loadStudioAuthoringState()` (same parse, normalizers, + * migrations); + * - writes go through `saveStudioAuthoringState()` (same `STUDIO_AUTHORING_STATE_KEY`, + * same `schemaVersion:1` + `savedAt` envelope, same normalizers/migrations). + * The persist `storage` adapter below is a thin translation between zustand's + * `StorageValue` envelope and that existing schema, so the bytes written to disk are + * identical to before — only the WRITE TRIGGER moved from a `useEffect` to the store's + * own persist hook. + * + * Setters mirror React's `useState` signature (value OR updater) so the migration off + * scattered `setX((prev) => …)` call sites in `StudioShell` is a drop-in. + */ + +type Updater = T | ((prev: T) => T); + +function resolve(updater: Updater, prev: T): T { + return typeof updater === "function" ? (updater as (p: T) => T)(prev) : updater; +} + +/** The persisted authoring fields (no actions) — the shape that round-trips to disk. */ +type AuthoringData = { + worldSettings: WorldSettings; + recipeSettings: RecipeSettings; + setupConfig: Civ7StudioSetupConfig; + pipelineConfig: PipelineConfig; + overridesDisabled: boolean; + repoBackedPresetOverridesByRecipe: Record>; +}; + +export type AuthoringState = AuthoringData & { + setWorldSettings: (next: Updater) => void; + setRecipeSettings: (next: Updater) => void; + setSetupConfig: (next: Updater) => void; + setPipelineConfig: (next: Updater) => void; + setOverridesDisabled: (next: Updater) => void; + setRepoBackedPresetOverridesByRecipe: ( + next: Updater>>, + ) => void; +}; + +/** + * The initial authoring data — reproduces the prior `StudioShell` lazy `useState` + * initializers EXACTLY (lines that read `initialAuthoringState?. ?? `), + * including the recipe-derived `pipelineConfig` default (`buildDefaultConfig` from the + * persisted/default recipe's artifacts) used when nothing is persisted. + */ +function buildInitialAuthoringData(): AuthoringData { + const persisted = loadStudioAuthoringState(); + const worldSettings = persisted?.worldSettings ?? { + mapSize: "MAPSIZE_STANDARD", + playerCount: 6, + resources: "balanced", + }; + const recipeSettings = persisted?.recipeSettings ?? { + recipe: DEFAULT_STUDIO_RECIPE_ID, + preset: "none", + seed: "123", + }; + const setupConfig = persisted?.setupConfig ?? DEFAULT_CIV7_STUDIO_SETUP_CONFIG; + const overridesDisabled = persisted?.overridesDisabled ?? false; + const repoBackedPresetOverridesByRecipe = persisted?.repoBackedPresetOverridesByRecipe ?? {}; + const pipelineConfig: PipelineConfig = persisted?.pipelineConfig + ? persisted.pipelineConfig + : (() => { + const artifacts = getRecipeArtifacts(recipeSettings.recipe); + return buildDefaultConfig(artifacts.configSchema, artifacts.uiMeta, artifacts.defaultConfig); + })(); + return { + worldSettings, + recipeSettings, + setupConfig, + pipelineConfig, + overridesDisabled, + repoBackedPresetOverridesByRecipe, + }; +} + +/** + * zustand persist storage adapter delegating to the reference persistence impl, so the + * disk schema stays byte-identical. `getItem` re-wraps the parsed snapshot in zustand's + * `StorageValue` envelope (consumed by `partialize`/hydration); `setItem` unwraps it and + * calls `saveStudioAuthoringState`, which writes the existing `schemaVersion:1` schema. + */ +const authoringPersistStorage: PersistStorage = { + getItem: (_name): StorageValue | null => { + const snapshot = loadStudioAuthoringState(); + if (!snapshot) return null; + const state: AuthoringData = { + worldSettings: snapshot.worldSettings, + recipeSettings: snapshot.recipeSettings, + setupConfig: snapshot.setupConfig, + pipelineConfig: snapshot.pipelineConfig, + overridesDisabled: snapshot.overridesDisabled, + repoBackedPresetOverridesByRecipe: snapshot.repoBackedPresetOverridesByRecipe, + }; + return { state }; + }, + setItem: (_name, value): void => { + const s = value.state; + saveStudioAuthoringState({ + worldSettings: s.worldSettings, + recipeSettings: s.recipeSettings, + setupConfig: s.setupConfig, + pipelineConfig: s.pipelineConfig, + overridesDisabled: s.overridesDisabled, + repoBackedPresetOverridesByRecipe: s.repoBackedPresetOverridesByRecipe, + }); + }, + removeItem: (_name): void => { + // Authoring state is never explicitly removed; persistence is a refresh recovery + // aid. Provided to satisfy the StorageValue contract. + try { + if (typeof window !== "undefined") window.localStorage?.removeItem(STUDIO_AUTHORING_STATE_KEY); + } catch { + // Removal is best-effort and must not break authoring. + } + }, +}; + +export const useAuthoringStore = create()( + persist( + (set) => ({ + ...buildInitialAuthoringData(), + + setWorldSettings: (next) => set((s) => ({ worldSettings: resolve(next, s.worldSettings) })), + setRecipeSettings: (next) => set((s) => ({ recipeSettings: resolve(next, s.recipeSettings) })), + setSetupConfig: (next) => set((s) => ({ setupConfig: resolve(next, s.setupConfig) })), + setPipelineConfig: (next) => set((s) => ({ pipelineConfig: resolve(next, s.pipelineConfig) })), + setOverridesDisabled: (next) => + set((s) => ({ overridesDisabled: resolve(next, s.overridesDisabled) })), + setRepoBackedPresetOverridesByRecipe: (next) => + set((s) => ({ + repoBackedPresetOverridesByRecipe: resolve(next, s.repoBackedPresetOverridesByRecipe), + })), + }), + { + name: STUDIO_AUTHORING_STATE_KEY, + storage: authoringPersistStorage, + // Persist only the data fields (the reference serializer normalizes them); actions + // are never written to disk. + partialize: (state): AuthoringData => ({ + worldSettings: state.worldSettings, + recipeSettings: state.recipeSettings, + setupConfig: state.setupConfig, + pipelineConfig: state.pipelineConfig, + overridesDisabled: state.overridesDisabled, + repoBackedPresetOverridesByRecipe: state.repoBackedPresetOverridesByRecipe, + }), + }, + ), +); + +export type { StudioAuthoringStateSnapshot }; diff --git a/apps/mapgen-studio/src/stores/runStore.ts b/apps/mapgen-studio/src/stores/runStore.ts new file mode 100644 index 0000000000..cb5c5f32da --- /dev/null +++ b/apps/mapgen-studio/src/stores/runStore.ts @@ -0,0 +1,177 @@ +import { create } from "zustand"; +import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; + +import { + parseRunInGameClientSnapshot, + parseRunInGameSourceSnapshot, + type RunInGameClientSnapshot, + type RunInGameSourceSnapshot, +} from "../features/runInGame/clientState"; +import { + RUN_IN_GAME_LAST_REQUEST_KEY, + RUN_IN_GAME_LAST_SNAPSHOT_KEY, + RUN_IN_GAME_LAST_SOURCE_KEY, +} from "../features/runInGame/sourceSnapshotStorage"; +import { MAP_CONFIG_SAVE_LAST_REQUEST_KEY } from "../features/mapConfigSave/api"; +import type { LastRunSnapshot } from "../features/runInGame/liveSource"; + +/** + * `runStore` — the single owner of RUN / SAVE correlation state (architecture/10 §3). + * + * It replaces the `StudioShell` run/save `useState`s and the scattered + * `localStorage.setItem`/`getItem` calls that bridged the active request ids and the + * last run-in-game snapshot/source across dev-server reloads. + * + * Two field classes: + * - PERSISTED (the localStorage bridge): `runInGameRequestId`, `runInGameSnapshot`, + * `lastRunInGameSource`, `saveDeployRequestId`. These resume an in-flight operation's + * status poll after a reload. + * - SESSION-ONLY (never persisted, exactly as before): `lastRunSnapshot`, + * `lastSaveDeployConfig` — transient authoring-session state. + * + * HARD-CORE PARITY (FRAME §4, architecture/10 §6, §7): the on-disk schema is a + * contract. The persisted surface is NOT a single zustand blob — it is the SAME FOUR + * independent localStorage keys the prior code wrote, with the SAME serializers + * (`RUN_IN_GAME_LAST_REQUEST_KEY`/`MAP_CONFIG_SAVE_LAST_REQUEST_KEY` raw request-id + * strings; `RUN_IN_GAME_LAST_SNAPSHOT_KEY`/`RUN_IN_GAME_LAST_SOURCE_KEY` JSON via the + * existing `parse*Snapshot` readers). The `storage` adapter below fans the store's + * persisted slice OUT to those four keys on write and reads them BACK on hydrate, so a + * payload written before this change loads unchanged and the bytes are identical. + */ + +type Updater = T | ((prev: T) => T); + +function resolve(updater: Updater, prev: T): T { + return typeof updater === "function" ? (updater as (p: T) => T)(prev) : updater; +} + +/** The persisted slice — what the four-key bridge round-trips. */ +type RunPersistedData = { + runInGameRequestId: string | null; + runInGameSnapshot: RunInGameClientSnapshot | null; + lastRunInGameSource: RunInGameSourceSnapshot | null; + saveDeployRequestId: string | null; +}; + +export type RunState = RunPersistedData & { + // Session-only (not persisted) — parity with the prior in-memory `useState`. + lastRunSnapshot: LastRunSnapshot | null; + lastSaveDeployConfig: unknown; + + setRunInGameRequestId: (next: Updater) => void; + setRunInGameSnapshot: (next: Updater) => void; + setLastRunInGameSource: (next: Updater) => void; + setSaveDeployRequestId: (next: Updater) => void; + setLastRunSnapshot: (next: Updater) => void; + setLastSaveDeployConfig: (next: Updater) => void; +}; + +function readRawKey(key: string): string | null { + try { + if (typeof localStorage === "undefined") return null; + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeRawKey(key: string, value: string | null): void { + try { + if (typeof localStorage === "undefined") return; + if (value === null) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, value); + } catch { + // Server status remains authoritative while the dev server is alive — persistence + // is a refresh recovery aid and must not break the run/save flow. + } +} + +/** Initial persisted slice — read from the existing four keys (same readers as the prior mount effect). */ +function readInitialPersistedData(): RunPersistedData { + return { + runInGameRequestId: readRawKey(RUN_IN_GAME_LAST_REQUEST_KEY), + runInGameSnapshot: parseRunInGameClientSnapshot(readRawKey(RUN_IN_GAME_LAST_SNAPSHOT_KEY)), + lastRunInGameSource: parseRunInGameSourceSnapshot(readRawKey(RUN_IN_GAME_LAST_SOURCE_KEY)), + saveDeployRequestId: readRawKey(MAP_CONFIG_SAVE_LAST_REQUEST_KEY), + }; +} + +/** + * Fan-out persist storage: maps the store's persisted slice onto the four legacy keys. + * Only fields that have a defined value are written to their key; a `null` field clears + * its key. JSON blobs use `JSON.stringify` (matching the prior `setItem` writes) and are + * read back through the existing tolerant parsers. + */ +const runPersistStorage: PersistStorage = { + getItem: (_name): StorageValue | null => { + const state = readInitialPersistedData(); + if ( + state.runInGameRequestId === null && + state.runInGameSnapshot === null && + state.lastRunInGameSource === null && + state.saveDeployRequestId === null + ) { + return null; + } + return { state }; + }, + setItem: (_name, value): void => { + const s = value.state; + writeRawKey(RUN_IN_GAME_LAST_REQUEST_KEY, s.runInGameRequestId); + writeRawKey( + RUN_IN_GAME_LAST_SNAPSHOT_KEY, + s.runInGameSnapshot ? JSON.stringify(s.runInGameSnapshot) : null, + ); + writeRawKey( + RUN_IN_GAME_LAST_SOURCE_KEY, + s.lastRunInGameSource ? JSON.stringify(s.lastRunInGameSource) : null, + ); + writeRawKey(MAP_CONFIG_SAVE_LAST_REQUEST_KEY, s.saveDeployRequestId); + }, + removeItem: (_name): void => { + writeRawKey(RUN_IN_GAME_LAST_REQUEST_KEY, null); + writeRawKey(RUN_IN_GAME_LAST_SNAPSHOT_KEY, null); + writeRawKey(RUN_IN_GAME_LAST_SOURCE_KEY, null); + writeRawKey(MAP_CONFIG_SAVE_LAST_REQUEST_KEY, null); + }, +}; + +export const useRunStore = create()( + persist( + (set) => ({ + ...readInitialPersistedData(), + lastRunSnapshot: null, + lastSaveDeployConfig: null, + + setRunInGameRequestId: (next) => + set((s) => ({ runInGameRequestId: resolve(next, s.runInGameRequestId) })), + setRunInGameSnapshot: (next) => + set((s) => ({ runInGameSnapshot: resolve(next, s.runInGameSnapshot) })), + setLastRunInGameSource: (next) => + set((s) => ({ lastRunInGameSource: resolve(next, s.lastRunInGameSource) })), + setSaveDeployRequestId: (next) => + set((s) => ({ saveDeployRequestId: resolve(next, s.saveDeployRequestId) })), + setLastRunSnapshot: (next) => + set((s) => ({ lastRunSnapshot: resolve(next, s.lastRunSnapshot) })), + setLastSaveDeployConfig: (next) => + set((s) => ({ lastSaveDeployConfig: resolve(next, s.lastSaveDeployConfig) })), + }), + { + // `name` is unused by the fan-out adapter (it addresses the four legacy keys + // directly) but is required by the persist contract. + name: "mapgen-studio.runStore.bridge.v1", + storage: runPersistStorage, + // Persist only the four-key bridge slice; session-only fields and actions never + // touch disk (parity with the prior in-memory `useState`). + partialize: (state): RunPersistedData => ({ + runInGameRequestId: state.runInGameRequestId, + runInGameSnapshot: state.runInGameSnapshot, + lastRunInGameSource: state.lastRunInGameSource, + saveDeployRequestId: state.saveDeployRequestId, + }), + }, + ), +); diff --git a/openspec/changes/mapgen-studio-data-model/design.md b/openspec/changes/mapgen-studio-data-model/design.md new file mode 100644 index 0000000000..80d09c1f6b --- /dev/null +++ b/openspec/changes/mapgen-studio-data-model/design.md @@ -0,0 +1,87 @@ +# Design — mapgen-studio-data-model + +## Context + +The client-data slice provisioned `orpc` (TanStack Query utils) and a `QueryClient` +but left every read imperative. This slice realises the query model for the read +surface, fixes a contract type hole, and lands the two persisted Zustand stores that +were deferred. All three touch parity-critical surfaces (localStorage schema, +run-in-game security boundary, status-poll cadence), so the guiding rule is: MOVE / +translate logic, never rewrite it. + +## Decisions + +### D1 — Which reads migrate to `useQuery`, which stay imperative + +| Read | Decision | Why | +| --- | --- | --- | +| `civ7.savedConfigs` | → `useQuery` | Pure read; retry + focus-refetch already covered by query defaults. Clean fit. | +| `civ7.setupCatalog` | → `useQuery` | Same. | +| run-in-game status poll | → `useQuery` (refetchInterval) | Keyed on active request id; adaptive cadence + terminal stop map onto `refetchInterval`/`enabled`. | +| save-deploy status poll | → `useQuery` (refetchInterval) | Same shape as run-in-game. | +| `civ7.setupConfig` (inlined in poll) | **stays imperative** | It runs inside the live poll tick and feeds `buildLiveRuntimeSuggestionRecords`; lifting it would break the per-tick coupling. Task permits this ("if helpful" — it is not). | +| live `status`/`snapshot` poll | **stays imperative (verbatim)** | Hard core: request-key staleness gate + adaptive backoff + abort plumbing do not map onto `useQuery`. §7 do-not-break. | + +### D2 — Status poll start→poll handoff + +The run-in-game/save-deploy operation state is BOTH set imperatively (on start/save) +and refreshed by a poll. To keep that without drift: + +- The **active request id** lives in `runStore` (persisted). It is the `useQuery` + input/key and the `enabled` gate (`skipToken` when absent). +- On start/save the mutation seeds the cache with the immediate response + (`queryClient.setQueryData(orpc.runInGame.status.queryKey({ input }), seed)`) and + sets the request id in `runStore`, so the UI shows the seeded operation instantly + and the poll continues from it. +- `refetchInterval` returns `false` once the operation reaches a terminal phase + (run-in-game: `isRunInGameTerminalPhase`) / non-running status (save-deploy), + reproducing the existing `useEffect` early-return. +- The 404 / transport-failure branches in `fetchRunInGameStatus` / + `fetchMapConfigSaveDeployStatus` already return `{ ok:false, statusCode }`; the + `queryFn` translates those into the SAME synthetic `uncertain` / + `operation-status-missing` operation the imperative `refreshRunInGameStatus` + produced, so the do-not-break failure mapping is preserved. + +If this handoff proves to introduce any cadence/terminal/404 drift in practice, the +fallback (Stop Condition) is to leave that specific poll imperative and document it — +parity wins over purity. + +### D3 — Persisted stores reuse the reference persistence as the storage engine + +The localStorage schema is the reference impl (§6: "copy it, don't fix it"). Rather +than reimplement (de)serialization inside the store, the `persist` middleware is +configured with a custom `storage` adapter that delegates to the EXISTING +`parseStudioAuthoringState` / `saveStudioAuthoringState`-equivalent serializers and +the EXACT `STUDIO_AUTHORING_STATE_KEY`. This guarantees byte-identical on-disk output +(`schemaVersion:1`, `savedAt`, normalizers, migrations) — the round-trip is the same +code path that exists today, only its trigger moves from a `useEffect` to the store's +own persist hook. + +`runStore` similarly reuses `RUN_IN_GAME_LAST_*` / `MAP_CONFIG_SAVE_LAST_REQUEST_KEY` +and the snapshot/source serializers from `features/runInGame/*`. + +### D4 — Contract `selectedConfig.id` optional + +`parseRunInGameSetupRequest` (server) already tolerates an absent `id` (disposable +runs). The contract over-constrained it to required, forcing the caller to launder +the request through `as unknown as Parameters<…>`. Making `id` optional aligns the +contract with the validator and the engine, and lets the cast be dropped — restoring +end-to-end input type safety on the `assertNoRawControlFields`-protected path. The +engine reads `selectedConfig?.id` defensively, so no engine change is required. + +## Risks / Mitigations + +- **Status poll drift** — mitigated by seeding + `refetchInterval` mirroring the + exact cadence and terminal predicates; covered by the AppFooter render tests and a + manual runtime check. Stop Condition fallback if drift appears. +- **localStorage schema regression** — mitigated by reusing the existing serializers + verbatim as the persist engine; covered by `test/studioState/persistence.test.ts` + (unchanged) + a manual reload round-trip. +- **Query-result mirroring into Zustand** — explicitly forbidden; the stores hold + only request ids + browser-authored state, never server payloads. + +## Out of scope + +- Live status/snapshot poll migration (stays imperative). +- Bun-server topology / production `/api` parity (later supervised slice). +- Any localStorage schema/key change. diff --git a/openspec/changes/mapgen-studio-data-model/proposal.md b/openspec/changes/mapgen-studio-data-model/proposal.md new file mode 100644 index 0000000000..574b07c0d4 --- /dev/null +++ b/openspec/changes/mapgen-studio-data-model/proposal.md @@ -0,0 +1,130 @@ +## Why + +The prior slice (`mapgen-studio-client-data`) stood up the TanStack Query client +(`src/lib/query.ts`) and the oRPC-native query utils (`orpc` in `src/lib/orpc.ts`), +but the query layer is **provisioned and unused** — every server read in +`StudioShell` is still an imperative `orpcClient..(...)` call wired into a +hand-rolled `useEffect` load/poll loop. The data model from +`architecture/10-target-architecture.md` §2–§3 is therefore only half-realised. + +This change completes it on three fronts: + +1. **Realise the oRPC-native query model.** Migrate the read surface to + `orpc...queryOptions()` into `useQuery`: saved configs, the setup + catalog, and the run-in-game + save-deploy status polling. The live-runtime + status/snapshot poll legitimately STAYS imperative (its request-key staleness + + adaptive backoff do not map onto `useQuery`); the inlined `setupConfig` read + stays inside that poll loop because it feeds the same per-tick suggestion + pipeline. Query results are never mirrored into Zustand. + +2. **Close the highest-stakes type hole.** The contract + `packages/studio-server/src/contract/runInGame.ts` declares `selectedConfig.id` + as `z.string()` REQUIRED, but the caller and engine treat it as optional + (disposable runs send `selectedConfig` without an `id`). The mismatch is masked + by an `as unknown as Parameters<…>` cast in `src/features/runInGame/api.ts` that + discards input type safety on the `assertNoRawControlFields`-protected path. Make + `id` optional to match `parseRunInGameSetupRequest`, then drop the cast. + +3. **Land the persisted `authoringStore` + `runStore`.** Move the authoring and run + state out of `StudioShell` `useState` + manual persistence effects into Zustand + `persist` stores (architecture/10 §3, deferred from the client-data slice). The + existing localStorage persistence is the reference impl (hard-core parity, §6): + it is ported VERBATIM into `persist` — same keys, same serializers, same schema. + +## Target Authority Refs + +- `docs/projects/mapgen-studio-redesign/FRAME.md` (§4.7 everything talks oRPC; + live-runtime poll staleness/backoff + localStorage schema are hard core) +- `docs/projects/mapgen-studio-redesign/architecture/10-target-architecture.md` + (§2 client data layer, §3 stores + crisp rule, §6 persistence reference impl, + §7 do-not-break registry) +- `packages/studio-server/src/contract/runInGame.ts` (the type hole) +- `apps/mapgen-studio/src/features/studioState/persistence.ts` (the reference impl) + +## What Changes + +- **Reads → TanStack Query.** Add an `orpc`-derived `useQuery` for + `civ7.savedConfigs` and `civ7.setupCatalog`, replacing the hand-rolled load/retry/ + focus-refetch effect; derive the existing `{ status, directory, configurations, + updatedAt, error }` / `{ status, catalog, updatedAt, error }` view shapes from the + query state so `setupControlOptions` and every downstream consumer is unchanged. + The retry-on-failure + refetch-on-focus behaviour is reproduced by the query + client defaults already provided. +- **Status polling → TanStack Query.** The run-in-game and save-deploy status polls + become `useQuery`s keyed on the active request id (sourced from `runStore`), with + an adaptive `refetchInterval` reproducing the existing `document.hidden ? 3000 : + 1000` cadence and STOPPING at terminal phase / non-running status. The imperative + start/save mutations keep their existing transport but seed the query cache + (`queryClient.setQueryData`) and write `runStore` so the poll picks up without a + round-trip. The 404 → synthetic-`uncertain` and `operation-status-missing` + failure mapping is preserved verbatim. +- **Contract fix.** `runInGame.ts` `selectedConfig.id` becomes `z.string().optional()`; + the `as unknown as Parameters[0]` cast in + `features/runInGame/api.ts` is removed, restoring full input type checking on the + `assertNoRawControlFields`-protected start path. +- **`authoringStore` (persist).** New Zustand `persist` store owning + `worldSettings`, `recipeSettings`, `setupConfig`, `pipelineConfig`, + `overridesDisabled`, `repoBackedPresetOverridesByRecipe`. It uses the EXACT + `STUDIO_AUTHORING_STATE_KEY` and the existing parse/serialize functions from + `features/studioState/persistence.ts` as its storage engine, so the on-disk schema + (`schemaVersion:1`, `savedAt`, normalizers, migrations) is byte-identical. The + `StudioShell` `useState` + `saveStudioAuthoringState` effect are replaced by store + selectors. +- **`runStore` (persist).** New Zustand `persist` store owning the run/save request + correlation bridge (`runInGameSnapshot`, `lastRunInGameSource`, + `lastSaveDeployConfig`, and the localStorage request-id keys), reusing the existing + `RUN_IN_GAME_LAST_*` / `MAP_CONFIG_SAVE_LAST_REQUEST_KEY` strings and serializers + verbatim. `StudioShell` reads the active request ids from the store to drive the + status `useQuery`s. + +## Requires + +- `mapgen-studio-client-data` (the prior slice — the `orpc` query utils, the + `QueryClient`, and the `viewStore` come from there; this stacks on it via the + `design/craft-a11y` tip) + +## Affected Owners + +- `packages/studio-server/src/contract/runInGame.ts` (`selectedConfig.id` optional) +- `apps/mapgen-studio/src/features/runInGame/api.ts` (drop the cast) +- `apps/mapgen-studio/src/stores/authoringStore.ts` (new) +- `apps/mapgen-studio/src/stores/runStore.ts` (new) +- `apps/mapgen-studio/src/app/StudioShell.tsx` (reads → useQuery; state → stores) +- `apps/mapgen-studio/src/app/hooks/*` (new query hooks if extracted) + +## Forbidden Owners + +- No change to the localStorage persistence SCHEMA, keys, serializers, or migrations + (the reference impl is copied, not fixed; §6). +- No migration of the live-runtime status/snapshot poll to `useQuery` (its + request-key staleness + adaptive backoff are hard core; it stays imperative). +- No mirroring of TanStack Query results into Zustand (the crisp rule, §3). +- No removal of the legacy `/api/*` middleware; no new FireTuner reads. +- No `mods/**` changes. + +## Stop Conditions + +- The run-in-game / save-deploy status `useQuery` cannot reproduce the start→poll + handoff (seed + cadence + terminal stop + 404 mapping) without behavior drift — in + that case leave that poll imperative and note it. +- The persisted stores cannot reproduce the exact localStorage schema/keys/round-trip. +- Any non-uniform status code or error-`data` field is lost across the boundary. + +## Consumer Impact + +The studio behaves identically: same persisted localStorage payloads, same status +poll cadence and 404 restart detection, same run-in-game security boundary. The +read surface now flows through oRPC-native TanStack Query, and authoring/run state +has single persisted Zustand owners. The `runInGame.start` input is now fully type +checked (no `as unknown` cast). + +## Verification Gates + +- `bun run check` in `apps/mapgen-studio` (tsc clean) and `tsc --noEmit` for + `packages/studio-server` (the contract changed). +- `bun run build` + `bun run test` in `apps/mapgen-studio` (all green). +- Runtime: dev server; saved-configs/catalog load over `/rpc` via TanStack Query; + run-in-game and save-deploy status poll over `/rpc` at the right cadence and stop + at terminal phase; localStorage round-trips the same authoring + run payloads + across reload; no console errors; screenshot renders the studio. +- `bun run openspec -- validate mapgen-studio-data-model --strict`. diff --git a/openspec/changes/mapgen-studio-data-model/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-data-model/specs/mapgen-studio/spec.md new file mode 100644 index 0000000000..432d5b76f4 --- /dev/null +++ b/openspec/changes/mapgen-studio-data-model/specs/mapgen-studio/spec.md @@ -0,0 +1,107 @@ +## ADDED Requirements + +### Requirement: Read Surface Uses oRPC-Native TanStack Query + +Mapgen Studio SHALL read its non-live server surface (saved setup configs, the setup +catalog, and the run-in-game + save-deploy operation status) through oRPC-native +TanStack Query (`orpc...queryOptions()` into `useQuery`), +replacing the hand-rolled `useEffect` load/poll loops, while preserving the exact +view shapes and poll cadence. The live-runtime status/snapshot poll and its inlined +`setupConfig` read SHALL remain imperative (their request-key staleness and adaptive +backoff are hard core). Query results SHALL NOT be mirrored into Zustand. + +#### Scenario: Saved configs and catalog load via useQuery + +- **WHEN** the studio mounts and the saved-configs / setup-catalog data is needed +- **THEN** it is read through `orpc.civ7.savedConfigs` / `orpc.civ7.setupCatalog` + `queryOptions()` into `useQuery`, not a hand-rolled `useEffect` fetch loop +- **AND** the derived `{ status, directory, configurations, updatedAt, error }` / + `{ status, catalog, updatedAt, error }` shapes consumed by `setupControlOptions` + are unchanged +- **AND** retry-on-failure and refetch-on-window-focus are provided by the query + client defaults (matching the legacy retry timer + `focus` listener) + +#### Scenario: Run-in-game and save-deploy status poll via useQuery + +- **WHEN** a run-in-game or save-deploy operation has an active request id +- **THEN** its status is polled by a `useQuery` keyed on that request id with a + `refetchInterval` of `document.hidden ? 3000 : 1000` ms +- **AND** the poll STOPS (no further refetch) once the operation reaches a terminal + phase (run-in-game) or a non-running status (save-deploy) +- **AND** a 404 / missing-operation response is mapped to the same synthetic + `uncertain` / `operation-status-missing` operation the imperative refresh produced + +#### Scenario: Start and save mutations seed the poll + +- **WHEN** a run-in-game start or save-deploy mutation returns its immediate response +- **THEN** the response seeds the operation state synchronously and sets the active + request id in `runStore`, and the poll's `initialData` keeps that seed fresh for one + interval — so the operation is shown immediately and the first network refresh is the + interval-driven one, with no extra immediate round-trip + +#### Scenario: Live-runtime poll stays imperative + +- **WHEN** the live-runtime status/snapshot poll runs +- **THEN** it still reads `civ7.live.status`, `civ7.live.snapshot`, and the inlined + `civ7.setupConfig` imperatively through the oRPC client, preserving the request-key + staleness gate and adaptive backoff exactly (it is NOT migrated to `useQuery`) + +#### Scenario: Query results are not mirrored into Zustand + +- **WHEN** server-owned data (saved configs, catalog, run/save status) is needed +- **THEN** it is read through the TanStack Query layer, never copied into a Zustand + store; the stores hold only request ids and browser-authored state + +### Requirement: Run-In-Game Contract Selected-Config Id Is Optional + +The `runInGame.start` contract input SHALL declare `selectedConfig.id` as optional, +matching `parseRunInGameSetupRequest` (disposable runs omit `id`), and the client +SHALL assemble the start request without an `as unknown as Parameters<…>` cast, +restoring full input type checking on the `assertNoRawControlFields`-protected path. + +#### Scenario: Disposable run without a selected-config id type-checks + +- **WHEN** a disposable run-in-game request is assembled with a `selectedConfig` that + has no `id` +- **THEN** it satisfies the `runInGame.start` contract input type directly (no cast) +- **AND** the server `parseRunInGameSetupRequest` accepts it exactly as before + +#### Scenario: The raw-control-fields scan is unaffected + +- **WHEN** the start request is sent +- **THEN** the server still runs `assertNoRawControlFields` over the body and the + `.catchall(z.unknown())` pass-through still carries unknown keys to that scan + +### Requirement: Authoring And Run State Are Persisted Zustand Stores + +Mapgen Studio SHALL own authoring state in a persisted `authoringStore` and run/save +correlation state in a persisted `runStore` (Zustand `persist`), replacing the +`StudioShell` `useState` + manual persistence effects. Both stores SHALL reproduce +the EXISTING localStorage schema — same keys, same serializers, same migrations — +byte-for-byte (the reference impl is copied, not modified). + +#### Scenario: authoringStore round-trips the existing schema + +- **WHEN** authoring state (`worldSettings`, `recipeSettings`, `setupConfig`, + `pipelineConfig`, `overridesDisabled`, `repoBackedPresetOverridesByRecipe`) changes + and the page is reloaded +- **THEN** it is persisted under the existing `STUDIO_AUTHORING_STATE_KEY` with the + same `schemaVersion:1` / `savedAt` / normalized payload the reference impl wrote +- **AND** it is rehydrated through the same `parseStudioAuthoringState` path, so a + payload written before this change still loads unchanged + +#### Scenario: runStore preserves the request-id bridge + +- **WHEN** a run-in-game or save-deploy request id, last source snapshot, last run + snapshot, or last save-deploy config is recorded +- **THEN** it is persisted under the existing `RUN_IN_GAME_LAST_*` / + `MAP_CONFIG_SAVE_LAST_REQUEST_KEY` strings with the existing serializers +- **AND** on reload the stored request ids feed the status `useQuery`s so an + in-flight operation resumes polling, exactly as the legacy mount effect did + +#### Scenario: StudioShell holds no authoring/run useState mirror + +- **WHEN** `StudioShell` is inspected after this change +- **THEN** the authoring and run/save state is read from and written to the stores, + with no local `useState` mirror and no standalone `saveStudioAuthoringState` + persistence `useEffect` diff --git a/openspec/changes/mapgen-studio-data-model/tasks.md b/openspec/changes/mapgen-studio-data-model/tasks.md new file mode 100644 index 0000000000..5795c230ea --- /dev/null +++ b/openspec/changes/mapgen-studio-data-model/tasks.md @@ -0,0 +1,65 @@ +# Tasks — mapgen-studio-data-model + +## 1. Contract type-hole fix +- [x] 1.1 `packages/studio-server/src/contract/runInGame.ts` — change + `selectedConfig.id` from `z.string()` to `z.string().optional()` to match + `parseRunInGameSetupRequest` (disposable runs omit `id`). +- [x] 1.2 `features/runInGame/api.ts` — remove the + `as unknown as Parameters[0]` cast; the + assembled request now type-checks against the start input directly. +- [x] 1.3 Verify the engine still reads `selectedConfig.id` the same way + (optional everywhere; no required-id assumption introduced). + +## 2. Reads → oRPC-native TanStack Query +- [x] 2.1 Add saved-configs + setup-catalog `useQuery`s via + `orpc.civ7.savedConfigs.queryOptions()` / `orpc.civ7.setupCatalog.queryOptions()`; + derive the existing `{ status, directory, configurations, updatedAt, error }` / + `{ status, catalog, updatedAt, error }` view shapes from query state. +- [x] 2.2 Remove the hand-rolled saved-configs/catalog load+retry+focus effect; + retry-on-failure and refetch-on-focus are provided by the query client defaults. +- [x] 2.3 Keep the inlined `setupConfig` read inside the live-runtime poll loop + (it feeds the per-tick suggestion pipeline); the live status/snapshot poll + stays imperative verbatim (hard core). + +## 3. Status polling → TanStack Query +- [x] 3.1 Run-in-game status `useQuery` keyed on the active request id (from + `runStore`), `refetchInterval` reproducing `document.hidden ? 3000 : 1000`, + stopping at terminal phase / non-running status. +- [x] 3.2 Save-deploy status `useQuery` keyed on the active request id, same + adaptive cadence, stopping when not running. +- [x] 3.3 Imperative start/save mutations seed the operation state synchronously and + write `runStore`; the poll's `initialData` keeps the seeded operation fresh for one + interval so there is NO immediate mount fetch (it picks up from the seed). The + 404 → synthetic `uncertain` / `operation-status-missing` mapping is preserved in + the `refresh*` callbacks the poll's `queryFn` invokes. +- [x] 3.4 Mount-time localStorage restore feeds the request id into `runStore` so the + status `useQuery`s resume after a dev-server reload. + +## 4. Persisted authoringStore +- [x] 4.1 Add `src/stores/authoringStore.ts` — Zustand `persist` store owning + `worldSettings`, `recipeSettings`, `setupConfig`, `pipelineConfig`, + `overridesDisabled`, `repoBackedPresetOverridesByRecipe`. +- [x] 4.2 Use the EXACT `STUDIO_AUTHORING_STATE_KEY` and the existing + `parseStudioAuthoringState` / serialize logic from + `features/studioState/persistence.ts` as the persist storage engine — the + on-disk schema (`schemaVersion:1`, `savedAt`, normalizers, migrations) is + byte-identical. +- [x] 4.3 Replace the `StudioShell` authoring `useState` + `saveStudioAuthoringState` + effect with store selectors (value-or-updater setters; call sites unchanged). + +## 5. Persisted runStore +- [x] 5.1 Add `src/stores/runStore.ts` — Zustand `persist` store owning the run/save + correlation bridge (`runInGameSnapshot`, `lastRunInGameSource`, + `lastSaveDeployConfig`, request-id keys) reusing the existing `RUN_IN_GAME_LAST_*` + / `MAP_CONFIG_SAVE_LAST_REQUEST_KEY` strings + serializers verbatim. +- [x] 5.2 Replace the corresponding `StudioShell` `useState` + manual + `localStorage.setItem` writes with store actions; the active request ids drive + the status `useQuery`s. + +## 6. Verify +- [x] 6.1 `bun run check` (tsc clean) + `tsc --noEmit` for `packages/studio-server`. +- [x] 6.2 `bun run build` (vite + worker-bundle check) + `bun run test` (all green). +- [x] 6.3 Dev-server runtime: saved-configs/catalog + status polls over `/rpc` via + TanStack Query; cadence + terminal stop correct; localStorage round-trips the + same authoring + run payloads across reload; no console errors; screenshot. +- [x] 6.4 `bun run openspec -- validate mapgen-studio-data-model --strict`. diff --git a/packages/studio-server/src/contract/runInGame.ts b/packages/studio-server/src/contract/runInGame.ts index 56d227ab16..050e8130ee 100644 --- a/packages/studio-server/src/contract/runInGame.ts +++ b/packages/studio-server/src/contract/runInGame.ts @@ -247,7 +247,15 @@ export const start = oc sourceSnapshot: z.unknown().optional(), selectedConfig: z .object({ - id: z.string(), + // `id` is OPTIONAL: disposable runs send `selectedConfig` without one, + // and `parseRunInGameSetupRequest` (apps/.../server/runInGame/ + // requestValidation.ts) reads `selected.id` defensively (defaulting to + // "studio-current" when absent). Declaring it required forced the caller + // to launder the request through an `as unknown as Parameters<…>` cast; + // making it optional here aligns the contract with the validator + engine + // and restores end-to-end input type safety on the + // `assertNoRawControlFields`-protected start path. + id: z.string().optional(), label: z.string().optional(), description: z.string().optional(), sourcePath: z.string().optional(),