DeltaZN commited on
Commit
1f1fdda
·
1 Parent(s): 0092d86

feat: gradio app

Browse files
README.md CHANGED
@@ -1,3 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # God Simulator
2
 
3
  Python-first foundation for an AI civilization sandbox.
@@ -49,6 +62,33 @@ Controls:
49
  - scroll to zoom;
50
  - use `WASD` or arrow keys to pan across the terrain.
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ## LLM Connector
53
 
54
  The default runtime config is `config/game.modal.local.json`, which uses the
 
1
+ ---
2
+ title: God Simulator
3
+ colorFrom: green
4
+ colorTo: blue
5
+ sdk: gradio
6
+ sdk_version: 6.17.3
7
+ python_version: 3.11
8
+ app_file: app.py
9
+ header: mini
10
+ fullWidth: true
11
+ startup_duration_timeout: 1h
12
+ ---
13
+
14
  # God Simulator
15
 
16
  Python-first foundation for an AI civilization sandbox.
 
62
  - scroll to zoom;
63
  - use `WASD` or arrow keys to pan across the terrain.
64
 
65
+ ## Hugging Face Space
66
+
67
+ This repo keeps the custom React/Three UI and wraps the same game runtime in
68
+ `gradio.Server` via `app.py`.
69
+
70
+ Build the custom UI before launching the Space app:
71
+
72
+ ```powershell
73
+ npm install
74
+ npm run gradio
75
+ ```
76
+
77
+ The Gradio server serves the built UI from `dist/frontend` and exposes the same
78
+ game endpoints:
79
+
80
+ - `GET /health`
81
+ - `GET /state`
82
+ - `GET /claw3d/state`
83
+ - `POST /tick`
84
+ - `POST /god-command`
85
+
86
+ It also registers Gradio API endpoints named `/tick` and `/god_command`, so the
87
+ app still runs through Gradio's API engine while preserving the custom UI. For
88
+ Hugging Face Spaces, `requirements.txt` installs the local project from
89
+ `pyproject.toml`; set `GOD_SIMULATOR_CONFIG` if the Space should use a config
90
+ other than `config/game.modal.local.json`.
91
+
92
  ## LLM Connector
93
 
94
  The default runtime config is `config/game.modal.local.json`, which uses the
app.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from god_simulator.api.gradio_app import create_gradio_app
7
+
8
+ app = create_gradio_app(
9
+ config_path=Path(os.getenv("GOD_SIMULATOR_CONFIG", "config/game.modal.local.json")),
10
+ static_dir=Path(os.getenv("GOD_SIMULATOR_STATIC_DIR", "dist/frontend")),
11
+ )
12
+
13
+
14
+ def _server_port() -> int | None:
15
+ value = os.getenv("GRADIO_SERVER_PORT")
16
+ return int(value) if value else None
17
+
18
+
19
+ if __name__ == "__main__":
20
+ app.launch(
21
+ server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
22
+ server_port=_server_port(),
23
+ )
frontend/src/api.ts CHANGED
@@ -1,6 +1,13 @@
1
  import type { WorldSnapshot } from "./types";
2
 
3
- const API_BASE = import.meta.env.VITE_GOD_SIMULATOR_API ?? "http://127.0.0.1:8000";
 
 
 
 
 
 
 
4
 
5
  export class ApiResponseError extends Error {
6
  readonly status: number;
 
1
  import type { WorldSnapshot } from "./types";
2
 
3
+ const API_BASE = import.meta.env.VITE_GOD_SIMULATOR_API ?? defaultApiBase();
4
+
5
+ function defaultApiBase(): string {
6
+ if (window.location.hostname === "127.0.0.1" && window.location.port === "5173") {
7
+ return "http://127.0.0.1:8000";
8
+ }
9
+ return window.location.origin;
10
+ }
11
 
12
  export class ApiResponseError extends Error {
13
  readonly status: number;
package.json CHANGED
@@ -6,6 +6,7 @@
6
  "scripts": {
7
  "dev": "vite --host 127.0.0.1",
8
  "build": "tsc -b && vite build",
 
9
  "preview": "vite preview --host 127.0.0.1",
10
  "typecheck": "tsc -b",
11
  "test:e2e": "playwright test"
 
6
  "scripts": {
7
  "dev": "vite --host 127.0.0.1",
8
  "build": "tsc -b && vite build",
9
+ "gradio": "npm run build && uv run python app.py",
10
  "preview": "vite preview --host 127.0.0.1",
11
  "typecheck": "tsc -b",
12
  "test:e2e": "playwright test"
pyproject.toml CHANGED
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
7
  license = { text = "MIT" }
8
  authors = [{ name = "God Simulator Team" }]
9
  dependencies = [
 
10
  "openai>=1.0",
11
  ]
12
 
 
7
  license = { text = "MIT" }
8
  authors = [{ name = "God Simulator Team" }]
9
  dependencies = [
10
+ "gradio>=6.17.3",
11
  "openai>=1.0",
12
  ]
13
 
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ -e .
src/god_simulator/api/gradio_app.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from http import HTTPStatus
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import gradio as gr
8
+ from fastapi import HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel
13
+
14
+ from god_simulator.api.runtime import GameRuntime, create_game_runtime
15
+ from god_simulator.config import load_game_config
16
+ from god_simulator.simulation.spawning import create_world
17
+
18
+
19
+ class GodCommandRequest(BaseModel):
20
+ command: str
21
+
22
+
23
+ def create_gradio_app(
24
+ *,
25
+ config_path: Path,
26
+ static_dir: Path = Path("dist/frontend"),
27
+ ) -> gr.Server:
28
+ config = load_game_config(config_path)
29
+ runtime = create_game_runtime(world=create_world(config), config=config)
30
+ app = gr.Server(title="God Simulator")
31
+
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=["*"],
35
+ allow_credentials=False,
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+ register_api_routes(app, runtime)
41
+ register_gradio_api(app, runtime)
42
+ register_static_routes(app, static_dir)
43
+ return app
44
+
45
+
46
+ def register_api_routes(app: gr.Server, runtime: GameRuntime) -> None:
47
+ @app.get("/health")
48
+ def health() -> dict[str, Any]:
49
+ return runtime.health()
50
+
51
+ @app.get("/state")
52
+ def state() -> dict[str, Any]:
53
+ return runtime.state()
54
+
55
+ @app.get("/claw3d/state")
56
+ def claw3d_state(warmup: str | None = None) -> dict[str, Any]:
57
+ return runtime.claw3d_state(warmup=_truthy(warmup))
58
+
59
+ @app.post("/tick")
60
+ def tick() -> JSONResponse:
61
+ status, payload = runtime.tick()
62
+ return JSONResponse(status_code=int(status), content=payload)
63
+
64
+ @app.post("/god-command")
65
+ def god_command(request: GodCommandRequest) -> JSONResponse:
66
+ status, payload = runtime.god_command(request.command)
67
+ return JSONResponse(status_code=int(status), content=payload)
68
+
69
+
70
+ def register_gradio_api(app: gr.Server, runtime: GameRuntime) -> None:
71
+ @app.api(name="tick", concurrency_limit=1) # type: ignore[untyped-decorator]
72
+ def tick_api() -> dict[str, Any]:
73
+ status, payload = runtime.tick()
74
+ if status != HTTPStatus.OK:
75
+ raise HTTPException(status_code=int(status), detail=payload)
76
+ return payload
77
+
78
+ @app.api(name="god_command", concurrency_limit=1) # type: ignore[untyped-decorator]
79
+ def god_command_api(command: str) -> dict[str, Any]:
80
+ status, payload = runtime.god_command(command)
81
+ if status != HTTPStatus.OK:
82
+ raise HTTPException(status_code=int(status), detail=payload)
83
+ return payload
84
+
85
+
86
+ def register_static_routes(app: gr.Server, static_dir: Path) -> None:
87
+ static_dir = static_dir.resolve()
88
+ assets_dir = static_dir / "assets"
89
+ index_path = static_dir / "index.html"
90
+
91
+ if assets_dir.exists():
92
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
93
+
94
+ @app.get("/", response_class=HTMLResponse, response_model=None)
95
+ def root() -> FileResponse | HTMLResponse:
96
+ if index_path.exists():
97
+ return FileResponse(index_path)
98
+ return missing_frontend_response(static_dir)
99
+
100
+ @app.get("/{path:path}", response_class=HTMLResponse, response_model=None)
101
+ def spa_fallback(path: str) -> FileResponse | HTMLResponse:
102
+ requested_path = (static_dir / path).resolve()
103
+ if index_path.exists() and _is_within(static_dir, requested_path):
104
+ if requested_path.is_file():
105
+ return FileResponse(requested_path)
106
+ return FileResponse(index_path)
107
+ return missing_frontend_response(static_dir)
108
+
109
+
110
+ def missing_frontend_response(static_dir: Path) -> HTMLResponse:
111
+ body = (
112
+ "<main style='font-family: system-ui; padding: 2rem'>"
113
+ "<h1>God Simulator frontend is not built</h1>"
114
+ f"<p>Expected built assets in <code>{static_dir}</code>.</p>"
115
+ "<p>Run <code>npm install</code> and <code>npm run build</code>, "
116
+ "then restart the Gradio app.</p>"
117
+ "</main>"
118
+ )
119
+ return HTMLResponse(body, status_code=503)
120
+
121
+
122
+ def _truthy(value: str | None) -> bool:
123
+ return value is not None and value.lower() in ("1", "true", "yes")
124
+
125
+
126
+ def _is_within(parent: Path, child: Path) -> bool:
127
+ try:
128
+ child.relative_to(parent)
129
+ except ValueError:
130
+ return False
131
+ return True
src/god_simulator/api/runtime.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ from http import HTTPStatus
7
+ from threading import Lock, Thread
8
+ from typing import Any, Protocol
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.request import urlopen
11
+
12
+ from god_simulator.config import ConnectorConfig, GameConfig
13
+ from god_simulator.domain import WorldState
14
+ from god_simulator.rendering.claw3d_contract import to_claw3d_snapshot
15
+ from god_simulator.simulation.connectors.base import WorldSimulator
16
+ from god_simulator.simulation.connectors.factory import create_world_simulator
17
+ from god_simulator.simulation.god_console import GodConsole, apply_god_command_plan
18
+ from god_simulator.simulation.tick import apply_tick_plan, plan_world_tick
19
+
20
+
21
+ class GameRuntime:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ world: WorldState,
26
+ simulator: WorldSimulator,
27
+ god_console: GodConsole | None = None,
28
+ modal_health_warmer: HealthWarmer | None = None,
29
+ ) -> None:
30
+ self._world = world
31
+ self._simulator = simulator
32
+ self._god_console = god_console
33
+ self._modal_health_warmer = modal_health_warmer
34
+ self._lock = Lock()
35
+ self._tick_status = TickStatus()
36
+ self._god_status = GodCommandStatus()
37
+
38
+ @property
39
+ def simulator_name(self) -> str:
40
+ return self._simulator.name
41
+
42
+ def health(self) -> dict[str, Any]:
43
+ with self._lock:
44
+ return {
45
+ "status": "ok",
46
+ "tick": self._world.tick,
47
+ "simulator": self._simulator.name,
48
+ "last_tick_source": self._world.last_tick_source,
49
+ **tick_status_payload(self._tick_status),
50
+ }
51
+
52
+ def state(self) -> dict[str, Any]:
53
+ with self._lock:
54
+ payload = self._world.to_dict()
55
+ payload["simulation"] = tick_status_payload(self._tick_status)
56
+ return payload
57
+
58
+ def claw3d_state(self, *, warmup: bool = False) -> dict[str, Any]:
59
+ if warmup and self._modal_health_warmer is not None:
60
+ self._modal_health_warmer.trigger(force=True)
61
+
62
+ with self._lock:
63
+ payload = to_claw3d_snapshot(
64
+ self._world,
65
+ tick_in_progress=self._tick_status.in_progress,
66
+ pending_tick=self._tick_status.pending_tick,
67
+ )
68
+ if self._modal_health_warmer is not None:
69
+ payload["simulation"]["models"] = self._modal_health_warmer.statuses()
70
+ return payload
71
+
72
+ def tick(self) -> tuple[HTTPStatus, dict[str, Any]]:
73
+ planning_world, next_tick, conflict = self._begin_tick()
74
+ if conflict is not None:
75
+ return HTTPStatus.CONFLICT, conflict
76
+ assert planning_world is not None
77
+ assert next_tick is not None
78
+
79
+ try:
80
+ planned_tick, plan = plan_world_tick(planning_world, self._simulator, next_tick)
81
+ except Exception as exc:
82
+ with self._lock:
83
+ self._tick_status.in_progress = False
84
+ self._tick_status.pending_tick = None
85
+ return HTTPStatus.INTERNAL_SERVER_ERROR, {
86
+ "error": "tick_failed",
87
+ "message": str(exc),
88
+ }
89
+
90
+ with self._lock:
91
+ apply_tick_plan(self._world, planned_tick, plan)
92
+ self._tick_status.in_progress = False
93
+ self._tick_status.pending_tick = None
94
+ payload = self._world.to_dict()
95
+ payload["simulation"] = tick_status_payload(self._tick_status)
96
+ return HTTPStatus.OK, payload
97
+
98
+ def god_command(self, command: str) -> tuple[HTTPStatus, dict[str, Any]]:
99
+ if self._god_console is None:
100
+ return HTTPStatus.BAD_REQUEST, {"error": "god_console_unavailable"}
101
+ if not command.strip():
102
+ return HTTPStatus.BAD_REQUEST, {
103
+ "error": "invalid_request",
104
+ "message": "Expected non-empty 'command'.",
105
+ }
106
+
107
+ planning_world, conflict = self._begin_god_command()
108
+ if conflict is not None:
109
+ return HTTPStatus.CONFLICT, conflict
110
+ assert planning_world is not None
111
+
112
+ try:
113
+ plan = self._god_console.propose(planning_world, command.strip())
114
+ except Exception as exc:
115
+ with self._lock:
116
+ self._god_status.in_progress = False
117
+ return HTTPStatus.INTERNAL_SERVER_ERROR, {
118
+ "error": "god_command_failed",
119
+ "message": str(exc),
120
+ }
121
+
122
+ with self._lock:
123
+ applied = apply_god_command_plan(self._world, plan)
124
+ self._god_status.in_progress = False
125
+ return HTTPStatus.OK, {
126
+ "source": plan.source,
127
+ "summary": plan.summary,
128
+ "applied": applied,
129
+ "snapshot": to_claw3d_snapshot(
130
+ self._world,
131
+ tick_in_progress=self._tick_status.in_progress,
132
+ pending_tick=self._tick_status.pending_tick,
133
+ ),
134
+ }
135
+
136
+ def _begin_tick(self) -> tuple[WorldState | None, int | None, dict[str, Any] | None]:
137
+ with self._lock:
138
+ if self._tick_status.in_progress:
139
+ return None, None, {
140
+ "error": "tick_in_progress",
141
+ "tick": self._world.tick,
142
+ **tick_status_payload(self._tick_status),
143
+ }
144
+ if self._god_status.in_progress:
145
+ return None, None, {
146
+ "error": "god_command_in_progress",
147
+ "message": "A god command is already modifying the world.",
148
+ "tick": self._world.tick,
149
+ **tick_status_payload(self._tick_status),
150
+ }
151
+
152
+ next_tick = self._world.tick + 1
153
+ planning_world = deepcopy(self._world)
154
+ self._tick_status.in_progress = True
155
+ self._tick_status.pending_tick = next_tick
156
+ return planning_world, next_tick, None
157
+
158
+ def _begin_god_command(self) -> tuple[WorldState | None, dict[str, Any] | None]:
159
+ with self._lock:
160
+ if self._tick_status.in_progress:
161
+ return None, {
162
+ "error": "tick_in_progress",
163
+ "message": "God commands are disabled while a tick is running.",
164
+ **tick_status_payload(self._tick_status),
165
+ }
166
+ if self._god_status.in_progress:
167
+ return None, {
168
+ "error": "god_command_in_progress",
169
+ "message": "Another god command is already running.",
170
+ **tick_status_payload(self._tick_status),
171
+ }
172
+
173
+ self._god_status.in_progress = True
174
+ return deepcopy(self._world), None
175
+
176
+
177
+ def create_game_runtime(*, world: WorldState, config: GameConfig) -> GameRuntime:
178
+ simulator = create_world_simulator(config)
179
+ god_console_config = config.god_console or config.connector
180
+ god_console = (
181
+ GodConsole(god_console_config)
182
+ if god_console_config.type == "openai_compatible"
183
+ else None
184
+ )
185
+ return GameRuntime(
186
+ world=world,
187
+ simulator=simulator,
188
+ god_console=god_console,
189
+ modal_health_warmer=ModalHealthWarmer(modal_health_targets(config)),
190
+ )
191
+
192
+
193
+ @dataclass(slots=True)
194
+ class TickStatus:
195
+ in_progress: bool = False
196
+ pending_tick: int | None = None
197
+
198
+
199
+ @dataclass(slots=True)
200
+ class GodCommandStatus:
201
+ in_progress: bool = False
202
+
203
+
204
+ def tick_status_payload(tick_status: TickStatus) -> dict[str, Any]:
205
+ return {
206
+ "tick_in_progress": tick_status.in_progress,
207
+ "pending_tick": tick_status.pending_tick,
208
+ }
209
+
210
+
211
+ class ModalHealthWarmer:
212
+ def __init__(
213
+ self,
214
+ targets: list[ModalHealthTarget],
215
+ *,
216
+ cooldown_seconds: float = 300,
217
+ warmup_after_seconds: float = 2,
218
+ warmup_retry_seconds: float = 5,
219
+ opener: UrlOpener = urlopen,
220
+ ) -> None:
221
+ self._states = {
222
+ target.id: ModalHealthState(target=target)
223
+ for target in targets
224
+ }
225
+ self._cooldown_seconds = cooldown_seconds
226
+ self._warmup_after_seconds = warmup_after_seconds
227
+ self._warmup_retry_seconds = warmup_retry_seconds
228
+ self._opener = opener
229
+ self._lock = Lock()
230
+
231
+ def trigger(self, *, force: bool = False) -> None:
232
+ if not self._states:
233
+ return
234
+
235
+ now = time.monotonic()
236
+ launches: list[tuple[str, str, int]] = []
237
+
238
+ with self._lock:
239
+ for target_id, state in self._states.items():
240
+ if state.in_progress:
241
+ continue
242
+ if not force and now - state.last_started_at < self._cooldown_seconds:
243
+ continue
244
+ state.in_progress = True
245
+ state.status = "checking"
246
+ state.http_status = None
247
+ state.last_started_at = now
248
+ state.generation += 1
249
+ launches.append((target_id, state.target.health_url, state.generation))
250
+
251
+ for target_id, health_url, generation in launches:
252
+ Thread(
253
+ target=self._mark_warmup_if_pending,
254
+ args=(target_id, generation),
255
+ name="modal-health-warmup-status",
256
+ daemon=True,
257
+ ).start()
258
+ Thread(
259
+ target=self._run_one,
260
+ args=(target_id, health_url, generation),
261
+ name="modal-health-warmer",
262
+ daemon=True,
263
+ ).start()
264
+
265
+ def statuses(self) -> list[dict[str, Any]]:
266
+ with self._lock:
267
+ return [
268
+ {
269
+ "id": state.target.id,
270
+ "label": state.target.label,
271
+ "model": state.target.model,
272
+ "status": state.status,
273
+ "http_status": state.http_status,
274
+ }
275
+ for state in self._states.values()
276
+ ]
277
+
278
+ def _mark_warmup_if_pending(self, target_id: str, generation: int) -> None:
279
+ time.sleep(self._warmup_after_seconds)
280
+ with self._lock:
281
+ state = self._states.get(target_id)
282
+ if (
283
+ state is not None
284
+ and state.generation == generation
285
+ and state.in_progress
286
+ and state.status == "checking"
287
+ ):
288
+ state.status = "warmup"
289
+
290
+ def _run_one(self, target_id: str, health_url: str, generation: int) -> None:
291
+ while True:
292
+ try:
293
+ response = self._opener(health_url)
294
+ try:
295
+ http_status = int(getattr(response, "status", 0))
296
+ finally:
297
+ response.close()
298
+ except HTTPError as exc:
299
+ http_status = int(exc.code)
300
+ if http_status != HTTPStatus.SERVICE_UNAVAILABLE:
301
+ self._set_status(target_id, generation, "error", http_status)
302
+ print(f"Modal health check failed for {health_url}: {exc}", flush=True)
303
+ return
304
+ except (TimeoutError, URLError, OSError) as exc:
305
+ self._set_status(target_id, generation, "error", None)
306
+ print(f"Modal health check failed for {health_url}: {exc}", flush=True)
307
+ return
308
+
309
+ if http_status == HTTPStatus.OK:
310
+ self._set_status(target_id, generation, "ready", http_status)
311
+ print(f"Modal health check warmed {health_url}: {http_status}", flush=True)
312
+ return
313
+
314
+ if http_status == HTTPStatus.SERVICE_UNAVAILABLE:
315
+ if not self._set_status(
316
+ target_id,
317
+ generation,
318
+ "warmup",
319
+ http_status,
320
+ in_progress=True,
321
+ ):
322
+ return
323
+ print(f"Modal health check warming {health_url}: {http_status}", flush=True)
324
+ time.sleep(self._warmup_retry_seconds)
325
+ continue
326
+
327
+ self._set_status(target_id, generation, "error", http_status)
328
+ print(f"Modal health check failed for {health_url}: {http_status}", flush=True)
329
+ return
330
+
331
+ def _set_status(
332
+ self,
333
+ target_id: str,
334
+ generation: int,
335
+ status: str,
336
+ http_status: int | None,
337
+ *,
338
+ in_progress: bool = False,
339
+ ) -> bool:
340
+ with self._lock:
341
+ state = self._states.get(target_id)
342
+ if state is None or state.generation != generation:
343
+ return False
344
+ state.status = status
345
+ state.http_status = http_status
346
+ state.in_progress = in_progress
347
+ return True
348
+
349
+
350
+ @dataclass(frozen=True, slots=True)
351
+ class ModalHealthTarget:
352
+ id: str
353
+ label: str
354
+ model: str | None
355
+ health_url: str
356
+
357
+
358
+ @dataclass(slots=True)
359
+ class ModalHealthState:
360
+ target: ModalHealthTarget
361
+ status: str = "unknown"
362
+ http_status: int | None = None
363
+ last_started_at: float = 0.0
364
+ generation: int = 0
365
+ in_progress: bool = False
366
+
367
+
368
+ class UrlResponse(Protocol):
369
+ status: int
370
+
371
+ def close(self) -> None: ...
372
+
373
+
374
+ class UrlOpener(Protocol):
375
+ def __call__(self, url: str) -> UrlResponse: ...
376
+
377
+
378
+ class HealthWarmer(Protocol):
379
+ def trigger(self, *, force: bool = False) -> None: ...
380
+
381
+ def statuses(self) -> list[dict[str, Any]]: ...
382
+
383
+
384
+ def modal_health_urls(config: GameConfig) -> list[str]:
385
+ return [target.health_url for target in modal_health_targets(config)]
386
+
387
+
388
+ def modal_health_targets(config: GameConfig) -> list[ModalHealthTarget]:
389
+ targets: list[ModalHealthTarget] = []
390
+ for target_id, label, connector in (
391
+ ("npc_model", "NPC model", config.connector),
392
+ ("god_console", "God Console", config.god_console),
393
+ ):
394
+ if connector is None:
395
+ continue
396
+ health_url = modal_health_url(connector)
397
+ if health_url is not None:
398
+ targets.append(
399
+ ModalHealthTarget(
400
+ id=target_id,
401
+ label=label,
402
+ model=connector.model,
403
+ health_url=health_url,
404
+ )
405
+ )
406
+ return targets
407
+
408
+
409
+ def modal_health_url(connector: ConnectorConfig) -> str | None:
410
+ if connector.type != "openai_compatible":
411
+ return None
412
+
413
+ root = connector.base_url or connector.api_url
414
+ if not root:
415
+ return None
416
+
417
+ root = root.strip().rstrip("/")
418
+ for suffix in ("/v1/chat/completions", "/chat/completions", "/v1"):
419
+ if root.endswith(suffix):
420
+ root = root.removesuffix(suffix)
421
+
422
+ if ".modal.run" not in root:
423
+ return None
424
+ return f"{root}/health"
src/god_simulator/api/server.py CHANGED
@@ -1,43 +1,57 @@
1
  from __future__ import annotations
2
 
3
  import json
4
- import time
5
- from copy import deepcopy
6
- from dataclasses import dataclass
7
  from http import HTTPStatus
8
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
- from threading import Lock, Thread
10
- from typing import Any, Protocol
11
- from urllib.error import HTTPError, URLError
12
  from urllib.parse import parse_qs, urlparse
13
- from urllib.request import urlopen
14
 
15
- from god_simulator.config import ConnectorConfig, GameConfig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  from god_simulator.domain import WorldState
17
- from god_simulator.rendering.claw3d_contract import to_claw3d_snapshot
18
  from god_simulator.simulation.connectors.base import WorldSimulator
19
- from god_simulator.simulation.connectors.factory import create_world_simulator
20
- from god_simulator.simulation.god_console import (
21
- GodConsole,
22
- apply_god_command_plan,
23
- )
24
- from god_simulator.simulation.tick import apply_tick_plan, plan_world_tick
 
 
 
 
 
 
25
 
26
 
27
  def run_server(*, world: WorldState, config: GameConfig) -> None:
28
- simulator = create_world_simulator(config)
29
- god_console_config = config.god_console or config.connector
30
- god_console = (
31
- GodConsole(god_console_config)
32
- if god_console_config.type == "openai_compatible"
33
- else None
34
- )
35
- modal_health_warmer = _ModalHealthWarmer(_modal_health_targets(config))
36
- handler = _build_handler(world, simulator, god_console, modal_health_warmer)
37
  address = (config.server.host, config.server.port)
38
  server = ThreadingHTTPServer(address, handler)
39
  print(f"God Simulator API listening on http://{config.server.host}:{config.server.port}")
40
- print(f"World simulator connector: {simulator.name}")
41
  print(
42
  "Available endpoints: GET /health, GET /state, GET /claw3d/state, "
43
  "POST /tick, POST /god-command"
@@ -51,10 +65,17 @@ def _build_handler(
51
  god_console: GodConsole | None = None,
52
  modal_health_warmer: _HealthWarmer | None = None,
53
  ) -> type[BaseHTTPRequestHandler]:
54
- lock = Lock()
55
- tick_status = _TickStatus()
56
- god_status = _GodCommandStatus()
 
 
 
 
 
 
57
 
 
58
  class GodSimulatorRequestHandler(BaseHTTPRequestHandler):
59
  server_version = "GodSimulator/0.1"
60
 
@@ -68,38 +89,16 @@ def _build_handler(
68
  path = parsed_path.path
69
 
70
  if path == "/health":
71
- with lock:
72
- payload = {
73
- "status": "ok",
74
- "tick": world.tick,
75
- "simulator": simulator.name,
76
- "last_tick_source": world.last_tick_source,
77
- **_tick_status_payload(tick_status),
78
- }
79
- self._send_json(payload)
80
  return
81
-
82
  if path == "/state":
83
- with lock:
84
- payload = world.to_dict()
85
- payload["simulation"] = _tick_status_payload(tick_status)
86
- self._send_json(payload)
87
  return
88
-
89
  if path == "/claw3d/state":
90
- if modal_health_warmer is not None:
91
- query = parse_qs(parsed_path.query)
92
- if _truthy_query_value(query, "warmup"):
93
- modal_health_warmer.trigger(force=True)
94
- with lock:
95
- payload = to_claw3d_snapshot(
96
- world,
97
- tick_in_progress=tick_status.in_progress,
98
- pending_tick=tick_status.pending_tick,
99
- )
100
- if modal_health_warmer is not None:
101
- payload["simulation"]["models"] = modal_health_warmer.statuses()
102
- self._send_json(payload)
103
  return
104
 
105
  self._send_json(
@@ -109,65 +108,22 @@ def _build_handler(
109
 
110
  def do_POST(self) -> None:
111
  if self.path == "/tick":
112
- payload: dict[str, Any] | None
113
- planning_world: WorldState | None = None
114
- next_tick: int | None = None
115
-
116
- with lock:
117
- if tick_status.in_progress:
118
- payload = {
119
- "error": "tick_in_progress",
120
- "tick": world.tick,
121
- **_tick_status_payload(tick_status),
122
- }
123
- elif god_status.in_progress:
124
- payload = {
125
- "error": "god_command_in_progress",
126
- "message": "A god command is already modifying the world.",
127
- "tick": world.tick,
128
- **_tick_status_payload(tick_status),
129
- }
130
- else:
131
- payload = None
132
-
133
- next_tick = world.tick + 1
134
- planning_world = deepcopy(world)
135
- tick_status.in_progress = True
136
- tick_status.pending_tick = next_tick
137
-
138
- if payload is not None:
139
- self._send_json(payload, status=HTTPStatus.CONFLICT)
140
- return
141
 
142
- assert planning_world is not None
143
- assert next_tick is not None
144
  try:
145
- planned_tick, plan = plan_world_tick(planning_world, simulator, next_tick)
146
- except Exception as exc:
147
- with lock:
148
- tick_status.in_progress = False
149
- tick_status.pending_tick = None
150
  self._send_json(
151
- {
152
- "error": "tick_failed",
153
- "message": str(exc),
154
- },
155
- status=HTTPStatus.INTERNAL_SERVER_ERROR,
156
  )
157
  return
158
 
159
- with lock:
160
- apply_tick_plan(world, planned_tick, plan)
161
- tick_status.in_progress = False
162
- tick_status.pending_tick = None
163
- payload = world.to_dict()
164
- payload["simulation"] = _tick_status_payload(tick_status)
165
-
166
- self._send_json(payload)
167
- return
168
-
169
- if self.path == "/god-command":
170
- self._handle_god_command()
171
  return
172
 
173
  self._send_json(
@@ -195,78 +151,6 @@ def _build_handler(
195
  except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
196
  return
197
 
198
- def _handle_god_command(self) -> None:
199
- if god_console is None:
200
- self._send_json(
201
- {"error": "god_console_unavailable"},
202
- status=HTTPStatus.BAD_REQUEST,
203
- )
204
- return
205
-
206
- try:
207
- command = self._read_command()
208
- except ValueError as exc:
209
- self._send_json(
210
- {"error": "invalid_request", "message": str(exc)},
211
- status=HTTPStatus.BAD_REQUEST,
212
- )
213
- return
214
-
215
- with lock:
216
- if tick_status.in_progress:
217
- payload = {
218
- "error": "tick_in_progress",
219
- "message": "God commands are disabled while a tick is running.",
220
- **_tick_status_payload(tick_status),
221
- }
222
- planning_world = None
223
- elif god_status.in_progress:
224
- payload = {
225
- "error": "god_command_in_progress",
226
- "message": "Another god command is already running.",
227
- **_tick_status_payload(tick_status),
228
- }
229
- planning_world = None
230
- else:
231
- payload = None
232
- planning_world = deepcopy(world)
233
- god_status.in_progress = True
234
-
235
- if payload is not None:
236
- self._send_json(payload, status=HTTPStatus.CONFLICT)
237
- return
238
-
239
- assert planning_world is not None
240
- try:
241
- plan = god_console.propose(planning_world, command)
242
- except Exception as exc:
243
- with lock:
244
- god_status.in_progress = False
245
- self._send_json(
246
- {
247
- "error": "god_command_failed",
248
- "message": str(exc),
249
- },
250
- status=HTTPStatus.INTERNAL_SERVER_ERROR,
251
- )
252
- return
253
-
254
- with lock:
255
- applied = apply_god_command_plan(world, plan)
256
- god_status.in_progress = False
257
- response = {
258
- "source": plan.source,
259
- "summary": plan.summary,
260
- "applied": applied,
261
- "snapshot": to_claw3d_snapshot(
262
- world,
263
- tick_in_progress=tick_status.in_progress,
264
- pending_tick=tick_status.pending_tick,
265
- ),
266
- }
267
-
268
- self._send_json(response)
269
-
270
  def _send_cors_headers(self) -> None:
271
  self.send_header("Access-Control-Allow-Origin", "*")
272
  self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
@@ -298,253 +182,5 @@ def _build_handler(
298
  return GodSimulatorRequestHandler
299
 
300
 
301
- @dataclass(slots=True)
302
- class _TickStatus:
303
- in_progress: bool = False
304
- pending_tick: int | None = None
305
-
306
-
307
- @dataclass(slots=True)
308
- class _GodCommandStatus:
309
- in_progress: bool = False
310
-
311
-
312
- def _tick_status_payload(tick_status: _TickStatus) -> dict[str, Any]:
313
- return {
314
- "tick_in_progress": tick_status.in_progress,
315
- "pending_tick": tick_status.pending_tick,
316
- }
317
-
318
-
319
- class _ModalHealthWarmer:
320
- def __init__(
321
- self,
322
- targets: list[_ModalHealthTarget],
323
- *,
324
- cooldown_seconds: float = 300,
325
- warmup_after_seconds: float = 2,
326
- warmup_retry_seconds: float = 5,
327
- opener: _UrlOpener = urlopen,
328
- ) -> None:
329
- self._states = {
330
- target.id: _ModalHealthState(target=target)
331
- for target in targets
332
- }
333
- self._cooldown_seconds = cooldown_seconds
334
- self._warmup_after_seconds = warmup_after_seconds
335
- self._warmup_retry_seconds = warmup_retry_seconds
336
- self._opener = opener
337
- self._lock = Lock()
338
-
339
- def trigger(self, *, force: bool = False) -> None:
340
- if not self._states:
341
- return
342
-
343
- now = time.monotonic()
344
- launches: list[tuple[str, str, int]] = []
345
-
346
- with self._lock:
347
- for target_id, state in self._states.items():
348
- if state.in_progress:
349
- continue
350
- if (
351
- not force
352
- and (
353
- now - state.last_started_at < self._cooldown_seconds
354
- )
355
- ):
356
- continue
357
- state.in_progress = True
358
- state.status = "checking"
359
- state.http_status = None
360
- state.last_started_at = now
361
- state.generation += 1
362
- launches.append((target_id, state.target.health_url, state.generation))
363
-
364
- for target_id, health_url, generation in launches:
365
- Thread(
366
- target=self._mark_warmup_if_pending,
367
- args=(target_id, generation),
368
- name="modal-health-warmup-status",
369
- daemon=True,
370
- ).start()
371
- Thread(
372
- target=self._run_one,
373
- args=(target_id, health_url, generation),
374
- name="modal-health-warmer",
375
- daemon=True,
376
- ).start()
377
-
378
- def statuses(self) -> list[dict[str, Any]]:
379
- with self._lock:
380
- return [
381
- {
382
- "id": state.target.id,
383
- "label": state.target.label,
384
- "model": state.target.model,
385
- "status": state.status,
386
- "http_status": state.http_status,
387
- }
388
- for state in self._states.values()
389
- ]
390
-
391
- def _mark_warmup_if_pending(self, target_id: str, generation: int) -> None:
392
- time.sleep(self._warmup_after_seconds)
393
- with self._lock:
394
- state = self._states.get(target_id)
395
- if (
396
- state is not None
397
- and state.generation == generation
398
- and state.in_progress
399
- and state.status == "checking"
400
- ):
401
- state.status = "warmup"
402
-
403
- def _run_one(self, target_id: str, health_url: str, generation: int) -> None:
404
- while True:
405
- try:
406
- response = self._opener(health_url)
407
- try:
408
- http_status = int(getattr(response, "status", 0))
409
- finally:
410
- response.close()
411
- except HTTPError as exc:
412
- http_status = int(exc.code)
413
- if http_status != HTTPStatus.SERVICE_UNAVAILABLE:
414
- self._set_status(target_id, generation, "error", http_status)
415
- print(
416
- f"Modal health check failed for {health_url}: {exc}",
417
- flush=True,
418
- )
419
- return
420
- except (TimeoutError, URLError, OSError) as exc:
421
- self._set_status(target_id, generation, "error", None)
422
- print(f"Modal health check failed for {health_url}: {exc}", flush=True)
423
- return
424
-
425
- if http_status == HTTPStatus.OK:
426
- self._set_status(target_id, generation, "ready", http_status)
427
- print(
428
- f"Modal health check warmed {health_url}: {http_status}",
429
- flush=True,
430
- )
431
- return
432
-
433
- if http_status == HTTPStatus.SERVICE_UNAVAILABLE:
434
- if not self._set_status(
435
- target_id,
436
- generation,
437
- "warmup",
438
- http_status,
439
- in_progress=True,
440
- ):
441
- return
442
- print(
443
- f"Modal health check warming {health_url}: {http_status}",
444
- flush=True,
445
- )
446
- time.sleep(self._warmup_retry_seconds)
447
- continue
448
-
449
- self._set_status(target_id, generation, "error", http_status)
450
- print(f"Modal health check failed for {health_url}: {http_status}", flush=True)
451
- return
452
-
453
- def _set_status(
454
- self,
455
- target_id: str,
456
- generation: int,
457
- status: str,
458
- http_status: int | None,
459
- *,
460
- in_progress: bool = False,
461
- ) -> bool:
462
- with self._lock:
463
- state = self._states.get(target_id)
464
- if state is None or state.generation != generation:
465
- return False
466
- state.status = status
467
- state.http_status = http_status
468
- state.in_progress = in_progress
469
- return True
470
-
471
-
472
- @dataclass(frozen=True, slots=True)
473
- class _ModalHealthTarget:
474
- id: str
475
- label: str
476
- model: str | None
477
- health_url: str
478
-
479
-
480
- @dataclass(slots=True)
481
- class _ModalHealthState:
482
- target: _ModalHealthTarget
483
- status: str = "unknown"
484
- http_status: int | None = None
485
- last_started_at: float = 0.0
486
- generation: int = 0
487
- in_progress: bool = False
488
-
489
-
490
- class _UrlResponse(Protocol):
491
- status: int
492
-
493
- def close(self) -> None: ...
494
-
495
-
496
- class _UrlOpener(Protocol):
497
- def __call__(self, url: str) -> _UrlResponse: ...
498
-
499
-
500
- class _HealthWarmer(Protocol):
501
- def trigger(self, *, force: bool = False) -> None: ...
502
-
503
- def statuses(self) -> list[dict[str, Any]]: ...
504
-
505
-
506
- def _modal_health_urls(config: GameConfig) -> list[str]:
507
- return [target.health_url for target in _modal_health_targets(config)]
508
-
509
-
510
- def _modal_health_targets(config: GameConfig) -> list[_ModalHealthTarget]:
511
- targets: list[_ModalHealthTarget] = []
512
- for target_id, label, connector in (
513
- ("npc_model", "NPC model", config.connector),
514
- ("god_console", "God Console", config.god_console),
515
- ):
516
- if connector is None:
517
- continue
518
- health_url = _modal_health_url(connector)
519
- if health_url is not None:
520
- targets.append(
521
- _ModalHealthTarget(
522
- id=target_id,
523
- label=label,
524
- model=connector.model,
525
- health_url=health_url,
526
- )
527
- )
528
- return targets
529
-
530
-
531
- def _modal_health_url(connector: ConnectorConfig) -> str | None:
532
- if connector.type != "openai_compatible":
533
- return None
534
-
535
- root = connector.base_url or connector.api_url
536
- if not root:
537
- return None
538
-
539
- root = root.strip().rstrip("/")
540
- for suffix in ("/v1/chat/completions", "/chat/completions", "/v1"):
541
- if root.endswith(suffix):
542
- root = root.removesuffix(suffix)
543
-
544
- if ".modal.run" not in root:
545
- return None
546
- return f"{root}/health"
547
-
548
-
549
  def _truthy_query_value(query: dict[str, list[str]], key: str) -> bool:
550
  return any(value.lower() in ("1", "true", "yes") for value in query.get(key, []))
 
1
  from __future__ import annotations
2
 
3
  import json
 
 
 
4
  from http import HTTPStatus
5
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from typing import Any
 
 
7
  from urllib.parse import parse_qs, urlparse
 
8
 
9
+ from god_simulator.api.runtime import (
10
+ GameRuntime,
11
+ create_game_runtime,
12
+ )
13
+ from god_simulator.api.runtime import (
14
+ HealthWarmer as _HealthWarmer,
15
+ )
16
+ from god_simulator.api.runtime import (
17
+ ModalHealthTarget as _ModalHealthTarget,
18
+ )
19
+ from god_simulator.api.runtime import (
20
+ ModalHealthWarmer as _ModalHealthWarmer,
21
+ )
22
+ from god_simulator.api.runtime import (
23
+ modal_health_targets as _modal_health_targets,
24
+ )
25
+ from god_simulator.api.runtime import (
26
+ modal_health_url as _modal_health_url,
27
+ )
28
+ from god_simulator.api.runtime import (
29
+ modal_health_urls as _modal_health_urls,
30
+ )
31
+ from god_simulator.config import GameConfig
32
  from god_simulator.domain import WorldState
 
33
  from god_simulator.simulation.connectors.base import WorldSimulator
34
+ from god_simulator.simulation.god_console import GodConsole
35
+
36
+ __all__ = [
37
+ "_ModalHealthTarget",
38
+ "_ModalHealthWarmer",
39
+ "_build_handler",
40
+ "_modal_health_targets",
41
+ "_modal_health_url",
42
+ "_modal_health_urls",
43
+ "build_handler",
44
+ "run_server",
45
+ ]
46
 
47
 
48
  def run_server(*, world: WorldState, config: GameConfig) -> None:
49
+ runtime = create_game_runtime(world=world, config=config)
50
+ handler = build_handler(runtime)
 
 
 
 
 
 
 
51
  address = (config.server.host, config.server.port)
52
  server = ThreadingHTTPServer(address, handler)
53
  print(f"God Simulator API listening on http://{config.server.host}:{config.server.port}")
54
+ print(f"World simulator connector: {runtime.simulator_name}")
55
  print(
56
  "Available endpoints: GET /health, GET /state, GET /claw3d/state, "
57
  "POST /tick, POST /god-command"
 
65
  god_console: GodConsole | None = None,
66
  modal_health_warmer: _HealthWarmer | None = None,
67
  ) -> type[BaseHTTPRequestHandler]:
68
+ return build_handler(
69
+ GameRuntime(
70
+ world=world,
71
+ simulator=simulator,
72
+ god_console=god_console,
73
+ modal_health_warmer=modal_health_warmer,
74
+ )
75
+ )
76
+
77
 
78
+ def build_handler(runtime: GameRuntime) -> type[BaseHTTPRequestHandler]:
79
  class GodSimulatorRequestHandler(BaseHTTPRequestHandler):
80
  server_version = "GodSimulator/0.1"
81
 
 
89
  path = parsed_path.path
90
 
91
  if path == "/health":
92
+ self._send_json(runtime.health())
 
 
 
 
 
 
 
 
93
  return
 
94
  if path == "/state":
95
+ self._send_json(runtime.state())
 
 
 
96
  return
 
97
  if path == "/claw3d/state":
98
+ query = parse_qs(parsed_path.query)
99
+ self._send_json(
100
+ runtime.claw3d_state(warmup=_truthy_query_value(query, "warmup"))
101
+ )
 
 
 
 
 
 
 
 
 
102
  return
103
 
104
  self._send_json(
 
108
 
109
  def do_POST(self) -> None:
110
  if self.path == "/tick":
111
+ status, payload = runtime.tick()
112
+ self._send_json(payload, status=status)
113
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ if self.path == "/god-command":
 
116
  try:
117
+ command = self._read_command()
118
+ except ValueError as exc:
 
 
 
119
  self._send_json(
120
+ {"error": "invalid_request", "message": str(exc)},
121
+ status=HTTPStatus.BAD_REQUEST,
 
 
 
122
  )
123
  return
124
 
125
+ status, payload = runtime.god_command(command)
126
+ self._send_json(payload, status=status)
 
 
 
 
 
 
 
 
 
 
127
  return
128
 
129
  self._send_json(
 
151
  except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
152
  return
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def _send_cors_headers(self) -> None:
155
  self.send_header("Access-Control-Allow-Origin", "*")
156
  self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
 
182
  return GodSimulatorRequestHandler
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  def _truthy_query_value(query: dict[str, list[str]], key: str) -> bool:
186
  return any(value.lower() in ("1", "true", "yes") for value in query.get(key, []))
tests/test_gradio_app.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from fastapi.testclient import TestClient
7
+
8
+ from god_simulator.api.gradio_app import create_gradio_app
9
+
10
+
11
+ def test_gradio_app_exposes_game_api(tmp_path: Path) -> None:
12
+ config_path = _write_config(tmp_path)
13
+ app = create_gradio_app(config_path=config_path, static_dir=tmp_path / "dist")
14
+ client = TestClient(app)
15
+
16
+ health = client.get("/health")
17
+ assert health.status_code == 200
18
+ assert health.json()["status"] == "ok"
19
+
20
+ snapshot = client.get("/claw3d/state?warmup=1")
21
+ assert snapshot.status_code == 200
22
+ assert snapshot.json()["terrain"]["kind"] == "plain_green"
23
+
24
+ tick = client.post("/tick")
25
+ assert tick.status_code == 200
26
+ assert tick.json()["tick"] == 1
27
+
28
+
29
+ def test_gradio_app_serves_missing_frontend_page(tmp_path: Path) -> None:
30
+ config_path = _write_config(tmp_path)
31
+ app = create_gradio_app(config_path=config_path, static_dir=tmp_path / "missing-dist")
32
+ client = TestClient(app)
33
+
34
+ response = client.get("/")
35
+
36
+ assert response.status_code == 503
37
+ assert "frontend is not built" in response.text
38
+
39
+
40
+ def _write_config(tmp_path: Path) -> Path:
41
+ config_path = tmp_path / "game.json"
42
+ config_path.write_text(
43
+ json.dumps(
44
+ {
45
+ "world": {
46
+ "width": 80,
47
+ "depth": 80,
48
+ "terrain": "plain_green",
49
+ "seed": 42,
50
+ },
51
+ "npcs": {"count": 2},
52
+ "simulation": {"tick_ms": 500},
53
+ "server": {"host": "127.0.0.1", "port": 8000},
54
+ }
55
+ ),
56
+ encoding="utf-8",
57
+ )
58
+ return config_path
uv.lock CHANGED
The diff for this file is too large to render. See raw diff