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
318 changes: 96 additions & 222 deletions apps/mapgen-studio/src/app/StudioShell.tsx

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions apps/mapgen-studio/src/app/hooks/useOperationStatusPolls.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
refreshMapConfigSaveDeployStatus: (requestId: string) => Promise<void>;
lastRunInGameToastRef: React.MutableRefObject<string | null>;
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,
});
}
96 changes: 96 additions & 0 deletions apps/mapgen-studio/src/app/hooks/useSetupDataQueries.ts
Original file line number Diff line number Diff line change
@@ -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.<ns>.<proc>.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<Civ7SavedSetupConfigFile>;
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<SavedSetupConfigsView>(() => {
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<Civ7SavedSetupConfigFile>,
updatedAt: body.observedAt ?? new Date().toISOString(),
};
}, [savedConfigsQuery.data, savedConfigsQuery.error, savedConfigsQuery.isError]);

const setupCatalog = useMemo<SetupCatalogView>(() => {
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 };
}
10 changes: 7 additions & 3 deletions apps/mapgen-studio/src/features/runInGame/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof orpcClient.runInGame.start>[0] = {
recipeId: args.recipeId,
seed: args.seed,
mapSize: args.mapSize,
Expand All @@ -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<typeof orpcClient.runInGame.start>[0];
};
const body = await orpcClient.runInGame.start(request);
return body as RunInGameOperationStatus;
} catch (err) {
Expand Down
Loading