Files
blokus/src/blokus_ui/store.py
T
mattlamb227@gmail.com 70672e9fef 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
2026-08-09 20:08:18 -04:00

100 lines
3.5 KiB
Python

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