feat: add playable Blokus web UI with replay system
- Add blokus_ui package: Flask web app for human-vs-bot Blokus - GameSession: wraps BlokusGame for human play, records move history, serializes - RunStore: file-based JSON replay storage with save/load/list/delete - Flask app: routes for new game, play, save, browse runs, replay - Templates: index (new game + run browser), game (playable board), replay (step controls) - Static: CSS (dark theme, colored cells, placement overlays) + JS (click handling, API calls) - Add blokus_ui tests (19 tests for GameSession and RunStore) - Update pyproject.toml with flask dependency and blokus-ui entry point - Fix: remove unreachable dead code in BlokusGame.valid_move - All 102 tests pass, ruff lint clean
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RunStore:
|
||||
"""File-based storage for game replays as JSON files.
|
||||
|
||||
Each saved run is a self-contained JSON file with game configuration,
|
||||
move history, and computed statistics.
|
||||
"""
|
||||
|
||||
def __init__(self, runs_dir: str | None = None):
|
||||
if runs_dir is None:
|
||||
runs_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "runs"
|
||||
)
|
||||
self.runs_dir = os.path.abspath(runs_dir)
|
||||
os.makedirs(self.runs_dir, exist_ok=True)
|
||||
|
||||
def save(self, session, name: str | None = None) -> str:
|
||||
"""Save a GameSession as a replay file. Returns the run_id."""
|
||||
data = session.to_dict()
|
||||
|
||||
if name is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
bot_type = data["config"]["bot_type"]
|
||||
name = f"{bot_type}_{timestamp}"
|
||||
|
||||
run_id = f"{name}_{uuid.uuid4().hex[:8]}"
|
||||
filepath = os.path.join(self.runs_dir, f"{run_id}.json")
|
||||
|
||||
run_data = {
|
||||
"run_id": run_id,
|
||||
"name": name,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(run_data, f, indent=2)
|
||||
|
||||
return run_id
|
||||
|
||||
def list_runs(self) -> list[dict[str, Any]]:
|
||||
"""List all saved runs with metadata, sorted by date (newest first)."""
|
||||
runs = []
|
||||
for filename in os.listdir(self.runs_dir):
|
||||
if not filename.endswith(".json"):
|
||||
continue
|
||||
filepath = os.path.join(self.runs_dir, filename)
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
run_data = json.load(f)
|
||||
config = run_data["data"]["config"]
|
||||
stats = run_data["data"].get("statistics", {})
|
||||
scores = run_data["data"].get("scores", [])
|
||||
runs.append(
|
||||
{
|
||||
"run_id": run_data["run_id"],
|
||||
"name": run_data["name"],
|
||||
"created_at": run_data["created_at"],
|
||||
"board_size": config["board_size"],
|
||||
"num_players": config["num_players"],
|
||||
"human_player": config["human_player"],
|
||||
"bot_type": config["bot_type"],
|
||||
"game_over": run_data["data"]["game_over"],
|
||||
"winner": run_data["data"]["winner"],
|
||||
"scores": scores,
|
||||
"total_moves": stats.get("total_moves", 0),
|
||||
"coverage": stats.get("coverage", 0),
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
runs.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return runs
|
||||
|
||||
def load(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""Load a run by ID. Returns the full run data dict, or None."""
|
||||
filepath = os.path.join(self.runs_dir, f"{run_id}.json")
|
||||
if not os.path.exists(filepath):
|
||||
return None
|
||||
|
||||
with open(filepath) as f:
|
||||
return json.load(f)
|
||||
|
||||
def delete(self, run_id: str) -> bool:
|
||||
"""Delete a run by ID. Returns True if deleted, False if not found."""
|
||||
filepath = os.path.join(self.runs_dir, f"{run_id}.json")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user