Initial Commit
This commit is contained in:
commit
35f3810632
90 changed files with 29267 additions and 0 deletions
126
tools/playtest_server.py
Normal file
126
tools/playtest_server.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Serve one prototype and save validated playtest JSONL into the repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
import tempfile
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
MAX_LOG_BYTES = 10 * 1024 * 1024
|
||||
SAFE_FILENAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,159}\.jsonl$")
|
||||
|
||||
|
||||
class PlaytestHandler(SimpleHTTPRequestHandler):
|
||||
server_version = "FindMyFunPlaytestServer/1"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if urlparse(self.path).path == "/api/playtest-log":
|
||||
self._json_response(200, {"ready": True, "destination": "JSONL/"})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if urlparse(self.path).path != "/api/playtest-log":
|
||||
self._json_response(404, {"error": "unknown endpoint"})
|
||||
return
|
||||
|
||||
raw_length = self.headers.get("Content-Length")
|
||||
try:
|
||||
length = int(raw_length or "")
|
||||
except ValueError:
|
||||
self._json_response(411, {"error": "valid Content-Length required"})
|
||||
return
|
||||
if length <= 0 or length > MAX_LOG_BYTES:
|
||||
self._json_response(413, {"error": f"log must be between 1 and {MAX_LOG_BYTES} bytes"})
|
||||
return
|
||||
|
||||
filename = self.headers.get("X-Playtest-Filename", "")
|
||||
if not SAFE_FILENAME.fullmatch(filename) or ".." in filename:
|
||||
self._json_response(400, {"error": "unsafe or invalid JSONL filename"})
|
||||
return
|
||||
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
events = [json.loads(line) for line in text.splitlines() if line.strip()]
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
self._json_response(400, {"error": f"invalid JSONL: {error}"})
|
||||
return
|
||||
if not events or not all(isinstance(event, dict) for event in events):
|
||||
self._json_response(400, {"error": "JSONL must contain event objects"})
|
||||
return
|
||||
|
||||
experiment = events[0].get("experiment")
|
||||
session_id = events[0].get("session_id")
|
||||
if not experiment or not session_id:
|
||||
self._json_response(400, {"error": "first event must contain experiment and session_id"})
|
||||
return
|
||||
if any(event.get("experiment") != experiment or event.get("session_id") != session_id for event in events):
|
||||
self._json_response(400, {"error": "all events must belong to one experiment and session"})
|
||||
return
|
||||
|
||||
destination = self.server.log_directory / filename
|
||||
normalized = text if text.endswith("\n") else text + "\n"
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=self.server.log_directory, prefix=".playtest-", suffix=".tmp", delete=False) as temporary:
|
||||
temporary.write(normalized)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
os.replace(temporary_path, destination)
|
||||
except OSError as error:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
self._json_response(500, {"error": f"could not save log: {error}"})
|
||||
return
|
||||
|
||||
self._json_response(201, {"saved": True, "path": f"JSONL/{filename}", "events": len(events), "bytes": len(normalized.encode("utf-8"))})
|
||||
|
||||
def _json_response(self, status: int, payload: dict[str, object]) -> None:
|
||||
body = (json.dumps(payload) + "\n").encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
class PlaytestServer(ThreadingHTTPServer):
|
||||
def __init__(self, address: tuple[str, int], handler: type[PlaytestHandler], directory: Path, log_directory: Path):
|
||||
self.log_directory = log_directory
|
||||
self.log_directory.mkdir(parents=True, exist_ok=True)
|
||||
super().__init__(address, lambda *args, **kwargs: handler(*args, directory=str(directory), **kwargs))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--directory", type=Path, required=True)
|
||||
parser.add_argument("--log-directory", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
args = parser.parse_args()
|
||||
|
||||
directory = args.directory.resolve(strict=True)
|
||||
log_directory = args.log_directory.resolve()
|
||||
server = PlaytestServer(("127.0.0.1", args.port), PlaytestHandler, directory, log_directory)
|
||||
print(f"Serving {directory} at http://127.0.0.1:{args.port}")
|
||||
print(f"Playtest logs save to {log_directory}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue