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 |
-
|
| 122 |
)
|
| 123 |
mcp_url_box = gr.Textbox(
|
| 124 |
label="Your MCP server URL",
|
| 125 |
interactive=False,
|
| 126 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 41 |
-
|
| 42 |
-
assert
|
| 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)
|