DeltaZN commited on
Commit
217ea4c
·
1 Parent(s): 8043870

feat: add reset world admin btn

Browse files
src/world_simulator/api/gradio_app.py CHANGED
@@ -43,7 +43,7 @@ class AdminModelRequest(BaseModel):
43
 
44
  def _load_or_create_world(
45
  config: GameConfig,
46
- ) -> tuple[WorldState, PlayerManager, BackgroundSnapshotWriter | None]:
47
  """Resume the world from the snapshot bucket if configured, else create fresh.
48
 
49
  Set ``WORLD_STATE_DIR`` to a (writable, ideally bucket-mounted) directory to
@@ -53,7 +53,7 @@ def _load_or_create_world(
53
  """
54
  state_dir = os.getenv("WORLD_STATE_DIR", "").strip()
55
  if not state_dir:
56
- return create_world(config), PlayerManager(), None
57
 
58
  store = SnapshotStore(Path(state_dir))
59
  players = PlayerManager()
@@ -72,7 +72,7 @@ def _load_or_create_world(
72
 
73
  writer = BackgroundSnapshotWriter(store)
74
  atexit.register(writer.close)
75
- return world, players, writer
76
 
77
 
78
  def create_gradio_app(
@@ -81,12 +81,13 @@ def create_gradio_app(
81
  static_dir: Path = Path("dist/frontend"),
82
  ) -> gr.Server:
83
  config = apply_runtime_env_overrides(load_game_config(config_path))
84
- world, players, snapshot_writer = _load_or_create_world(config)
85
  runtime = create_game_runtime(
86
  world=world,
87
  config=config,
88
  players=players,
89
  snapshot_writer=snapshot_writer,
 
90
  )
91
  app = gr.Server(title="World Simulator")
92
 
 
43
 
44
  def _load_or_create_world(
45
  config: GameConfig,
46
+ ) -> tuple[WorldState, PlayerManager, BackgroundSnapshotWriter | None, SnapshotStore | None]:
47
  """Resume the world from the snapshot bucket if configured, else create fresh.
48
 
49
  Set ``WORLD_STATE_DIR`` to a (writable, ideally bucket-mounted) directory to
 
53
  """
54
  state_dir = os.getenv("WORLD_STATE_DIR", "").strip()
55
  if not state_dir:
56
+ return create_world(config), PlayerManager(), None, None
57
 
58
  store = SnapshotStore(Path(state_dir))
59
  players = PlayerManager()
 
72
 
73
  writer = BackgroundSnapshotWriter(store)
74
  atexit.register(writer.close)
75
+ return world, players, writer, store
76
 
77
 
78
  def create_gradio_app(
 
81
  static_dir: Path = Path("dist/frontend"),
82
  ) -> gr.Server:
83
  config = apply_runtime_env_overrides(load_game_config(config_path))
84
+ world, players, snapshot_writer, snapshot_store = _load_or_create_world(config)
85
  runtime = create_game_runtime(
86
  world=world,
87
  config=config,
88
  players=players,
89
  snapshot_writer=snapshot_writer,
90
+ snapshot_store=snapshot_store,
91
  )
92
  app = gr.Server(title="World Simulator")
93
 
src/world_simulator/api/persistence.py CHANGED
@@ -121,6 +121,22 @@ class SnapshotStore:
121
  best = (tick, data)
122
  return best[1] if best else None
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  def save(self, snapshot: dict[str, Any]) -> None:
125
  target = 0 if self._slot_ticks[0] <= self._slot_ticks[1] else 1
126
  path = self._dir / _SLOT_NAMES[target]
@@ -168,6 +184,12 @@ class BackgroundSnapshotWriter:
168
  self._pending = snapshot
169
  self._cond.notify()
170
 
 
 
 
 
 
 
171
  def _run(self) -> None:
172
  while True:
173
  with self._cond:
 
121
  best = (tick, data)
122
  return best[1] if best else None
123
 
124
+ def clear(self) -> None:
125
+ """Delete both slot files and forget their ticks.
126
+
127
+ Missing files are ignored so a clear on a never-persisted world is a
128
+ no-op. After this, :meth:`load` returns ``None`` and the next
129
+ :meth:`save` starts the ping-pong from slot 0 again.
130
+ """
131
+ for name in _SLOT_NAMES:
132
+ try:
133
+ (self._dir / name).unlink()
134
+ except FileNotFoundError:
135
+ pass
136
+ except OSError as exc:
137
+ print(f"world snapshot clear failed for {name}: {exc}", flush=True)
138
+ self._slot_ticks = [-1, -1]
139
+
140
  def save(self, snapshot: dict[str, Any]) -> None:
141
  target = 0 if self._slot_ticks[0] <= self._slot_ticks[1] else 1
142
  path = self._dir / _SLOT_NAMES[target]
 
184
  self._pending = snapshot
185
  self._cond.notify()
186
 
187
+ def drop_pending(self) -> None:
188
+ """Discard any not-yet-written snapshot so a reset can't be undone by a
189
+ stale snapshot submitted just before it."""
190
+ with self._cond:
191
+ self._pending = None
192
+
193
  def _run(self) -> None:
194
  while True:
195
  with self._cond:
src/world_simulator/api/public_ui.py CHANGED
@@ -118,12 +118,12 @@ def build_public_ui(runtime: GameRuntime, *, world_path: str = "/world") -> gr.B
118
  token_box = gr.Textbox(
119
  label="Your MCP token",
120
  interactive=False,
121
- show_copy_button=True,
122
  )
123
  mcp_url_box = gr.Textbox(
124
  label="Your MCP server URL",
125
  interactive=False,
126
- show_copy_button=True,
127
  )
128
  config_box = gr.Code(
129
  label="MCP client config (Streamable HTTP)",
@@ -159,10 +159,10 @@ def build_public_ui(runtime: GameRuntime, *, world_path: str = "/world") -> gr.B
159
  """
160
  <div class="gs-card-head sky">
161
  <div class="gs-tile sky">📄</div>
162
- <h2>Admin Logs</h2>
163
  </div>
164
  <div class="gs-card-body">
165
- <p class="gs-lede">Read model, validator and engine records from ledger.jsonl. Requires ADMIN_TOKEN.</p>
166
  </div>
167
  """
168
  )
@@ -189,6 +189,11 @@ def build_public_ui(runtime: GameRuntime, *, world_path: str = "/world") -> gr.B
189
  admin_limit = gr.Number(label="limit", value=100, precision=0)
190
  admin_btn = gr.Button("Load ledger records")
191
  admin_out = gr.JSON(label="Ledger records")
 
 
 
 
 
192
 
193
  gr.HTML(_GROUND_FOOTER_HTML)
194
 
@@ -233,6 +238,13 @@ def build_public_ui(runtime: GameRuntime, *, world_path: str = "/world") -> gr.B
233
  api_name="admin_logs",
234
  )
235
 
 
 
 
 
 
 
 
236
  return blocks
237
 
238
 
@@ -315,6 +327,18 @@ def _make_admin_logs_handler(runtime: GameRuntime) -> Any:
315
  return load
316
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  def _format_status(payload: dict[str, Any]) -> str:
319
  icon = payload.get("icon", "")
320
  name = payload.get("name", "?")
 
118
  token_box = gr.Textbox(
119
  label="Your MCP token",
120
  interactive=False,
121
+ buttons=["copy"],
122
  )
123
  mcp_url_box = gr.Textbox(
124
  label="Your MCP server URL",
125
  interactive=False,
126
+ buttons=["copy"],
127
  )
128
  config_box = gr.Code(
129
  label="MCP client config (Streamable HTTP)",
 
159
  """
160
  <div class="gs-card-head sky">
161
  <div class="gs-tile sky">📄</div>
162
+ <h2>Admin panel</h2>
163
  </div>
164
  <div class="gs-card-body">
165
+ <p class="gs-lede">Read model, validator and engine records from ledger.jsonl, and reset the world. Requires ADMIN_TOKEN.</p>
166
  </div>
167
  """
168
  )
 
189
  admin_limit = gr.Number(label="limit", value=100, precision=0)
190
  admin_btn = gr.Button("Load ledger records")
191
  admin_out = gr.JSON(label="Ledger records")
192
+ with gr.Row(elem_classes="status-row"):
193
+ admin_reset_btn = gr.Button(
194
+ "⚠️ Clear state & restart world", variant="stop"
195
+ )
196
+ admin_reset_out = gr.Markdown()
197
 
198
  gr.HTML(_GROUND_FOOTER_HTML)
199
 
 
238
  api_name="admin_logs",
239
  )
240
 
241
+ admin_reset_btn.click(
242
+ _make_admin_reset_handler(runtime),
243
+ inputs=[admin_token],
244
+ outputs=[admin_reset_out],
245
+ api_name="admin_reset",
246
+ )
247
+
248
  return blocks
249
 
250
 
 
327
  return load
328
 
329
 
330
+ def _make_admin_reset_handler(runtime: GameRuntime) -> Any:
331
+ def reset(token: str) -> str:
332
+ if token != os.getenv("ADMIN_TOKEN", "dev-admin-token"):
333
+ return "❌ admin_token_required"
334
+ status, payload = runtime.reset_world()
335
+ if status != HTTPStatus.OK:
336
+ return f"❌ {payload.get('message', 'Could not reset the world.')}"
337
+ return f"✅ World cleared and restarted (tick {payload.get('tick', 0)})."
338
+
339
+ return reset
340
+
341
+
342
  def _format_status(payload: dict[str, Any]) -> str:
343
  icon = payload.get("icon", "")
344
  name = payload.get("name", "?")
src/world_simulator/api/runtime.py CHANGED
@@ -13,7 +13,11 @@ from urllib.error import HTTPError, URLError
13
  from urllib.request import Request, urlopen
14
 
15
  from world_simulator.api.modal_auth import modal_proxy_auth_headers
16
- from world_simulator.api.persistence import BackgroundSnapshotWriter, build_snapshot
 
 
 
 
17
  from world_simulator.api.players import (
18
  PlayerCharacter,
19
  PlayerError,
@@ -26,6 +30,7 @@ from world_simulator.rendering.scene_contract import to_scene_snapshot
26
  from world_simulator.simulation.connectors.base import TickPlan, WorldSimulator
27
  from world_simulator.simulation.connectors.factory import create_world_simulator
28
  from world_simulator.simulation.directives import expire_directives
 
29
  from world_simulator.simulation.tick import apply_tick_plan, plan_world_tick
30
 
31
 
@@ -40,6 +45,7 @@ class GameRuntime:
40
  ledger: RunLedger | None = None,
41
  config: GameConfig | None = None,
42
  snapshot_writer: BackgroundSnapshotWriter | None = None,
 
43
  ) -> None:
44
  self._world = world
45
  self._simulator = simulator
@@ -48,6 +54,7 @@ class GameRuntime:
48
  self._ledger = ledger or RunLedger()
49
  self._config = config
50
  self._snapshot_writer = snapshot_writer
 
51
  self._lock = Lock()
52
  self._tick_status = TickStatus()
53
  self._pending_commands: list[PendingCommand] = []
@@ -155,6 +162,29 @@ class GameRuntime:
155
  return
156
  self._snapshot_writer.submit(build_snapshot(self._world, self._players.snapshot()))
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  # -- player characters (external agents over MCP) ---------------------- #
159
  def create_player(
160
  self,
@@ -423,6 +453,7 @@ def create_game_runtime(
423
  config: GameConfig,
424
  players: PlayerManager | None = None,
425
  snapshot_writer: BackgroundSnapshotWriter | None = None,
 
426
  ) -> GameRuntime:
427
  simulator = create_world_simulator(config)
428
  return GameRuntime(
@@ -432,6 +463,7 @@ def create_game_runtime(
432
  players=players,
433
  config=config,
434
  snapshot_writer=snapshot_writer,
 
435
  )
436
 
437
 
 
13
  from urllib.request import Request, urlopen
14
 
15
  from world_simulator.api.modal_auth import modal_proxy_auth_headers
16
+ from world_simulator.api.persistence import (
17
+ BackgroundSnapshotWriter,
18
+ SnapshotStore,
19
+ build_snapshot,
20
+ )
21
  from world_simulator.api.players import (
22
  PlayerCharacter,
23
  PlayerError,
 
30
  from world_simulator.simulation.connectors.base import TickPlan, WorldSimulator
31
  from world_simulator.simulation.connectors.factory import create_world_simulator
32
  from world_simulator.simulation.directives import expire_directives
33
+ from world_simulator.simulation.spawning import create_world
34
  from world_simulator.simulation.tick import apply_tick_plan, plan_world_tick
35
 
36
 
 
45
  ledger: RunLedger | None = None,
46
  config: GameConfig | None = None,
47
  snapshot_writer: BackgroundSnapshotWriter | None = None,
48
+ snapshot_store: SnapshotStore | None = None,
49
  ) -> None:
50
  self._world = world
51
  self._simulator = simulator
 
54
  self._ledger = ledger or RunLedger()
55
  self._config = config
56
  self._snapshot_writer = snapshot_writer
57
+ self._snapshot_store = snapshot_store
58
  self._lock = Lock()
59
  self._tick_status = TickStatus()
60
  self._pending_commands: list[PendingCommand] = []
 
162
  return
163
  self._snapshot_writer.submit(build_snapshot(self._world, self._players.snapshot()))
164
 
165
+ def reset_world(self) -> tuple[HTTPStatus, dict[str, Any]]:
166
+ """Wipe the live world: fresh world, no players, deleted snapshot files.
167
+
168
+ Used by the Admin panel. Drops any in-flight snapshot first so the
169
+ background writer cannot resurrect the deleted state, then clears the
170
+ store on disk.
171
+ """
172
+ if self._config is None:
173
+ return HTTPStatus.INTERNAL_SERVER_ERROR, {
174
+ "error": "reset_unavailable",
175
+ "message": "Runtime has no config to rebuild the world from.",
176
+ }
177
+ with self._lock:
178
+ if self._snapshot_writer is not None:
179
+ self._snapshot_writer.drop_pending()
180
+ self._world = create_world(self._config)
181
+ self._players = PlayerManager()
182
+ self._tick_status = TickStatus()
183
+ self._pending_commands = []
184
+ if self._snapshot_store is not None:
185
+ self._snapshot_store.clear()
186
+ return HTTPStatus.OK, {"ok": True, "tick": self._world.tick}
187
+
188
  # -- player characters (external agents over MCP) ---------------------- #
189
  def create_player(
190
  self,
 
453
  config: GameConfig,
454
  players: PlayerManager | None = None,
455
  snapshot_writer: BackgroundSnapshotWriter | None = None,
456
+ snapshot_store: SnapshotStore | None = None,
457
  ) -> GameRuntime:
458
  simulator = create_world_simulator(config)
459
  return GameRuntime(
 
463
  players=players,
464
  config=config,
465
  snapshot_writer=snapshot_writer,
466
+ snapshot_store=snapshot_store,
467
  )
468
 
469
 
tests/test_gradio_app.py CHANGED
@@ -37,10 +37,9 @@ def test_gradio_app_serves_missing_frontend_page(tmp_path: Path) -> None:
37
  assert response.status_code == 503
38
  assert "frontend is not built" in response.text
39
 
40
- # The landing page redirects to the public lobby.
41
- redirect = client.get("/", follow_redirects=False)
42
- assert redirect.status_code in (302, 307)
43
- assert redirect.headers["location"] == "/connect"
44
 
45
 
46
  def test_gradio_app_force_deterministic_disables_modal_runtime(
 
37
  assert response.status_code == 503
38
  assert "frontend is not built" in response.text
39
 
40
+ # The public lobby is served directly at the root path.
41
+ lobby = client.get("/")
42
+ assert lobby.status_code == 200
 
43
 
44
 
45
  def test_gradio_app_force_deterministic_disables_modal_runtime(
tests/test_persistence.py CHANGED
@@ -104,6 +104,24 @@ def test_snapshot_store_falls_back_when_newest_slot_is_corrupt(tmp_path) -> None
104
  assert loaded["tick"] == 0 # falls back to the older, intact slot
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def test_background_writer_persists_latest(tmp_path) -> None:
108
  store = SnapshotStore(tmp_path)
109
  writer = BackgroundSnapshotWriter(store)
 
104
  assert loaded["tick"] == 0 # falls back to the older, intact slot
105
 
106
 
107
+ def test_snapshot_store_clear_deletes_slots(tmp_path) -> None:
108
+ store = SnapshotStore(tmp_path)
109
+ world = create_world(_config(npc_count=3))
110
+ store.save(build_snapshot(world, PlayerManager().snapshot()))
111
+ world.tick = 4
112
+ store.save(build_snapshot(world, PlayerManager().snapshot()))
113
+
114
+ store.clear()
115
+
116
+ assert not list(tmp_path.glob("world_state.*.json"))
117
+ assert store.load() is None
118
+ # Clearing again (nothing on disk) is a no-op, not an error.
119
+ store.clear()
120
+ # The next save starts the ping-pong from slot 0 again.
121
+ store.save(build_snapshot(world, PlayerManager().snapshot()))
122
+ assert (tmp_path / "world_state.0.json").exists()
123
+
124
+
125
  def test_background_writer_persists_latest(tmp_path) -> None:
126
  store = SnapshotStore(tmp_path)
127
  writer = BackgroundSnapshotWriter(store)