Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
591 changes: 40 additions & 551 deletions apps/mapgen-studio/src/App.tsx

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions apps/mapgen-studio/src/features/civ7Setup/api.ts
Original file line number Diff line number Diff line change
@@ -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<Civ7SetupCatalogOption>;
civilizations: ReadonlyArray<Civ7SetupCatalogOption>;
difficulties: ReadonlyArray<Civ7SetupCatalogOption>;
gameSpeeds: ReadonlyArray<Civ7SetupCatalogOption>;
}>;

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<Civ7SavedSetupConfigFile> }
| { 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<Civ7SavedSetupConfigFile>;
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" };
}
}
5 changes: 5 additions & 0 deletions apps/mapgen-studio/src/features/civ7Setup/livePreset.ts
Original file line number Diff line number Diff line change
@@ -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;
43 changes: 43 additions & 0 deletions apps/mapgen-studio/src/features/civ7Setup/setupOptions.ts
Original file line number Diff line number Diff line change
@@ -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<Civ7SetupParameterSnapshotLike> | 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 }>>
): ReadonlyArray<{ value: string; label: string }> {
const seen = new Set<string>();
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<Civ7SetupCatalogOption> | undefined,
): ReadonlyArray<{ value: string; label: string }> {
return (options ?? []).map((option) => ({ value: option.value, label: option.label }));
}
129 changes: 129 additions & 0 deletions apps/mapgen-studio/src/features/configOverrides/configBuilders.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown> = { ...(base as Record<string, unknown>) };
for (const key of Object.keys(overrides)) {
if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
out[key] = mergeDeterministic((base as Record<string, unknown>)[key], overrides[key]);
}
return out;
}

export function setAtPath(root: Record<string, unknown>, 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<string, unknown>;
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<string, unknown> = { 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<PipelineConfig>(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<PipelineConfig>(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<string> {
return errors.map((e) => `${e.path}: ${e.message}`);
}
Loading