Spaces:
Sleeping
Sleeping
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # All rights reserved. | |
| # | |
| # This source code is licensed under the BSD-style license found in the | |
| # LICENSE file in the root directory of this source tree. | |
| """ | |
| FastAPI application for the Toxic Royale Env Environment. | |
| This module creates an HTTP server that exposes the ToxicRoyaleEnvironment | |
| over HTTP and WebSocket endpoints, compatible with EnvClient. | |
| Endpoints: | |
| - POST /reset: Reset the environment | |
| - POST /step: Execute an action | |
| - GET /state: Get current environment state | |
| - GET /schema: Get action/observation schemas | |
| - WS /ws: WebSocket endpoint for persistent sessions | |
| Usage: | |
| # Development (with auto-reload): | |
| uvicorn toxic_royale_env.server.app:app --reload --host 0.0.0.0 --port 8000 | |
| # Production: | |
| uvicorn toxic_royale_env.server.app:app --host 0.0.0.0 --port 8000 --workers 4 | |
| # Or run directly: | |
| python -m toxic_royale_env.server.app | |
| """ | |
| try: | |
| from openenv.core.env_server.http_server import create_app | |
| except Exception as e: # pragma: no cover | |
| raise ImportError( | |
| "openenv is required for the web interface. Install dependencies with '\n uv sync\n'" | |
| ) from e | |
| import json | |
| import time | |
| from collections import deque | |
| from threading import Lock | |
| from fastapi.responses import HTMLResponse, StreamingResponse | |
| from toxic_royale_env.models import ToxicRoyaleAction, ToxicRoyaleObservation | |
| from toxic_royale_env.policy_models import FramePacket, PolicyActResponse, PolicyAction | |
| from toxic_royale_env.policy_utils import append_jsonl, now_ms, rule_policy, should_gate | |
| from toxic_royale_env.server.toxic_royale_env_environment import ToxicRoyaleEnvironment | |
| # Create the app with web interface and README integration | |
| app = create_app( | |
| ToxicRoyaleEnvironment, | |
| ToxicRoyaleAction, | |
| ToxicRoyaleObservation, | |
| env_name="toxic_royale_env", | |
| max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions | |
| ) | |
| # --- Live viewer memory (in-RAM ring buffer) --- | |
| _LIVE_LOCK = Lock() | |
| _LIVE_EVENTS: deque[dict] = deque(maxlen=2000) # last ~2000 ticks across matches | |
| def _live_push(obj: dict) -> None: | |
| with _LIVE_LOCK: | |
| _LIVE_EVENTS.append(obj) | |
| def _live_snapshot(match_id: str | None = None) -> list[dict]: | |
| with _LIVE_LOCK: | |
| if match_id: | |
| return [e for e in list(_LIVE_EVENTS) if e.get("match_id") == match_id] | |
| return list(_LIVE_EVENTS) | |
| def policy_act(packet: FramePacket) -> PolicyActResponse: | |
| """ | |
| Stateless policy endpoint for the Windows live pipeline. | |
| Input: structured FramePacket (no images) | |
| Output: one action (play/wait) for this tick | |
| """ | |
| t0 = now_ms() | |
| tick_id = int(packet.meta.tick_id or 0) | |
| if should_gate(packet): | |
| action = PolicyAction(kind="wait", emote="yawn", reasoning="low_detection_quality") | |
| resp = PolicyActResponse(tick_id=tick_id, action=action, source="gated") | |
| else: | |
| action = rule_policy(packet) | |
| resp = PolicyActResponse(tick_id=tick_id, action=action, source="rules") | |
| latency_ms = now_ms() - t0 | |
| live_obj = { | |
| "ts_ms": now_ms(), | |
| "match_id": packet.meta.match_id, | |
| "tick_id": tick_id, | |
| "time_left_s": (packet.ui.time_left_s if packet.ui else None), | |
| "overall_q": ( | |
| packet.debug.detections_quality.overall | |
| if packet.debug and packet.debug.detections_quality | |
| else None | |
| ), | |
| "my_elixir": packet.player.elixir if packet.player else None, | |
| "hand": [c.model_dump() for c in (packet.player.hand if packet.player else [])], | |
| "action": resp.action.model_dump(), | |
| "source": resp.source, | |
| "latency_ms": latency_ms, | |
| } | |
| _live_push(live_obj) | |
| append_jsonl( | |
| # stored under toxic_royale_env/outputs/policy_logs/ | |
| __import__("pathlib").Path(__file__).resolve().parents[1] / "outputs" / "policy_logs" / "policy_act.jsonl", | |
| live_obj, | |
| ) | |
| return resp | |
| def live_page() -> str: | |
| return """ | |
| <!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>ToxicRoyale Live Match</title> | |
| <style> | |
| body { font-family: ui-sans-serif, system-ui, -apple-system; margin: 16px; } | |
| .row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } | |
| .card { border: 1px solid #ddd; border-radius: 12px; padding: 10px 12px; min-width: 210px; } | |
| .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas; font-size: 12px; white-space: pre-wrap; } | |
| table { border-collapse: collapse; width: 100%; } | |
| th, td { border-bottom: 1px solid #eee; text-align: left; padding: 6px 8px; font-size: 13px; } | |
| .pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f3f4f6; } | |
| </style> | |
| </head> | |
| <body> | |
| <h2>Live Match Viewer</h2> | |
| <div class="row"> | |
| <div class="card"><b>Match</b><div id="match">-</div></div> | |
| <div class="card"><b>Tick</b><div id="tick">-</div></div> | |
| <div class="card"><b>Time left</b><div id="time">-</div></div> | |
| <div class="card"><b>Elixir</b><div id="elixir">-</div></div> | |
| <div class="card"><b>Quality</b><div id="q">-</div></div> | |
| <div class="card"><b>Chosen action</b><div id="action">-</div><div id="src" class="pill">-</div></div> | |
| </div> | |
| <h3>Hand</h3> | |
| <table> | |
| <thead><tr><th>slot</th><th>card</th><th>cost</th><th>playable</th><th>conf</th></tr></thead> | |
| <tbody id="hand"></tbody> | |
| </table> | |
| <h3>Raw event JSON</h3> | |
| <div id="raw" class="mono"></div> | |
| <script> | |
| const handEl = document.getElementById("hand"); | |
| const rawEl = document.getElementById("raw"); | |
| const params = new URLSearchParams(window.location.search); | |
| const match = params.get("match_id"); | |
| const url = match ? `/live/stream?match_id=${encodeURIComponent(match)}` : `/live/stream`; | |
| const ev = new EventSource(url); | |
| function td(v){ return `<td>${v ?? ""}</td>`; } | |
| ev.onmessage = (e) => { | |
| const obj = JSON.parse(e.data); | |
| document.getElementById("match").innerText = obj.match_id ?? "-"; | |
| document.getElementById("tick").innerText = obj.tick_id ?? "-"; | |
| document.getElementById("time").innerText = (obj.time_left_s == null) ? "-" : obj.time_left_s.toFixed(1) + "s"; | |
| document.getElementById("elixir").innerText = (obj.my_elixir == null) ? "-" : obj.my_elixir.toFixed(2); | |
| document.getElementById("q").innerText = (obj.overall_q == null) ? "-" : obj.overall_q.toFixed(2); | |
| document.getElementById("action").innerText = JSON.stringify(obj.action); | |
| document.getElementById("src").innerText = obj.source; | |
| const hand = obj.hand ?? []; | |
| handEl.innerHTML = hand.map(c => `<tr>${td(c.slot)}${td(c.card)}${td(c.cost)}${td(c.is_playable)}${td(c.conf)}</tr>`).join(""); | |
| rawEl.innerText = JSON.stringify(obj, null, 2); | |
| }; | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def live_stream(match_id: str | None = None): | |
| def gen(): | |
| last_ts = 0 | |
| while True: | |
| events = _live_snapshot(match_id) | |
| if events: | |
| ev = events[-1] | |
| if int(ev.get("ts_ms") or 0) != last_ts: | |
| last_ts = int(ev.get("ts_ms") or 0) | |
| yield f"data: {json.dumps(ev, ensure_ascii=False)}\\n\\n" | |
| time.sleep(0.2) | |
| return StreamingResponse(gen(), media_type="text/event-stream") | |
| def main(host: str = "0.0.0.0", port: int = 8000): | |
| """ | |
| Entry point for direct execution via uv run or python -m. | |
| This function enables running the server without Docker: | |
| uv run --project . server | |
| uv run --project . server --port 8001 | |
| python -m toxic_royale_env.server.app | |
| Args: | |
| host: Host address to bind to (default: "0.0.0.0") | |
| port: Port number to listen on (default: 8000) | |
| For production deployments, consider using uvicorn directly with | |
| multiple workers: | |
| uvicorn toxic_royale_env.server.app:app --workers 4 | |
| """ | |
| import uvicorn | |
| uvicorn.run(app, host=host, port=port) | |
| if __name__ == "__main__": | |
| main() | |