#!/usr/bin/env python3 """Serve the production web client and append completed games to JSONL.""" from __future__ import annotations import argparse import json from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any MAX_RECORD_BYTES = 6_000_000 # v2 adds the opponent's identity (codename + hash) and the match layer -- three # deals, the coin flips, and which round each move belongs to. v1 is still # accepted because 111 games were recorded under it; they are all altair, which # data/models.json records since v1 has nowhere to say so. SUPPORTED_FORMATS = frozenset({"lost-cities-web-game-v1", "lost-cities-web-game-v2"}) def parse_record(body: bytes) -> dict[str, Any]: if len(body) > MAX_RECORD_BYTES: raise ValueError("record is too large") value = json.loads(body) if not isinstance(value, dict) or value.get("format") not in SUPPORTED_FORMATS: raise ValueError("unsupported game record") if not isinstance(value.get("gameId"), str) or not isinstance(value.get("moves"), list): raise ValueError("invalid game record") return value def make_handler(dist: Path, output: Path): seen_ids: set[str] = set() if output.exists(): for line in output.read_text(encoding="utf-8").splitlines(): try: game_id = json.loads(line).get("gameId") if isinstance(game_id, str): seen_ids.add(game_id) except (json.JSONDecodeError, AttributeError): continue class Handler(SimpleHTTPRequestHandler): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, directory=str(dist), **kwargs) def do_POST(self) -> None: # noqa: N802 if self.path != "/api/game-records": self.send_error(HTTPStatus.NOT_FOUND) return try: length = int(self.headers.get("content-length", "0")) record = parse_record(self.rfile.read(length)) except (ValueError, json.JSONDecodeError) as error: self.send_error(HTTPStatus.BAD_REQUEST, str(error)) return game_id = record["gameId"] if game_id not in seen_ids: output.parent.mkdir(parents=True, exist_ok=True) with output.open("a", encoding="utf-8") as stream: stream.write( json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" ) seen_ids.add(game_id) self.send_response(HTTPStatus.NO_CONTENT) self.end_headers() return Handler def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=5173) parser.add_argument("--dist", type=Path, default=Path("web/dist")) parser.add_argument("--output", type=Path, default=Path("data/human-play/game-records.jsonl")) args = parser.parse_args() server = ThreadingHTTPServer((args.host, args.port), make_handler(args.dist, args.output)) print(f"Serving {args.dist} on http://{args.host}:{args.port}", flush=True) print(f"Writing game records to {args.output}", flush=True) server.serve_forever() if __name__ == "__main__": main()