From 6d775d69c4544e1e607b20f3193441e7a2cc5a90 Mon Sep 17 00:00:00 2001 From: Matei Date: Tue, 9 Jun 2026 00:50:26 -0400 Subject: [PATCH 1/4] =?UTF-8?q?spec(mapgen-studio):=20app-decomposition=20?= =?UTF-8?q?changeset=20=E2=80=94=20behavior-preserving=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../.openspec.yaml | 3 + .../mapgen-studio-app-decomposition/design.md | 102 ++++++++++++++++++ .../proposal.md | 79 ++++++++++++++ .../specs/mapgen-studio/spec.md | 46 ++++++++ .../mapgen-studio-app-decomposition/tasks.md | 30 ++++++ 5 files changed, 260 insertions(+) create mode 100644 openspec/changes/mapgen-studio-app-decomposition/.openspec.yaml create mode 100644 openspec/changes/mapgen-studio-app-decomposition/design.md create mode 100644 openspec/changes/mapgen-studio-app-decomposition/proposal.md create mode 100644 openspec/changes/mapgen-studio-app-decomposition/specs/mapgen-studio/spec.md create mode 100644 openspec/changes/mapgen-studio-app-decomposition/tasks.md diff --git a/openspec/changes/mapgen-studio-app-decomposition/.openspec.yaml b/openspec/changes/mapgen-studio-app-decomposition/.openspec.yaml new file mode 100644 index 0000000000..fc3781f778 --- /dev/null +++ b/openspec/changes/mapgen-studio-app-decomposition/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-06-09 +status: draft diff --git a/openspec/changes/mapgen-studio-app-decomposition/design.md b/openspec/changes/mapgen-studio-app-decomposition/design.md new file mode 100644 index 0000000000..a6bbff0a8d --- /dev/null +++ b/openspec/changes/mapgen-studio-app-decomposition/design.md @@ -0,0 +1,102 @@ +## Context + +`App.tsx` (3,032 LoC) holds two unrelated things in one file: + +1. **A non-React corpus** (L127–668, ~535 LoC): `fetch` wrappers for the `/api/*` + endpoints, deterministic config builders (`buildConfigSkeleton`, + `buildDefaultConfig`, `applyPresetConfig`), the prototype-safe deterministic + merge (`mergeDeterministic`, `setAtPath`), preset adapters + (`mergeBuiltInPresets`, `toRepoBackedPreset`), Civ7 setup option helpers, + localStorage key constants + the source-snapshot reader, and small predicates + (`delay`, `isAbortLikeError`, `clampNumber`, `isPlainObject`). + +2. **The `AppContent` authoring closure** (L676–3022): 42 `useState`, 24 `useEffect`, + 34 `useMemo`, 37 `useCallback`, plus the inlined provider shell, deck-canvas + stage JSX, and error-banner JSX. + +The audit (refactor #1) flags the corpus extraction as **trivial risk, ~18% of the +file, zero behavior change**. That is the safe, high-leverage core of this slice. + +## Goals / Non-Goals + +- **Goal:** MOVE the non-React corpus into named feature modules; MOVE the provider + shell and the two purely-presentational chrome pieces (`CanvasStage`, `ErrorBanner`) + plus the layout frame (`StudioShell`) into `app/` components. Re-import into `App.tsx`. +- **Goal:** Preserve every hard-core behavior verbatim — the moved code is identical. +- **Non-Goal (deferred to the post-stores slice):** the container/presentational + split that makes `RecipePanel`/`ExplorePanel`/`AppFooter` read from stores and + drops their 30–38-prop interfaces. That split depends on the Zustand/TanStack + Query client-data layer, which is NOT in this stack yet. Doing it here would be a + **logic rewrite**, not a move, and would risk the live-loop and run-in-game parity + the FRAME marks as hard core. Per the FRAME falsifier, we stop short of it. + +## Decisions + +### Decision: Extract the non-React corpus into existing feature folders, not a new junk drawer + +Each helper goes to the feature that owns its concern, matching the existing layout +(`features//{api,model,status,...}.ts`): + +| Module | Moved symbols | +| --- | --- | +| `features/mapConfigSave/api.ts` | `toConfigId`, `saveRepoBackedConfig`, `fetchMapConfigSaveDeployStatus`, `MAP_CONFIG_SAVE_LAST_REQUEST_KEY` | +| `features/runInGame/api.ts` | `runCurrentConfigInGame`, `fetchRunInGameStatus` | +| `features/runInGame/sourceSnapshotStorage.ts` | `readStoredRunInGameSourceSnapshot`, `RUN_IN_GAME_LAST_*` keys | +| `features/runInGame/liveSource.ts` | `liveSourceMatchesStudio`, `LastRunSnapshot` | +| `features/civ7Setup/api.ts` | `fetchCiv7SetupConfig`, `fetchCiv7SavedSetupConfigs`, `fetchCiv7SetupCatalog`, `requestCiv7Autoplay`, `Civ7SetupCatalog(Option)` types | +| `features/civ7Setup/setupOptions.ts` | `findSetupParameterLike`, `ensureSelectOption`, `mergeSelectOptions`, `setupCatalogOptions` | +| `features/configOverrides/configBuilders.ts` | `isPlainObject`, `isNumericPathSegment`, `FORBIDDEN_MERGE_KEYS`, `mergeDeterministic`, `setAtPath`, `buildConfigSkeleton`, `buildDefaultConfig`, `applyPresetConfig`, `formatPresetErrors`, `PresetApplyResult`, `AppliedPresetSnapshot` | +| `features/presets/repoBacked.ts` | `mergeBuiltInPresets`, `toRepoBackedPreset` | +| `features/presets/dialogState.ts` | `PresetErrorState` | +| `features/civ7Setup/livePreset.ts` | `LIVE_GAME_PRESET_ID`, `LIVE_GAME_PRESET_KEY` | +| `shared/async.ts` | `delay`, `isAbortLikeError` | +| `shared/number.ts` | `clampNumber` | + +Rationale: zero new top-level dirs, no logic change, and the persistence-key +constants live next to the code that reads/writes them — but the key STRINGS are +unchanged, so the localStorage contract holds. + +### Decision: The provider shell + chrome become `app/` components; the closure stays intact + +- `app/StudioProviders.tsx` — the current `App` export body (`TooltipProvider` + + `AppContent` + `Toaster` + the `useThemePreference` call). `App.tsx` re-exports it. +- `app/CanvasStage.tsx` — the `canvas` JSX (backdrop, theme tint, optional grid, + ``, empty-state). It receives exactly the values the inline JSX read: + `isLightMode`, `backgroundGridEnabled`, deck props, `viewportSize`, manifest flag. +- `app/ErrorBanner.tsx` — the error banner JSX. +- `app/StudioShell.tsx` — the outer layout frame (`containerRef` div, panel-top + positioning, slot placement of canvas/header/panels/footer/dialogs/error). + +`AppContent` keeps all hooks/derivations/handlers. The only edits to the closure are: +(a) import sites for the moved helpers, and (b) the trailing JSX delegating to the +new presentational components with the same inputs. This keeps the diff a **move**, +auditable as such. + +### Decision: Defer the eight disguised effects and the prop-drilling cleanup + +The audit lists 8 derived-state effects to delete and the 30–38-prop panel +interfaces to collapse. Both are **logic rewrites** and both depend on the +not-yet-landed stores. Removing the disguised effects changes render timing and +must be validated against the live loop with a parity harness — out of scope for a +move-only slice. They are explicitly deferred to the post-stores decomposition +slice and recorded here so the next slice picks them up. + +## Risks / Trade-offs + +- **Risk:** an extracted helper silently captured a module-level binding. Mitigation: + every moved symbol is self-contained (verified by reference-count grep); the type + checker proves no dangling reference after the move. +- **Trade-off:** `App.tsx` shrinks by the corpus + chrome but the `AppContent` + closure remains large until the stores land. Accepted: this slice is the + prerequisite domino, not the finish line. + +## Migration Plan + +1. Create the feature modules; move symbols verbatim; export. +2. Replace the inline definitions in `App.tsx` with imports. +3. Extract `StudioProviders`, `CanvasStage`, `ErrorBanner`, `StudioShell`. +4. `bun run check` + `bun run build` + live preview console-error check. + +## Open Questions + +None blocking. The container/store split is sequenced to the client-data-layer slice. diff --git a/openspec/changes/mapgen-studio-app-decomposition/proposal.md b/openspec/changes/mapgen-studio-app-decomposition/proposal.md new file mode 100644 index 0000000000..9f06694a52 --- /dev/null +++ b/openspec/changes/mapgen-studio-app-decomposition/proposal.md @@ -0,0 +1,79 @@ +## Why + +`apps/mapgen-studio/src/App.tsx` is a 3,032-line god-module. Roughly 535 lines at +the top of the file (L127–668) are **non-React** helpers — fetch wrappers, config +builders, deterministic merge/path utilities, preset adapters, localStorage key +constants, and pure predicates — that have nothing to do with the component tree +yet sit in the same file as a 2,300-line `AppContent` closure. The provider shell +and the presentational chrome (the deck canvas stage and the error banner) are +also inlined. This makes the file unreviewable and blocks every later +decomposition slice. + +This change performs a **behavior-preserving structural extraction**: it MOVES the +non-React helpers into focused feature modules and MOVES the provider shell and the +two purely-presentational chrome pieces into their own components. It does not +rewrite any logic, does not introduce client-state stores or server-data hooks +(those land in the client-data-layer slice), and does not touch any hard-core +behavior. + +## Target Authority Refs + +- `docs/projects/mapgen-studio-redesign/FRAME.md` +- `docs/projects/mapgen-studio-redesign/architecture/10-target-architecture.md` (§4 component tree, §7 do-not-break registry) +- `docs/projects/mapgen-studio-redesign/audit/03-component-architecture.md` (§4 decomposition target, refactor #1) + +## What Changes + +- Extract the ~535 LoC of non-React helpers out of `App.tsx` into modules under + existing `features/*` folders (and `shared/`): map-config save/deploy API, run-in-game + API, Civ7 setup API + option helpers, config builders + deterministic merge, + preset adapters, run-in-game source-snapshot storage + persistence keys, and + small shared predicates. +- Extract the provider shell `App` wrapper into `app/StudioProviders` and the two + purely-presentational chrome pieces (`CanvasStage`, `ErrorBanner`) and the layout + frame (`StudioShell`) into components under `app/`. +- `App.tsx` re-imports every moved symbol; the `AppContent` closure logic is + unchanged byte-for-byte except for the import sites and the JSX that now delegates + to the extracted presentational components. + +## Requires + +- Design-system foundation, primitives, and shell-reskin slices (already in the stack). + +## Enables Parallel Work + +- The container/presentational store-reading split (RecipeConfigPanel, ExploreController, + store-backed AppFooter) — deferred to the post-stores decomposition slice. + +## Affected Owners + +- `apps/mapgen-studio/src/App.tsx` +- `apps/mapgen-studio/src/features/{mapConfigSave,runInGame,civ7Setup,configOverrides,presets,studioState}/**` +- `apps/mapgen-studio/src/shared/**` +- `apps/mapgen-studio/src/app/**` (new) + +## Forbidden Owners + +- No change to map-generation, Deck.gl rendering, recipe semantics, or run-in-game flow. +- No change to the live-runtime poll staleness/backoff gating. +- No change to the localStorage schema (keys + serialized shapes preserved verbatim). +- No new client-state store or server-data hook (separate slice). + +## Stop Conditions + +- Any extraction would require rewriting logic rather than moving it. +- Behavior parity on recipe authoring, viz selection, or run-in-game cannot be preserved. + +## Consumer Impact + +Developers get a reviewable `App.tsx`: the non-React corpus lives in named, +testable modules, and the provider/chrome shell is separated from the authoring +closure. End users see no behavior change. + +## Verification Gates + +- `bun run check` (tsc --noEmit) clean. +- `bun run build` succeeds, including the worker-bundle check. +- Live preview renders with no console errors; recipe authoring, viz, and + run-in-game controls behave identically. +- OpenSpec strict validation. diff --git a/openspec/changes/mapgen-studio-app-decomposition/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-app-decomposition/specs/mapgen-studio/spec.md new file mode 100644 index 0000000000..4c0da3eeea --- /dev/null +++ b/openspec/changes/mapgen-studio-app-decomposition/specs/mapgen-studio/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: App.tsx Non-React Helpers Are Extracted Without Behavior Change + +Mapgen Studio SHALL host the non-React helper corpus (fetch wrappers, config +builders, deterministic merge/path utilities, preset adapters, setup option +helpers, source-snapshot storage, and shared predicates) in named feature modules +rather than inline at the top of `App.tsx`, with identical runtime behavior. + +#### Scenario: Helpers move into modules +- **WHEN** the non-React helpers previously defined at the top of `App.tsx` are referenced +- **THEN** they are imported from feature modules under `features/*` or `shared/*` +- **AND** each helper's implementation is byte-for-byte the same as before extraction +- **AND** `tsc --noEmit` reports zero errors and the production build succeeds + +#### Scenario: localStorage schema is preserved +- **WHEN** the run-in-game and map-config persistence keys are read or written +- **THEN** the key strings and serialized shapes are identical to the pre-extraction values +- **AND** a session persisted before the change rehydrates identically after it + +### Requirement: Studio Provider Shell And Presentational Chrome Are Separated + +Mapgen Studio SHALL render through a provider shell component and dedicated +presentational chrome components, separated from the authoring closure, without +changing the rendered output or interaction behavior. + +#### Scenario: Provider shell wraps the app +- **WHEN** the Studio app mounts +- **THEN** the tooltip provider, toast surface, and theme preference wiring are owned by a `StudioProviders` shell +- **AND** the mounted component tree and provider configuration are unchanged from before extraction + +#### Scenario: Canvas and error chrome render identically +- **WHEN** the deck canvas stage and the error banner render +- **THEN** they are produced by dedicated presentational components receiving the same inputs +- **AND** the resulting DOM, classes, and Deck.gl inputs match the pre-extraction output + +### Requirement: Hard-Core Behaviors Are Untouched By Decomposition + +The decomposition SHALL NOT alter map-generation, Deck.gl rendering, recipe +semantics, the run-in-game flow, the live-runtime poll staleness/backoff gating, +or the materialization-mode decision. + +#### Scenario: Live loop and run-in-game parity hold +- **WHEN** the live-runtime poll runs and a Run in Game request is issued after the change +- **THEN** the request-key staleness gating, adaptive backoff, fingerprint/relation equality, and materialization-mode decision behave identically to before +- **AND** no live read is re-implemented and no FireTuner read is introduced diff --git a/openspec/changes/mapgen-studio-app-decomposition/tasks.md b/openspec/changes/mapgen-studio-app-decomposition/tasks.md new file mode 100644 index 0000000000..f5bc2566b0 --- /dev/null +++ b/openspec/changes/mapgen-studio-app-decomposition/tasks.md @@ -0,0 +1,30 @@ +## 1. Extract the non-React corpus + +- [ ] 1.1 `features/mapConfigSave/api.ts`: move `toConfigId`, `saveRepoBackedConfig`, `fetchMapConfigSaveDeployStatus`, `MAP_CONFIG_SAVE_LAST_REQUEST_KEY` +- [ ] 1.2 `features/runInGame/api.ts`: move `runCurrentConfigInGame`, `fetchRunInGameStatus` +- [ ] 1.3 `features/runInGame/sourceSnapshotStorage.ts`: move `readStoredRunInGameSourceSnapshot` + `RUN_IN_GAME_LAST_*` keys +- [ ] 1.4 `features/runInGame/liveSource.ts`: move `liveSourceMatchesStudio`, `LastRunSnapshot` +- [ ] 1.5 `features/civ7Setup/api.ts`: move `fetchCiv7SetupConfig`, `fetchCiv7SavedSetupConfigs`, `fetchCiv7SetupCatalog`, `requestCiv7Autoplay`, `Civ7SetupCatalog(Option)` types +- [ ] 1.6 `features/civ7Setup/setupOptions.ts`: move `findSetupParameterLike`, `ensureSelectOption`, `mergeSelectOptions`, `setupCatalogOptions` +- [ ] 1.7 `features/civ7Setup/livePreset.ts`: move `LIVE_GAME_PRESET_ID`, `LIVE_GAME_PRESET_KEY` +- [ ] 1.8 `features/configOverrides/configBuilders.ts`: move merge/path/config builders + preset-apply + types +- [ ] 1.9 `features/presets/repoBacked.ts`: move `mergeBuiltInPresets`, `toRepoBackedPreset` +- [ ] 1.10 `features/presets/dialogState.ts`: move `PresetErrorState` +- [ ] 1.11 `shared/async.ts` + `shared/number.ts`: move `delay`, `isAbortLikeError`, `clampNumber` +- [ ] 1.12 Replace inline definitions in `App.tsx` with imports + +## 2. Extract the provider shell + presentational chrome + +- [ ] 2.1 `app/StudioProviders.tsx`: move the `App` export body +- [ ] 2.2 `app/CanvasStage.tsx`: move the canvas JSX (props-in, no logic change) +- [ ] 2.3 `app/ErrorBanner.tsx`: move the error banner JSX +- [ ] 2.4 `app/StudioShell.tsx`: move the outer layout frame +- [ ] 2.5 Wire `App.tsx` to the new components; keep `AppContent` logic intact + +## 3. Verify parity + +- [ ] 3.1 `bun run check` clean +- [ ] 3.2 `bun run build` succeeds incl. worker-bundle check +- [ ] 3.3 Live preview: no console errors; recipe authoring, viz, run-in-game identical +- [ ] 3.4 localStorage keys/shapes unchanged (grep diff) +- [ ] 3.5 OpenSpec strict validation passes From f8cab91d87bb6d9dcb03ccd08670e2918e53f885 Mon Sep 17 00:00:00 2001 From: Matei Date: Tue, 9 Jun 2026 01:05:13 -0400 Subject: [PATCH 2/4] refactor(mapgen-studio): extract App.tsx non-React helper corpus into feature modules Behavior-preserving MOVE of the ~535 LoC of non-React helpers from the top of App.tsx into focused feature modules. No logic changes: every helper is byte-for- byte identical, only relocated, and App.tsx re-imports them. - features/mapConfigSave/api.ts: toConfigId, saveRepoBackedConfig, fetchMapConfigSaveDeployStatus, MAP_CONFIG_SAVE_LAST_REQUEST_KEY - features/runInGame/{api,sourceSnapshotStorage,liveSource}.ts - features/civ7Setup/{api,setupOptions,livePreset}.ts - features/configOverrides/configBuilders.ts (merge/path/config builders) - features/presets/{repoBacked,dialogState}.ts - shared/{async,number}.ts localStorage key strings preserved verbatim (persistence contract). tsc clean; prod build + worker-bundle check pass. Co-Authored-By: Claude Opus 4.8 --- apps/mapgen-studio/src/App.tsx | 591 ++---------------- .../src/features/civ7Setup/api.ts | 139 ++++ .../src/features/civ7Setup/livePreset.ts | 5 + .../src/features/civ7Setup/setupOptions.ts | 43 ++ .../configOverrides/configBuilders.ts | 129 ++++ .../src/features/mapConfigSave/api.ts | 98 +++ .../src/features/presets/dialogState.ts | 7 + .../src/features/presets/repoBacked.ts | 46 ++ .../src/features/runInGame/api.ts | 84 +++ .../src/features/runInGame/liveSource.ts | 32 + .../runInGame/sourceSnapshotStorage.ts | 17 + apps/mapgen-studio/src/shared/async.ts | 10 + apps/mapgen-studio/src/shared/number.ts | 6 + 13 files changed, 656 insertions(+), 551 deletions(-) create mode 100644 apps/mapgen-studio/src/features/civ7Setup/api.ts create mode 100644 apps/mapgen-studio/src/features/civ7Setup/livePreset.ts create mode 100644 apps/mapgen-studio/src/features/civ7Setup/setupOptions.ts create mode 100644 apps/mapgen-studio/src/features/configOverrides/configBuilders.ts create mode 100644 apps/mapgen-studio/src/features/mapConfigSave/api.ts create mode 100644 apps/mapgen-studio/src/features/presets/dialogState.ts create mode 100644 apps/mapgen-studio/src/features/presets/repoBacked.ts create mode 100644 apps/mapgen-studio/src/features/runInGame/api.ts create mode 100644 apps/mapgen-studio/src/features/runInGame/liveSource.ts create mode 100644 apps/mapgen-studio/src/features/runInGame/sourceSnapshotStorage.ts create mode 100644 apps/mapgen-studio/src/shared/async.ts create mode 100644 apps/mapgen-studio/src/shared/number.ts diff --git a/apps/mapgen-studio/src/App.tsx b/apps/mapgen-studio/src/App.tsx index 3ef738e04d..589aa9d0eb 100644 --- a/apps/mapgen-studio/src/App.tsx +++ b/apps/mapgen-studio/src/App.tsx @@ -1,9 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; -import { normalizeStrict } from "@swooper/mapgen-core/compiler/normalize"; import { stripSchemaMetadataRoot, type StudioPresetExportFileV1, - type TSchema, } from "@swooper/mapgen-core/authoring"; import { AppHeader } from "./ui/components/AppHeader"; @@ -37,7 +35,6 @@ import { buildRunInGameFingerprint, buildRunInGameSourceSnapshot, parseRunInGameClientSnapshot, - parseRunInGameSourceSnapshot, relationForRunInGameOperation, type RunInGameClientSnapshot, type RunInGameCurrentRelation, @@ -47,21 +44,17 @@ import { formatRunInGameDiagnostics, isRunInGameTerminalPhase, runInGameRequiresProcessRestart, - type RunInGameFailureDetails, type RunInGameOperationStatus, } from "./features/runInGame/status"; import { createStudioCiv7ControlOrpcClient } from "./features/runInGame/civ7ControlOrpcClient"; import { DEFAULT_CIV7_STUDIO_SETUP_CONFIG, getLocalPlayerSetup, - labelForCiv7SetupValue, normalizeStudioSetupConfig, optionRowsFromParameter, studioSetupConfigFromLiveSnapshot, updateStudioSetupSavedConfig, - studioSetupConfigsEqual, type Civ7SavedSetupConfigFile, - type Civ7SetupParameterSnapshotLike, type Civ7SetupSnapshotLike, type Civ7StudioSetupConfig, } from "./features/civ7Setup/setupConfig"; @@ -110,7 +103,7 @@ import { resolveImportedPreset } from "./features/presets/importFlow"; import { buildPresetExportFile, downloadPresetFile, parsePresetExportFile } from "./features/presets/importExport"; import { parsePresetKey, type PresetKey } from "./features/presets/types"; import { usePresets } from "./features/presets/usePresets"; -import { migratePipelineConfig, migratePipelineConfigUnknown } from "./features/configMigrations/pipelineConfig"; +import { migratePipelineConfig } from "./features/configMigrations/pipelineConfig"; import { loadStudioAuthoringState, saveStudioAuthoringState, @@ -121,554 +114,50 @@ import { getRecipeArtifacts, STUDIO_RECIPE_OPTIONS, type BuiltInPreset, - type StudioRecipeUiMeta, } from "./recipes/catalog"; import { getOverlaySuggestions } from "./recipes/overlaySuggestions"; +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 { + fetchCiv7SetupConfig, + fetchCiv7SavedSetupConfigs, + fetchCiv7SetupCatalog, + requestCiv7Autoplay, + type Civ7SetupCatalog, +} from "./features/civ7Setup/api"; +import { + findSetupParameterLike, + ensureSelectOption, + mergeSelectOptions, + setupCatalogOptions, +} from "./features/civ7Setup/setupOptions"; +import { LIVE_GAME_PRESET_ID, LIVE_GAME_PRESET_KEY } from "./features/civ7Setup/livePreset"; +import { + isPlainObject, + buildDefaultConfig, + applyPresetConfig, + formatPresetErrors, + type AppliedPresetSnapshot, +} from "./features/configOverrides/configBuilders"; +import { mergeBuiltInPresets, toRepoBackedPreset } from "./features/presets/repoBacked"; +import type { PresetErrorState } from "./features/presets/dialogState"; const civ7ControlOrpcClient = createStudioCiv7ControlOrpcClient(); -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function toConfigId(label: string): string { - const id = label - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return id || `map-config-${Date.now()}`; -} - -async function saveRepoBackedConfig(args: { - requestId: string; - id: string; - name: string; - description?: string; - sourcePath?: string; - sortIndex: number; - latitudeBounds?: Readonly<{ - topLatitude: number; - bottomLatitude: number; - }>; - config: unknown; - onStatus?: (status: MapConfigSaveDeployStatus) => void; -}): Promise< - | { ok: true; path?: string; deploy?: { command?: string } } - | { ok: false; error: string; saved?: boolean; deployed?: boolean; path?: string } -> { - const envelope = { - $schema: "../../../dist/recipes/standard-map-config.schema.json", - id: args.id, - name: args.name, - description: args.description?.trim() || args.name, - recipe: "standard", - sortIndex: args.sortIndex, - ...(args.latitudeBounds ? { latitudeBounds: args.latitudeBounds } : {}), - config: args.config, - }; - try { - const res = await fetch("/api/map-configs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requestId: args.requestId, id: args.id, sourcePath: args.sourcePath, envelope }), - }); - const body = (await res.json().catch(() => null)) as (Partial & { error?: string }) | null; - if (!res.ok || !body?.requestId) { - return { ok: false, error: body?.error ?? `HTTP ${res.status}`, path: body?.path }; - } - let status = body as MapConfigSaveDeployStatus; - args.onStatus?.(status); - while (status.status === "running") { - await delay(500); - const next = await fetchMapConfigSaveDeployStatus(status.requestId); - if (!("requestId" in next)) { - return { ok: false, error: next.error, saved: status.saved, deployed: status.deployed, path: status.path }; - } - status = next; - args.onStatus?.(status); - } - if (!status.ok || status.status === "failed") { - return { - ok: false, - error: status.error ?? "Save/deploy failed", - saved: status.saved, - deployed: status.deployed, - path: status.path, - }; - } - return { ok: true, path: status.path, deploy: status.deploy }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Repo config save failed" }; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -function isAbortLikeError(err: unknown): boolean { - return Boolean(err && typeof err === "object" && (err as { name?: unknown }).name === "AbortError"); -} - -async function fetchMapConfigSaveDeployStatus(requestId: string): Promise { - try { - const res = await fetch(`/api/map-configs/status?requestId=${encodeURIComponent(requestId)}`); - const body = (await res.json().catch(() => null)) as (Partial & { error?: string }) | null; - if (!res.ok || !body?.requestId) { - return { ok: false, error: body?.error ?? `HTTP ${res.status}`, statusCode: res.status }; - } - return body as MapConfigSaveDeployStatus; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Save/Deploy status unavailable" }; - } -} - -async function runCurrentConfigInGame(args: { - recipeId: string; - seed: string; - mapSize: string; - playerCount: number; - resources: string; - setupConfig: Civ7StudioSetupConfig; - materializationMode: "durable" | "disposable"; - restartCivProcess?: boolean; - selectedConfig?: { - id?: string; - label?: string; - description?: string; - sourcePath?: string; - sortIndex?: number; - latitudeBounds?: Readonly<{ - topLatitude: number; - bottomLatitude: number; - }>; - }; - config: unknown; - sourceSnapshot?: unknown; -}): Promise< - | RunInGameOperationStatus - | { ok: false; error: string; details?: RunInGameFailureDetails; statusCode?: number } -> { - try { - const res = await fetch("/api/civ7/run-in-game", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - recipeId: args.recipeId, - seed: args.seed, - mapSize: args.mapSize, - playerCount: args.playerCount, - resources: args.resources, - setupConfig: normalizeStudioSetupConfig(args.setupConfig), - materialization: { mode: args.materializationMode }, - ...(args.restartCivProcess ? { recovery: { restartCivProcess: true } } : {}), - selectedConfig: args.selectedConfig, - config: args.config, - sourceSnapshot: args.sourceSnapshot, - }), - }); - const body = (await res.json().catch(() => null)) as - | (Partial & { error?: string; details?: RunInGameFailureDetails }) - | null; - if (!res.ok || !body?.requestId) { - return { - ok: false, - error: body?.error ?? `HTTP ${res.status}`, - details: body?.details, - statusCode: res.status, - }; - } - return body as RunInGameOperationStatus; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Run in Game failed" }; - } -} - -async function fetchRunInGameStatus(requestId: string): Promise { - try { - const res = await fetch(`/api/civ7/run-in-game/status?requestId=${encodeURIComponent(requestId)}`); - const body = (await res.json().catch(() => null)) as (Partial & { error?: string }) | null; - if (!res.ok || !body?.requestId) { - return { ok: false, error: body?.error ?? `HTTP ${res.status}`, statusCode: res.status }; - } - return body as RunInGameOperationStatus; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Run in Game status unavailable" }; - } -} - -async function fetchCiv7SetupConfig(options: { signal?: AbortSignal } = {}): Promise< - | { ok: true; observedAt: string; setup: Civ7SetupSnapshotLike } - | { ok: false; error: string; observedAt?: string; statusCode?: number } -> { - try { - const res = await fetch("/api/civ7/setup-config", options.signal ? { signal: options.signal } : undefined); - const body = (await res.json().catch(() => null)) as { - ok?: boolean; - observedAt?: string; - setup?: Civ7SetupSnapshotLike; - error?: string; - } | null; - if (!res.ok || !body?.ok || !body.setup) { - return { - ok: false, - error: body?.error ?? `HTTP ${res.status}`, - observedAt: body?.observedAt, - statusCode: res.status, - }; - } - return { - ok: true, - observedAt: body.observedAt ?? new Date().toISOString(), - setup: body.setup, - }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Civ7 setup config unavailable" }; - } -} - -type Civ7SetupCatalogOption = Readonly<{ - value: string; - label: string; - source?: string; - sourcePath?: string; -}>; - -type Civ7SetupCatalog = Readonly<{ - observedAt: string; - leaders: ReadonlyArray; - civilizations: ReadonlyArray; - difficulties: ReadonlyArray; - gameSpeeds: ReadonlyArray; -}>; - -async function fetchCiv7SavedSetupConfigs(): Promise< - | { ok: true; observedAt: string; directory: string; configurations: ReadonlyArray } - | { ok: false; error: string; observedAt?: string; statusCode?: number } -> { - try { - const res = await fetch("/api/civ7/saved-configs"); - const body = (await res.json().catch(() => null)) as { - ok?: boolean; - observedAt?: string; - directory?: string; - configurations?: ReadonlyArray; - error?: string; - } | null; - if (!res.ok || !body?.ok || !Array.isArray(body.configurations)) { - return { - ok: false, - error: body?.error ?? `HTTP ${res.status}`, - observedAt: body?.observedAt, - statusCode: res.status, - }; - } - return { - ok: true, - observedAt: body.observedAt ?? new Date().toISOString(), - directory: body.directory ?? "", - configurations: body.configurations, - }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Civ7 saved configurations unavailable" }; - } -} - -async function fetchCiv7SetupCatalog(): Promise< - | { ok: true; catalog: Civ7SetupCatalog } - | { ok: false; error: string; observedAt?: string; statusCode?: number } -> { - try { - const res = await fetch("/api/civ7/setup-catalog"); - const body = (await res.json().catch(() => null)) as { - ok?: boolean; - catalog?: Civ7SetupCatalog; - error?: string; - observedAt?: string; - } | null; - if (!res.ok || !body?.ok || !body.catalog) { - return { - ok: false, - error: body?.error ?? `HTTP ${res.status}`, - observedAt: body?.observedAt, - statusCode: res.status, - }; - } - return { ok: true, catalog: body.catalog }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Civ7 setup catalog unavailable" }; - } -} - -async function requestCiv7Autoplay(action: "start" | "stop"): Promise<{ - ok: boolean; - action?: "start" | "stop"; - autoplay?: { isActive?: boolean; isPaused?: boolean; isPausedOrPending?: boolean }; - game?: { turn?: { ok?: boolean; value?: number } }; - error?: string; -}> { - try { - const res = await fetch("/api/civ7/autoplay", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - const body = (await res.json().catch(() => null)) as { - ok?: boolean; - action?: "start" | "stop"; - autoplay?: { isActive?: boolean; isPaused?: boolean; isPausedOrPending?: boolean }; - game?: { turn?: { ok?: boolean; value?: number } }; - error?: string; - } | null; - if (!res.ok || !body?.ok) { - return { ok: false, error: body?.error ?? `HTTP ${res.status}` }; - } - return { ok: true, action: body.action, autoplay: body.autoplay, game: body.game }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Civ7 autoplay request failed" }; - } -} - -const RUN_IN_GAME_LAST_REQUEST_KEY = "mapgen-studio.runInGame.lastRequestId.v1"; -const RUN_IN_GAME_LAST_SNAPSHOT_KEY = "mapgen-studio.runInGame.lastSnapshot.v1"; -const RUN_IN_GAME_LAST_SOURCE_KEY = "mapgen-studio.runInGame.lastSource.v1"; -const MAP_CONFIG_SAVE_LAST_REQUEST_KEY = "mapgen-studio.mapConfigSave.lastRequestId.v1"; -const LIVE_GAME_PRESET_ID = "live-game"; -const LIVE_GAME_PRESET_KEY = `live:${LIVE_GAME_PRESET_ID}` as const; - -function isNumericPathSegment(segment: string): boolean { - return /^[0-9]+$/.test(segment); -} - -const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); - -function mergeDeterministic(base: unknown, overrides: unknown): unknown { - if (overrides === undefined) return base; - if (!isPlainObject(base) || !isPlainObject(overrides)) return overrides; - - const out: Record = { ...(base as Record) }; - for (const key of Object.keys(overrides)) { - if (FORBIDDEN_MERGE_KEYS.has(key)) continue; - out[key] = mergeDeterministic((base as Record)[key], overrides[key]); - } - return out; -} - -function setAtPath(root: Record, path: readonly string[], value: unknown): void { - let current: unknown = root; - for (let i = 0; i < path.length; i += 1) { - const key = path[i]; - const isLast = i === path.length - 1; - const nextKey = path[i + 1]; - const wantsArray = typeof nextKey === "string" && isNumericPathSegment(nextKey); - - if (Array.isArray(current) && isNumericPathSegment(key)) { - const idx = Number(key); - if (isLast) { - if (current[idx] === undefined) current[idx] = value; - return; - } - const next = current[idx]; - if (!isPlainObject(next) && !Array.isArray(next)) { - current[idx] = wantsArray ? [] : {}; - } - current = current[idx]; - continue; - } - - if (!isPlainObject(current) && !Array.isArray(current)) { - return; - } - const record = current as Record; - if (isLast) { - if (record[key] === undefined) record[key] = value; - return; - } - const existing = record[key]; - if (!isPlainObject(existing) && !Array.isArray(existing)) { - record[key] = wantsArray ? [] : {}; - } - current = record[key]; - } -} - -function buildConfigSkeleton(uiMeta: StudioRecipeUiMeta): PipelineConfig { - const skeleton: PipelineConfig = {}; - for (const stage of uiMeta.stages) { - const stageConfig: Record = { knobs: {} }; - for (const step of stage.steps) { - setAtPath(stageConfig, step.configFocusPathWithinStage, {}); - } - skeleton[stage.stageId] = stageConfig; - } - return skeleton; -} - - -function buildDefaultConfig( - schema: TSchema, - uiMeta: StudioRecipeUiMeta, - defaultConfig: unknown -): PipelineConfig { - const skeleton = buildConfigSkeleton(uiMeta); - const merged = mergeDeterministic(skeleton, stripSchemaMetadataRoot(defaultConfig)); - const { value, errors } = normalizeStrict(schema, merged, "/defaultConfig"); - if (errors.length > 0) { - console.error("[mapgen-studio] invalid recipe config schema defaults", errors); - return skeleton; - } - return value; -} - -type PresetApplyResult = Readonly<{ - value: PipelineConfig | null; - errors: ReadonlyArray<{ path: string; message: string }>; -}>; - -type AppliedPresetSnapshot = Readonly<{ - key: PresetKey; - config: unknown; -}>; - -function mergeBuiltInPresets( - base: ReadonlyArray, - overrides: Readonly> -): ReadonlyArray { - const overrideIds = new Set(Object.keys(overrides)); - if (overrideIds.size === 0) return base; - const merged = base.map((preset) => { - const override = overrides[preset.id]; - if (!override) return preset; - overrideIds.delete(preset.id); - return override; - }); - for (const id of overrideIds) { - const override = overrides[id]; - if (override) merged.push(override); - } - return merged; -} - -function toRepoBackedPreset(args: { - id: string; - label: string; - description?: string; - sourcePath?: string; - sortIndex?: number; - latitudeBounds?: Readonly<{ - topLatitude: number; - bottomLatitude: number; - }>; - config: unknown; -}): BuiltInPreset { - return { - id: args.id, - label: args.label, - description: args.description, - sourcePath: args.sourcePath, - sortIndex: args.sortIndex, - latitudeBounds: args.latitudeBounds, - config: args.config, - }; -} - -function readStoredRunInGameSourceSnapshot(): RunInGameSourceSnapshot | null { - try { - if (typeof localStorage === "undefined") return null; - return parseRunInGameSourceSnapshot(localStorage.getItem(RUN_IN_GAME_LAST_SOURCE_KEY)); - } catch { - return null; - } -} - -function applyPresetConfig(args: { - schema: TSchema; - uiMeta: StudioRecipeUiMeta; - presetConfig: unknown; - label: string; -}): PresetApplyResult { - const { schema, uiMeta, presetConfig, label } = args; - const skeleton = buildConfigSkeleton(uiMeta); - const migratedPresetConfig = migratePipelineConfigUnknown(stripSchemaMetadataRoot(presetConfig)); - const merged = mergeDeterministic(skeleton, migratedPresetConfig); - const { value, errors } = normalizeStrict(schema, merged, `/preset/${label}`); - if (errors.length > 0) return { value: null, errors }; - return { value, errors: [] }; -} - -function formatPresetErrors(errors: ReadonlyArray<{ path: string; message: string }>): ReadonlyArray { - return errors.map((e) => `${e.path}: ${e.message}`); -} - -function clampNumber(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -function findSetupParameterLike( - parameters: ReadonlyArray | undefined, - id: string, -): Civ7SetupParameterSnapshotLike | undefined { - return parameters?.find((parameter) => parameter.id === id && parameter.exists !== false); -} - -function ensureSelectOption( - options: ReadonlyArray<{ value: string; label: string }>, - value: unknown, -): ReadonlyArray<{ value: string; label: string }> { - if (typeof value !== "string" || value.length === 0 || options.some((option) => option.value === value)) return options; - return [{ value, label: labelForCiv7SetupValue(value) }, ...options]; -} - -function mergeSelectOptions( - ...groups: ReadonlyArray> -): ReadonlyArray<{ value: string; label: string }> { - const seen = new Set(); - const out: Array<{ value: string; label: string }> = []; - for (const group of groups) { - for (const option of group) { - if (!option.value && seen.has(option.value)) continue; - if (option.value && seen.has(option.value)) continue; - seen.add(option.value); - out.push(option); - } - } - return out; -} - -function setupCatalogOptions(options: ReadonlyArray | undefined): ReadonlyArray<{ value: string; label: string }> { - return (options ?? []).map((option) => ({ value: option.value, label: option.label })); -} - -type LastRunSnapshot = { - worldSettings: WorldSettings; - recipeSettings: RecipeSettings; - pipelineConfig: PipelineConfig; -}; - -function liveSourceMatchesStudio(args: { - source: RunInGameSourceSnapshot; - recipeSettings: RecipeSettings; - worldSettings: WorldSettings; - pipelineConfig: PipelineConfig; - setupConfig: Civ7StudioSetupConfig; -}): boolean { - return ( - args.source.recipeSettings.recipe === args.recipeSettings.recipe && - args.source.recipeSettings.seed === args.recipeSettings.seed && - args.source.worldSettings.mapSize === args.worldSettings.mapSize && - args.source.worldSettings.playerCount === args.worldSettings.playerCount && - args.source.worldSettings.resources === args.worldSettings.resources && - studioSetupConfigsEqual(args.source.setupConfig, args.setupConfig) && - configsEqual(args.source.pipelineConfig, args.pipelineConfig) - ); -} - -type PresetErrorState = Readonly<{ - title: string; - message: string; - details?: ReadonlyArray; -}>; type AppContentProps = { themePreference: "system" | "light" | "dark"; diff --git a/apps/mapgen-studio/src/features/civ7Setup/api.ts b/apps/mapgen-studio/src/features/civ7Setup/api.ts new file mode 100644 index 0000000000..6e95aa3545 --- /dev/null +++ b/apps/mapgen-studio/src/features/civ7Setup/api.ts @@ -0,0 +1,139 @@ +// Civ7 setup HTTP surface: live setup-config snapshot, saved configurations, +// the setup option catalog, and autoplay control. +// +// Thin `fetch` wrappers over `/api/civ7/*`. Extracted verbatim from `App.tsx` +// during the app-decomposition slice — request shapes, the optional abort +// signal, and the per-endpoint error/status-code handling are unchanged. +import type { Civ7SavedSetupConfigFile, Civ7SetupSnapshotLike } from "./setupConfig"; + +export type Civ7SetupCatalogOption = Readonly<{ + value: string; + label: string; + source?: string; + sourcePath?: string; +}>; + +export type Civ7SetupCatalog = Readonly<{ + observedAt: string; + leaders: ReadonlyArray; + civilizations: ReadonlyArray; + difficulties: ReadonlyArray; + gameSpeeds: ReadonlyArray; +}>; + +export async function fetchCiv7SetupConfig(options: { signal?: AbortSignal } = {}): Promise< + | { ok: true; observedAt: string; setup: Civ7SetupSnapshotLike } + | { ok: false; error: string; observedAt?: string; statusCode?: number } +> { + try { + const res = await fetch("/api/civ7/setup-config", options.signal ? { signal: options.signal } : undefined); + const body = (await res.json().catch(() => null)) as { + ok?: boolean; + observedAt?: string; + setup?: Civ7SetupSnapshotLike; + error?: string; + } | null; + if (!res.ok || !body?.ok || !body.setup) { + return { + ok: false, + error: body?.error ?? `HTTP ${res.status}`, + observedAt: body?.observedAt, + statusCode: res.status, + }; + } + return { + ok: true, + observedAt: body.observedAt ?? new Date().toISOString(), + setup: body.setup, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Civ7 setup config unavailable" }; + } +} + +export async function fetchCiv7SavedSetupConfigs(): Promise< + | { ok: true; observedAt: string; directory: string; configurations: ReadonlyArray } + | { ok: false; error: string; observedAt?: string; statusCode?: number } +> { + try { + const res = await fetch("/api/civ7/saved-configs"); + const body = (await res.json().catch(() => null)) as { + ok?: boolean; + observedAt?: string; + directory?: string; + configurations?: ReadonlyArray; + error?: string; + } | null; + if (!res.ok || !body?.ok || !Array.isArray(body.configurations)) { + return { + ok: false, + error: body?.error ?? `HTTP ${res.status}`, + observedAt: body?.observedAt, + statusCode: res.status, + }; + } + return { + ok: true, + observedAt: body.observedAt ?? new Date().toISOString(), + directory: body.directory ?? "", + configurations: body.configurations, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Civ7 saved configurations unavailable" }; + } +} + +export async function fetchCiv7SetupCatalog(): Promise< + | { ok: true; catalog: Civ7SetupCatalog } + | { ok: false; error: string; observedAt?: string; statusCode?: number } +> { + try { + const res = await fetch("/api/civ7/setup-catalog"); + const body = (await res.json().catch(() => null)) as { + ok?: boolean; + catalog?: Civ7SetupCatalog; + error?: string; + observedAt?: string; + } | null; + if (!res.ok || !body?.ok || !body.catalog) { + return { + ok: false, + error: body?.error ?? `HTTP ${res.status}`, + observedAt: body?.observedAt, + statusCode: res.status, + }; + } + return { ok: true, catalog: body.catalog }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Civ7 setup catalog unavailable" }; + } +} + +export async function requestCiv7Autoplay(action: "start" | "stop"): Promise<{ + ok: boolean; + action?: "start" | "stop"; + autoplay?: { isActive?: boolean; isPaused?: boolean; isPausedOrPending?: boolean }; + game?: { turn?: { ok?: boolean; value?: number } }; + error?: string; +}> { + try { + const res = await fetch("/api/civ7/autoplay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + const body = (await res.json().catch(() => null)) as { + ok?: boolean; + action?: "start" | "stop"; + autoplay?: { isActive?: boolean; isPaused?: boolean; isPausedOrPending?: boolean }; + game?: { turn?: { ok?: boolean; value?: number } }; + error?: string; + } | null; + if (!res.ok || !body?.ok) { + return { ok: false, error: body?.error ?? `HTTP ${res.status}` }; + } + return { ok: true, action: body.action, autoplay: body.autoplay, game: body.game }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Civ7 autoplay request failed" }; + } +} diff --git a/apps/mapgen-studio/src/features/civ7Setup/livePreset.ts b/apps/mapgen-studio/src/features/civ7Setup/livePreset.ts new file mode 100644 index 0000000000..3bbf8addb8 --- /dev/null +++ b/apps/mapgen-studio/src/features/civ7Setup/livePreset.ts @@ -0,0 +1,5 @@ +// Identifiers for the synthetic "Live Game" preset that mirrors the last proved +// Run-in-Game source. Extracted verbatim from `App.tsx` during the +// app-decomposition slice — the id/key strings are unchanged. +export const LIVE_GAME_PRESET_ID = "live-game"; +export const LIVE_GAME_PRESET_KEY = `live:${LIVE_GAME_PRESET_ID}` as const; diff --git a/apps/mapgen-studio/src/features/civ7Setup/setupOptions.ts b/apps/mapgen-studio/src/features/civ7Setup/setupOptions.ts new file mode 100644 index 0000000000..fb817d0798 --- /dev/null +++ b/apps/mapgen-studio/src/features/civ7Setup/setupOptions.ts @@ -0,0 +1,43 @@ +// Pure helpers that shape Civ7 setup select options for the header controls: +// locate a setup parameter, guarantee the current value is present as an option, +// and merge/normalize option groups (live snapshot, saved configs, catalog). +// Extracted verbatim from `App.tsx` during the app-decomposition slice. +import { labelForCiv7SetupValue, type Civ7SetupParameterSnapshotLike } from "./setupConfig"; +import type { Civ7SetupCatalogOption } from "./api"; + +export function findSetupParameterLike( + parameters: ReadonlyArray | undefined, + id: string, +): Civ7SetupParameterSnapshotLike | undefined { + return parameters?.find((parameter) => parameter.id === id && parameter.exists !== false); +} + +export function ensureSelectOption( + options: ReadonlyArray<{ value: string; label: string }>, + value: unknown, +): ReadonlyArray<{ value: string; label: string }> { + if (typeof value !== "string" || value.length === 0 || options.some((option) => option.value === value)) return options; + return [{ value, label: labelForCiv7SetupValue(value) }, ...options]; +} + +export function mergeSelectOptions( + ...groups: ReadonlyArray> +): ReadonlyArray<{ value: string; label: string }> { + const seen = new Set(); + const out: Array<{ value: string; label: string }> = []; + for (const group of groups) { + for (const option of group) { + if (!option.value && seen.has(option.value)) continue; + if (option.value && seen.has(option.value)) continue; + seen.add(option.value); + out.push(option); + } + } + return out; +} + +export function setupCatalogOptions( + options: ReadonlyArray | undefined, +): ReadonlyArray<{ value: string; label: string }> { + return (options ?? []).map((option) => ({ value: option.value, label: option.label })); +} diff --git a/apps/mapgen-studio/src/features/configOverrides/configBuilders.ts b/apps/mapgen-studio/src/features/configOverrides/configBuilders.ts new file mode 100644 index 0000000000..43fb1ca3f9 --- /dev/null +++ b/apps/mapgen-studio/src/features/configOverrides/configBuilders.ts @@ -0,0 +1,129 @@ +// Deterministic config construction for the Studio authoring model. +// +// These helpers are pure (no React, no I/O): they build the per-recipe pipeline +// config skeleton, merge schema defaults and presets deterministically, and +// normalize the result through the recipe schema. They were extracted verbatim +// from `App.tsx` during the app-decomposition slice — behavior is unchanged. +import { normalizeStrict } from "@swooper/mapgen-core/compiler/normalize"; +import { stripSchemaMetadataRoot, type TSchema } from "@swooper/mapgen-core/authoring"; + +import { migratePipelineConfigUnknown } from "../configMigrations/pipelineConfig"; +import type { PipelineConfig } from "../../ui/types"; +import type { StudioRecipeUiMeta } from "../../recipes/catalog"; +import type { PresetKey } from "../presets/types"; + +export function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isNumericPathSegment(segment: string): boolean { + return /^[0-9]+$/.test(segment); +} + +const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); + +export function mergeDeterministic(base: unknown, overrides: unknown): unknown { + if (overrides === undefined) return base; + if (!isPlainObject(base) || !isPlainObject(overrides)) return overrides; + + const out: Record = { ...(base as Record) }; + for (const key of Object.keys(overrides)) { + if (FORBIDDEN_MERGE_KEYS.has(key)) continue; + out[key] = mergeDeterministic((base as Record)[key], overrides[key]); + } + return out; +} + +export function setAtPath(root: Record, path: readonly string[], value: unknown): void { + let current: unknown = root; + for (let i = 0; i < path.length; i += 1) { + const key = path[i]; + const isLast = i === path.length - 1; + const nextKey = path[i + 1]; + const wantsArray = typeof nextKey === "string" && isNumericPathSegment(nextKey); + + if (Array.isArray(current) && isNumericPathSegment(key)) { + const idx = Number(key); + if (isLast) { + if (current[idx] === undefined) current[idx] = value; + return; + } + const next = current[idx]; + if (!isPlainObject(next) && !Array.isArray(next)) { + current[idx] = wantsArray ? [] : {}; + } + current = current[idx]; + continue; + } + + if (!isPlainObject(current) && !Array.isArray(current)) { + return; + } + const record = current as Record; + if (isLast) { + if (record[key] === undefined) record[key] = value; + return; + } + const existing = record[key]; + if (!isPlainObject(existing) && !Array.isArray(existing)) { + record[key] = wantsArray ? [] : {}; + } + current = record[key]; + } +} + +export function buildConfigSkeleton(uiMeta: StudioRecipeUiMeta): PipelineConfig { + const skeleton: PipelineConfig = {}; + for (const stage of uiMeta.stages) { + const stageConfig: Record = { knobs: {} }; + for (const step of stage.steps) { + setAtPath(stageConfig, step.configFocusPathWithinStage, {}); + } + skeleton[stage.stageId] = stageConfig; + } + return skeleton; +} + +export function buildDefaultConfig( + schema: TSchema, + uiMeta: StudioRecipeUiMeta, + defaultConfig: unknown +): PipelineConfig { + const skeleton = buildConfigSkeleton(uiMeta); + const merged = mergeDeterministic(skeleton, stripSchemaMetadataRoot(defaultConfig)); + const { value, errors } = normalizeStrict(schema, merged, "/defaultConfig"); + if (errors.length > 0) { + console.error("[mapgen-studio] invalid recipe config schema defaults", errors); + return skeleton; + } + return value; +} + +export type PresetApplyResult = Readonly<{ + value: PipelineConfig | null; + errors: ReadonlyArray<{ path: string; message: string }>; +}>; + +export type AppliedPresetSnapshot = Readonly<{ + key: PresetKey; + config: unknown; +}>; + +export function applyPresetConfig(args: { + schema: TSchema; + uiMeta: StudioRecipeUiMeta; + presetConfig: unknown; + label: string; +}): PresetApplyResult { + const { schema, uiMeta, presetConfig, label } = args; + const skeleton = buildConfigSkeleton(uiMeta); + const migratedPresetConfig = migratePipelineConfigUnknown(stripSchemaMetadataRoot(presetConfig)); + const merged = mergeDeterministic(skeleton, migratedPresetConfig); + const { value, errors } = normalizeStrict(schema, merged, `/preset/${label}`); + if (errors.length > 0) return { value: null, errors }; + return { value, errors: [] }; +} + +export function formatPresetErrors(errors: ReadonlyArray<{ path: string; message: string }>): ReadonlyArray { + return errors.map((e) => `${e.path}: ${e.message}`); +} diff --git a/apps/mapgen-studio/src/features/mapConfigSave/api.ts b/apps/mapgen-studio/src/features/mapConfigSave/api.ts new file mode 100644 index 0000000000..c5f4374036 --- /dev/null +++ b/apps/mapgen-studio/src/features/mapConfigSave/api.ts @@ -0,0 +1,98 @@ +// Map-config save/deploy HTTP surface and its persistence key. +// +// Thin `fetch` wrappers over the `/api/map-configs*` endpoints plus the +// localStorage key used to correlate the last save/deploy request across +// dev-server reloads. Extracted verbatim from `App.tsx` during the +// app-decomposition slice — request shapes, error handling, and the key string +// are unchanged (localStorage contract preserved). +import { delay } from "../../shared/async"; +import type { MapConfigSaveDeployStatus } from "./status"; + +export const MAP_CONFIG_SAVE_LAST_REQUEST_KEY = "mapgen-studio.mapConfigSave.lastRequestId.v1"; + +export function toConfigId(label: string): string { + const id = label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return id || `map-config-${Date.now()}`; +} + +export async function fetchMapConfigSaveDeployStatus( + requestId: string, +): Promise { + try { + const res = await fetch(`/api/map-configs/status?requestId=${encodeURIComponent(requestId)}`); + const body = (await res.json().catch(() => null)) as (Partial & { error?: string }) | null; + if (!res.ok || !body?.requestId) { + return { ok: false, error: body?.error ?? `HTTP ${res.status}`, statusCode: res.status }; + } + return body as MapConfigSaveDeployStatus; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Save/Deploy status unavailable" }; + } +} + +export async function saveRepoBackedConfig(args: { + requestId: string; + id: string; + name: string; + description?: string; + sourcePath?: string; + sortIndex: number; + latitudeBounds?: Readonly<{ + topLatitude: number; + bottomLatitude: number; + }>; + config: unknown; + onStatus?: (status: MapConfigSaveDeployStatus) => void; +}): Promise< + | { ok: true; path?: string; deploy?: { command?: string } } + | { ok: false; error: string; saved?: boolean; deployed?: boolean; path?: string } +> { + const envelope = { + $schema: "../../../dist/recipes/standard-map-config.schema.json", + id: args.id, + name: args.name, + description: args.description?.trim() || args.name, + recipe: "standard", + sortIndex: args.sortIndex, + ...(args.latitudeBounds ? { latitudeBounds: args.latitudeBounds } : {}), + config: args.config, + }; + try { + const res = await fetch("/api/map-configs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId: args.requestId, id: args.id, sourcePath: args.sourcePath, envelope }), + }); + const body = (await res.json().catch(() => null)) as (Partial & { error?: string }) | null; + if (!res.ok || !body?.requestId) { + return { ok: false, error: body?.error ?? `HTTP ${res.status}`, path: body?.path }; + } + let status = body as MapConfigSaveDeployStatus; + args.onStatus?.(status); + while (status.status === "running") { + await delay(500); + const next = await fetchMapConfigSaveDeployStatus(status.requestId); + if (!("requestId" in next)) { + return { ok: false, error: next.error, saved: status.saved, deployed: status.deployed, path: status.path }; + } + status = next; + args.onStatus?.(status); + } + if (!status.ok || status.status === "failed") { + return { + ok: false, + error: status.error ?? "Save/deploy failed", + saved: status.saved, + deployed: status.deployed, + path: status.path, + }; + } + return { ok: true, path: status.path, deploy: status.deploy }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Repo config save failed" }; + } +} diff --git a/apps/mapgen-studio/src/features/presets/dialogState.ts b/apps/mapgen-studio/src/features/presets/dialogState.ts new file mode 100644 index 0000000000..6d93e202c8 --- /dev/null +++ b/apps/mapgen-studio/src/features/presets/dialogState.ts @@ -0,0 +1,7 @@ +// Shape of the preset-error dialog state. Extracted verbatim from `App.tsx` +// during the app-decomposition slice. +export type PresetErrorState = Readonly<{ + title: string; + message: string; + details?: ReadonlyArray; +}>; diff --git a/apps/mapgen-studio/src/features/presets/repoBacked.ts b/apps/mapgen-studio/src/features/presets/repoBacked.ts new file mode 100644 index 0000000000..a4a40e0bc6 --- /dev/null +++ b/apps/mapgen-studio/src/features/presets/repoBacked.ts @@ -0,0 +1,46 @@ +// Adapters for repo-backed preset overrides: merge per-recipe overrides over the +// built-in preset list, and construct a built-in preset from a saved repo config. +// Extracted verbatim from `App.tsx` during the app-decomposition slice. +import type { BuiltInPreset } from "../../recipes/catalog"; + +export function mergeBuiltInPresets( + base: ReadonlyArray, + overrides: Readonly>, +): ReadonlyArray { + const overrideIds = new Set(Object.keys(overrides)); + if (overrideIds.size === 0) return base; + const merged = base.map((preset) => { + const override = overrides[preset.id]; + if (!override) return preset; + overrideIds.delete(preset.id); + return override; + }); + for (const id of overrideIds) { + const override = overrides[id]; + if (override) merged.push(override); + } + return merged; +} + +export function toRepoBackedPreset(args: { + id: string; + label: string; + description?: string; + sourcePath?: string; + sortIndex?: number; + latitudeBounds?: Readonly<{ + topLatitude: number; + bottomLatitude: number; + }>; + config: unknown; +}): BuiltInPreset { + return { + id: args.id, + label: args.label, + description: args.description, + sourcePath: args.sourcePath, + sortIndex: args.sortIndex, + latitudeBounds: args.latitudeBounds, + config: args.config, + }; +} diff --git a/apps/mapgen-studio/src/features/runInGame/api.ts b/apps/mapgen-studio/src/features/runInGame/api.ts new file mode 100644 index 0000000000..1e64126cfa --- /dev/null +++ b/apps/mapgen-studio/src/features/runInGame/api.ts @@ -0,0 +1,84 @@ +// Run-in-game HTTP surface: start a run and poll its status. +// +// Thin `fetch` wrappers over `/api/civ7/run-in-game*`. Extracted verbatim from +// `App.tsx` during the app-decomposition slice — the request body shape +// (including the `assertNoRawControlFields`-protected payload assembled here), +// non-uniform error handling, and status-code propagation are unchanged. +import { normalizeStudioSetupConfig, type Civ7StudioSetupConfig } from "../civ7Setup/setupConfig"; +import type { RunInGameFailureDetails, RunInGameOperationStatus } from "./status"; + +export async function runCurrentConfigInGame(args: { + recipeId: string; + seed: string; + mapSize: string; + playerCount: number; + resources: string; + setupConfig: Civ7StudioSetupConfig; + materializationMode: "durable" | "disposable"; + restartCivProcess?: boolean; + selectedConfig?: { + id?: string; + label?: string; + description?: string; + sourcePath?: string; + sortIndex?: number; + latitudeBounds?: Readonly<{ + topLatitude: number; + bottomLatitude: number; + }>; + }; + config: unknown; + sourceSnapshot?: unknown; +}): Promise< + | RunInGameOperationStatus + | { ok: false; error: string; details?: RunInGameFailureDetails; statusCode?: number } +> { + try { + const res = await fetch("/api/civ7/run-in-game", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + recipeId: args.recipeId, + seed: args.seed, + mapSize: args.mapSize, + playerCount: args.playerCount, + resources: args.resources, + setupConfig: normalizeStudioSetupConfig(args.setupConfig), + materialization: { mode: args.materializationMode }, + ...(args.restartCivProcess ? { recovery: { restartCivProcess: true } } : {}), + selectedConfig: args.selectedConfig, + config: args.config, + sourceSnapshot: args.sourceSnapshot, + }), + }); + const body = (await res.json().catch(() => null)) as + | (Partial & { error?: string; details?: RunInGameFailureDetails }) + | null; + if (!res.ok || !body?.requestId) { + return { + ok: false, + error: body?.error ?? `HTTP ${res.status}`, + details: body?.details, + statusCode: res.status, + }; + } + return body as RunInGameOperationStatus; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Run in Game failed" }; + } +} + +export async function fetchRunInGameStatus( + requestId: string, +): Promise { + try { + const res = await fetch(`/api/civ7/run-in-game/status?requestId=${encodeURIComponent(requestId)}`); + const body = (await res.json().catch(() => null)) as (Partial & { error?: string }) | null; + if (!res.ok || !body?.requestId) { + return { ok: false, error: body?.error ?? `HTTP ${res.status}`, statusCode: res.status }; + } + return body as RunInGameOperationStatus; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Run in Game status unavailable" }; + } +} diff --git a/apps/mapgen-studio/src/features/runInGame/liveSource.ts b/apps/mapgen-studio/src/features/runInGame/liveSource.ts new file mode 100644 index 0000000000..8436925edf --- /dev/null +++ b/apps/mapgen-studio/src/features/runInGame/liveSource.ts @@ -0,0 +1,32 @@ +// Equality between a proved live-game source snapshot and the current Studio +// authoring model. Drives whether "Sync from Live Game" / "Run in Game" relation +// states are offered — its semantics are PARITY-CRITICAL and reproduced verbatim +// from `App.tsx` (app-decomposition slice). +import { configsEqual } from "../../ui/utils/config"; +import type { PipelineConfig, RecipeSettings, WorldSettings } from "../../ui/types"; +import { studioSetupConfigsEqual, type Civ7StudioSetupConfig } from "../civ7Setup/setupConfig"; +import type { RunInGameSourceSnapshot } from "./clientState"; + +export type LastRunSnapshot = { + worldSettings: WorldSettings; + recipeSettings: RecipeSettings; + pipelineConfig: PipelineConfig; +}; + +export function liveSourceMatchesStudio(args: { + source: RunInGameSourceSnapshot; + recipeSettings: RecipeSettings; + worldSettings: WorldSettings; + pipelineConfig: PipelineConfig; + setupConfig: Civ7StudioSetupConfig; +}): boolean { + return ( + args.source.recipeSettings.recipe === args.recipeSettings.recipe && + args.source.recipeSettings.seed === args.recipeSettings.seed && + args.source.worldSettings.mapSize === args.worldSettings.mapSize && + args.source.worldSettings.playerCount === args.worldSettings.playerCount && + args.source.worldSettings.resources === args.worldSettings.resources && + studioSetupConfigsEqual(args.source.setupConfig, args.setupConfig) && + configsEqual(args.source.pipelineConfig, args.pipelineConfig) + ); +} diff --git a/apps/mapgen-studio/src/features/runInGame/sourceSnapshotStorage.ts b/apps/mapgen-studio/src/features/runInGame/sourceSnapshotStorage.ts new file mode 100644 index 0000000000..d4a7b0ca6d --- /dev/null +++ b/apps/mapgen-studio/src/features/runInGame/sourceSnapshotStorage.ts @@ -0,0 +1,17 @@ +// localStorage bridge for run-in-game request correlation across dev-server +// reloads. These key strings are a persistence CONTRACT — they are reproduced +// verbatim from `App.tsx` (app-decomposition slice) and must not change. +import { parseRunInGameSourceSnapshot, type RunInGameSourceSnapshot } from "./clientState"; + +export const RUN_IN_GAME_LAST_REQUEST_KEY = "mapgen-studio.runInGame.lastRequestId.v1"; +export const RUN_IN_GAME_LAST_SNAPSHOT_KEY = "mapgen-studio.runInGame.lastSnapshot.v1"; +export const RUN_IN_GAME_LAST_SOURCE_KEY = "mapgen-studio.runInGame.lastSource.v1"; + +export function readStoredRunInGameSourceSnapshot(): RunInGameSourceSnapshot | null { + try { + if (typeof localStorage === "undefined") return null; + return parseRunInGameSourceSnapshot(localStorage.getItem(RUN_IN_GAME_LAST_SOURCE_KEY)); + } catch { + return null; + } +} diff --git a/apps/mapgen-studio/src/shared/async.ts b/apps/mapgen-studio/src/shared/async.ts new file mode 100644 index 0000000000..537986ebec --- /dev/null +++ b/apps/mapgen-studio/src/shared/async.ts @@ -0,0 +1,10 @@ +// Small async primitives shared across Studio features. Extracted verbatim from +// `App.tsx` during the app-decomposition slice. + +export function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +export function isAbortLikeError(err: unknown): boolean { + return Boolean(err && typeof err === "object" && (err as { name?: unknown }).name === "AbortError"); +} diff --git a/apps/mapgen-studio/src/shared/number.ts b/apps/mapgen-studio/src/shared/number.ts new file mode 100644 index 0000000000..e8f90f45ab --- /dev/null +++ b/apps/mapgen-studio/src/shared/number.ts @@ -0,0 +1,6 @@ +// Numeric helpers shared across Studio features. Extracted verbatim from +// `App.tsx` during the app-decomposition slice. + +export function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} From e36cf0658566524268123f2b1d10be490cb8625d Mon Sep 17 00:00:00 2001 From: Matei Date: Tue, 9 Jun 2026 01:34:15 -0400 Subject: [PATCH 3/4] =?UTF-8?q?docs(mapgen-studio):=20guardrail=20?= =?UTF-8?q?=E2=80=94=20consume=20CIV=20via=20typed=20oRPC=20client,=20neve?= =?UTF-8?q?r=20hand-roll=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User directive 2026-06-09. Studio talks to CIV through the control-oRPC client + contract behind one typed LiveControlPort (oRPC-native TanStack Query; studio-server proxies control-oRPC, never re-implements CIV API calls). No new manual /api fetch; existing /api/civ7/* middleware wrapped behind the port then deleted when the client lands. No FireTuner. Co-Authored-By: Claude Opus 4.8 --- docs/projects/mapgen-studio-redesign/FRAME.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/projects/mapgen-studio-redesign/FRAME.md b/docs/projects/mapgen-studio-redesign/FRAME.md index 34e3b164a7..1954244499 100644 --- a/docs/projects/mapgen-studio-redesign/FRAME.md +++ b/docs/projects/mapgen-studio-redesign/FRAME.md @@ -94,6 +94,16 @@ These are the standing constraints. Violating one is a defect, not a tradeoff. 6. **Fully autonomous to a built (unsubmitted) stack.** No per-gate approval. The one genuine taste fork — design *direction* — is confirmed once at `design:init` per that command's own protocol; everything else proceeds. +7. **Consume CIV through the typed oRPC client — never hand-roll fetch.** (User + directive 2026-06-09.) The studio talks to CIV via the control-oRPC client + + contract behind one typed `LiveControlPort` — oRPC-native TanStack Query on the + client; the studio-server *proxies/uses* control-oRPC, it does **not** + re-implement CIV API calls. Adding any new manual `/api`/socket fetch-and-parse + against CIV is forbidden. Until `@civ7/control-orpc` is consumable, the port is + satisfied by a **thin temporary transport that still speaks the typed + contract**; the existing hand-rolled `/api/civ7/*` middleware is *wrapped behind + the port, then deleted* when the client lands. No FireTuner reads. See + [`architecture/12-control-seam.md`](architecture/12-control-seam.md). ## 5. Skills & mechanisms (reference, load on demand) From e4e7c87bed8c15ab7c23103d6d8cd547a86b5474 Mon Sep 17 00:00:00 2001 From: Matei Date: Tue, 9 Jun 2026 01:41:42 -0400 Subject: [PATCH 4/4] =?UTF-8?q?docs(mapgen-studio):=20broaden=20oRPC=20gua?= =?UTF-8?q?rdrail=20=E2=80=94=20studio's=20OWN=20api=20is=20oRPC=20too,=20?= =?UTF-8?q?server=20un-deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reaffirmed: no manual fetch of our own /api either. Studio exposes its own effect-orpc contract+server (contracts already scaffolded), client consumes via oRPC client + oRPC-native TanStack Query. Server NOT deferred (uses direct-control, on main). Existing /api lifted verbatim into effect-orpc procedures (parity); Bun topology + prod parity = later supervised step. Data layer precedes decomposition. Co-Authored-By: Claude Opus 4.8 --- docs/projects/mapgen-studio-redesign/FRAME.md | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/projects/mapgen-studio-redesign/FRAME.md b/docs/projects/mapgen-studio-redesign/FRAME.md index 1954244499..b26c74a790 100644 --- a/docs/projects/mapgen-studio-redesign/FRAME.md +++ b/docs/projects/mapgen-studio-redesign/FRAME.md @@ -94,16 +94,27 @@ These are the standing constraints. Violating one is a defect, not a tradeoff. 6. **Fully autonomous to a built (unsubmitted) stack.** No per-gate approval. The one genuine taste fork — design *direction* — is confirmed once at `design:init` per that command's own protocol; everything else proceeds. -7. **Consume CIV through the typed oRPC client — never hand-roll fetch.** (User - directive 2026-06-09.) The studio talks to CIV via the control-oRPC client + - contract behind one typed `LiveControlPort` — oRPC-native TanStack Query on the - client; the studio-server *proxies/uses* control-oRPC, it does **not** - re-implement CIV API calls. Adding any new manual `/api`/socket fetch-and-parse - against CIV is forbidden. Until `@civ7/control-orpc` is consumable, the port is - satisfied by a **thin temporary transport that still speaks the typed - contract**; the existing hand-rolled `/api/civ7/*` middleware is *wrapped behind - the port, then deleted* when the client lands. No FireTuner reads. See - [`architecture/12-control-seam.md`](architecture/12-control-seam.md). +7. **Everything talks oRPC — never hand-roll `fetch`.** (User directive + 2026-06-09, reaffirmed.) Two layers, one rule: + - **The studio's own API:** the studio exposes its **own oRPC contract + + server** (`@civ7/studio-server`, effect-orpc; contracts already scaffolded at + `packages/studio-server/src/contract/*`) and the React client consumes it + through the **oRPC client + oRPC-native TanStack Query**. **No manual `fetch` + of `/api/*` on either end.** The studio-server is **NOT deferred** — its + endpoints use `@civ7/direct-control` (already on `main`), so it has no + dependency on the control-oRPC merge. + - **CIV / live-control:** the live-state subset routes through the control-oRPC + client + contract behind the same typed boundary (the seam, + [`architecture/12-control-seam.md`](architecture/12-control-seam.md)); until + `@civ7/control-orpc` is consumable it is served by the studio-server's own + direct-control-backed procedures. No FireTuner reads; never re-implement CIV + calls as new manual fetch. + + The existing hand-rolled `/api/*` middleware is **lifted verbatim into + effect-orpc procedures** (behavior parity preserved), not extended. The + Bun-server topology + production `/api` parity fix is a later **supervised** + step (heaviest/most parity-critical); the immediate win is: client + server + both speak oRPC, zero manual fetch. ## 5. Skills & mechanisms (reference, load on demand)