diff --git a/apps/mapgen-studio/src/app/StudioShell.tsx b/apps/mapgen-studio/src/app/StudioShell.tsx index 1abffd3053..058f6c9203 100644 --- a/apps/mapgen-studio/src/app/StudioShell.tsx +++ b/apps/mapgen-studio/src/app/StudioShell.tsx @@ -473,6 +473,7 @@ export function StudioShell(props: StudioShellProps) { // shapes are unchanged so `setupControlOptions` below consumes them as before. const { savedSetupConfigs, setupCatalog } = useSetupDataQueries(); const [autoplayActionRunning, setAutoplayActionRunning] = useState(false); + const [exploreActionRunning, setExploreActionRunning] = useState(false); const saveDeployRunning = saveDeployOperation?.status === "running"; const runInGameRunning = runInGameOperation?.status === "running"; @@ -1752,6 +1753,30 @@ export function StudioShell(props: StudioShellProps) { } }, [autoplayActionRunning, browserRunning, liveRuntime.autoplayActive, runInGameRunning, saveDeployRunning, toast]); + /** + * Explore (reveal the map) in the live game via the canonical + * `display.explore.request` control procedure — the studio's map-QA verb. + * The grant stays held (fog does not re-cover) for a disposable studio + * session; player 0 is the canonical local player, matching the CLI. + */ + const handleExplore = useCallback(async () => { + if (exploreActionRunning || browserRunning || runInGameRunning || saveDeployRunning) return; + setExploreActionRunning(true); + try { + const result = await liveControlPort.display.explore.request({ playerId: 0 }); + toast( + result.classification === "already-explored" + ? "Live map already fully revealed" + : `Live map revealed — ${result.grantedPlots} plots granted`, + { variant: "success" }, + ); + } catch (err) { + toast(`Explore failed: ${err instanceof Error ? err.message : "live game unavailable"}`, { variant: "error" }); + } finally { + setExploreActionRunning(false); + } + }, [browserRunning, exploreActionRunning, runInGameRunning, saveDeployRunning, toast]); + const copyRunInGameDiagnostics = useCallback(async () => { if (!runInGameOperation) return; try { @@ -2302,6 +2327,8 @@ export function StudioShell(props: StudioShellProps) { onSyncFromLiveGame={syncStudioFromLiveGame} isAutoplayActionRunning={autoplayActionRunning} onToggleAutoplay={handleToggleAutoplay} + onExplore={handleExplore} + isExploreActionRunning={exploreActionRunning} operationControlsDisabled={browserRunning || runInGameRunning || saveDeployRunning} isRunInGameRunning={runInGameRunning} runInGameStatus={runInGameOperation} diff --git a/apps/mapgen-studio/src/lib/control/liveControlPort.ts b/apps/mapgen-studio/src/lib/control/liveControlPort.ts index 12246d1657..1824298057 100644 --- a/apps/mapgen-studio/src/lib/control/liveControlPort.ts +++ b/apps/mapgen-studio/src/lib/control/liveControlPort.ts @@ -7,10 +7,40 @@ export type LiveControlReadinessResult = Awaited< ReturnType >; +export type LiveControlExploreRequestInput = Parameters< + StudioCiv7ControlOrpcClient["display"]["explore"]["request"] +>[0]; + +export type LiveControlExploreRequestResult = Awaited< + ReturnType +>; + +/** + * The studio's typed seam onto the canonical Civ7 control-oRPC surface. + * + * Why a port instead of passing the raw client around: control procedures + * are a pinned taxonomy (`display.*`, `view.*`, `readiness.*`) that must be + * called through the canonical router client — never re-exposed as bespoke + * studio routes. The port names exactly the procedures the shell consumes, + * mirrors the router's path shape, and keeps test seams trivial (hand a + * fake port, not a fake transport). + */ export type LiveControlPort = Readonly<{ readiness: Readonly<{ current(): Promise; }>; + display: Readonly<{ + explore: Readonly<{ + /** + * Reveal the map for a player via a tracked visibility grant + * (`display.explore.request`): suspends the display queue with + * readback verification so discovery popups don't storm the UI, + * drains gameplay discovery, and reports before/after visibility + * probes plus an explored/already-explored/unverified classification. + */ + request(input: LiveControlExploreRequestInput): Promise; + }>; + }>; }>; export function createBoundLiveControlPort( @@ -20,6 +50,11 @@ export function createBoundLiveControlPort( readiness: { current: () => client.readiness.current({}), }, + display: { + explore: { + request: (input) => client.display.explore.request(input), + }, + }, }; } diff --git a/apps/mapgen-studio/src/ui/components/GameConsole.tsx b/apps/mapgen-studio/src/ui/components/GameConsole.tsx index 31321ae950..8c0de4da95 100644 --- a/apps/mapgen-studio/src/ui/components/GameConsole.tsx +++ b/apps/mapgen-studio/src/ui/components/GameConsole.tsx @@ -56,10 +56,14 @@ export interface GameConsoleProps { /** Callback to start or stop Civ7 native autoplay */ onToggleAutoplay?: () => void; /** - * Callback for the Explore command (tile visibility/exploration in the - * live game). The button is a disabled placeholder until this is wired. + * Callback for the Explore command: reveal the full map in the live game + * via the canonical `display.explore.request` control procedure (UI + * display queue suppressed while gameplay discovers the tiles). The + * button renders disabled until a handler is wired. */ onExplore?: () => void; + /** Whether a map explore (reveal) request is in flight */ + isExploreActionRunning?: boolean; /** Whether any studio/game operation is in flight (shared control gating) */ operationControlsDisabled: boolean; /** Whether Civ7 Run in Game is currently running */ @@ -99,6 +103,7 @@ export const GameConsole: React.FC = ({ isAutoplayActionRunning = false, onToggleAutoplay, onExplore, + isExploreActionRunning = false, operationControlsDisabled, isRunInGameRunning, runInGameStatus, @@ -181,9 +186,11 @@ export const GameConsole: React.FC = ({ : liveRuntime?.autoplayActive ? `Stop Civ7 autoplay${liveRuntime.autoplayPaused ? " (paused)" : ""}` : "Start Civ7 autoplay"; - const exploreTitle = onExplore - ? "Explore: toggle tile visibility in the live game" - : "Explore: tile visibility control is not yet available"; + const exploreTitle = !onExplore + ? "Explore: tile visibility control is not yet available" + : isExploreActionRunning + ? "Explore request in flight" + : "Explore: reveal the full map in the live game"; const saveDeployLabel = saveDeployStatus ? formatMapConfigSaveDeployPhaseLabel(saveDeployStatus.phase) : null; const saveDeployActive = saveDeployStatus?.status === "running"; const saveDeployDotClass = @@ -300,12 +307,16 @@ export const GameConsole: React.FC = ({ variant="outline" size="icon" onClick={onExplore} - disabled={operationControlsDisabled || liveRuntime?.status !== "ok" || !onExplore} + disabled={operationControlsDisabled || isExploreActionRunning || liveRuntime?.status !== "ok" || !onExplore} aria-label={exploreTitle} title={exploreTitle} className="shrink-0"> - + {isExploreActionRunning ? ( + + ) : ( + + )} {exploreTitle} diff --git a/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx b/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx index 20295ceef3..81fd05cf27 100644 --- a/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx +++ b/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx @@ -185,6 +185,25 @@ describe("GameConsole live runtime and save/deploy", () => { expect(html).toContain("Explore: tile visibility control is not yet available"); }); + it("renders the wired Explore reveal action when live and a handler exists", () => { + const html = renderConsole({ + liveRuntime: { status: "ok", readiness: "ready" }, + onExplore: vi.fn(), + }); + + expect(html).toContain("Explore: reveal the full map in the live game"); + }); + + it("disables Explore and narrates the in-flight request while revealing", () => { + const html = renderConsole({ + liveRuntime: { status: "ok", readiness: "ready" }, + onExplore: vi.fn(), + isExploreActionRunning: true, + }); + + expect(html).toContain("Explore request in flight"); + }); + it("highlights live seed status when a proved live game is out of sync with Studio", () => { const html = renderConsole({ liveRuntime: { status: "ok", turn: 12, seed: 123 },