refactor: game state persistence (SQLite), cookie-based sessions, health/reset endpoints

This commit is contained in:
2026-08-09 07:44:42 +00:00
parent 06618b5e7d
commit 466dbc2214
4 changed files with 246 additions and 13 deletions
+58 -7
View File
@@ -1,18 +1,39 @@
"""
JSON API routes for React frontend.
Uses game_id cookies for session persistence.
"""
from flask import Blueprint, request, jsonify, session
from flask import Blueprint, request, jsonify, make_response
from app.services.game_service import start_game, roll_scene, format_scene_data
from app.services.persistence import create_game, get_game, update_game, delete_game
api_bp = Blueprint("api", __name__)
GAME_COOKIE = "pony_game_id"
def _get_game_from_cookie():
"""Get game from cookie, return (response_modifier, game) tuple."""
game_id = request.cookies.get(GAME_COOKIE)
if not game_id:
return None, None
game = get_game(game_id)
return game_id, game
def _set_game_cookie(response, game_id):
"""Add game_id cookie to response."""
response.set_cookie(GAME_COOKIE, game_id, max_age=86400, httponly=True)
return response
@api_bp.route("/api/start", methods=["POST"])
def api_start():
"""Start a new game.
Expects JSON: {"type": <pony_idx>, "tema": <theme_idx>}
Returns game state with game_id cookie.
"""
data = request.get_json(force=True)
ponytype_idx = data.get("type")
@@ -22,14 +43,24 @@ def api_start():
tema_idx = int(data.get("tema", 0))
game = start_game(ponytype_idx, tema_idx)
session["spil"] = game
return jsonify(format_scene_data(game))
game_id = game["game_id"]
# Delete old game if exists
old_game = get_game(game_id)
if old_game:
delete_game(game_id)
create_game(game_id, game)
resp = make_response(jsonify(format_scene_data(game)))
_set_game_cookie(resp, game_id)
return resp
@api_bp.route("/api/scene", methods=["GET"])
def api_scene():
"""Get current scene data."""
game = session.get("spil")
game_id, game = _get_game_from_cookie()
if not game:
return jsonify({"error": "no game"}), 404
return jsonify(format_scene_data(game))
@@ -38,10 +69,30 @@ def api_scene():
@api_bp.route("/api/kast", methods=["POST"])
def api_kast():
"""Roll dice for current scene."""
game = session.get("spil")
game_id, game = _get_game_from_cookie()
if not game or game.get("færdig"):
return jsonify({"error": "no game"}), 404
game = roll_scene(game)
session["spil"] = game
return jsonify(format_scene_data(game))
update_game(game_id, game)
resp = make_response(jsonify(format_scene_data(game)))
_set_game_cookie(resp, game_id)
return resp
@api_bp.route("/api/reset", methods=["POST"])
def api_reset():
"""Reset current game."""
game_id, game = _get_game_from_cookie()
if game_id:
delete_game(game_id)
resp = make_response(jsonify({"ok": True}))
resp.delete_cookie(GAME_COOKIE)
return resp
@api_bp.route("/api/health", methods=["GET"])
def api_health():
"""Health check endpoint."""
return jsonify({"ok": True, "game": "MLP Pony: Tails of Equestria"})
+93
View File
@@ -0,0 +1,93 @@
"""
SQLite-backed game state persistence for MLP Pony.
Provides a persistent store for game sessions, replacing Flask in-memory sessions.
Each game gets a UUID, stored in a SQLite database.
"""
import json
import os
import sqlite3
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "game_state.db")
def get_connection():
"""Get a database connection, creating tables if needed."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
_ensure_tables(conn)
return conn
def _ensure_tables(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
state TEXT NOT NULL
)
""")
conn.commit()
def create_game(game_id, state):
"""Insert a new game state. Returns True if created."""
conn = get_connection()
try:
conn.execute(
"INSERT INTO games (game_id, state) VALUES (?, ?)",
(game_id, json.dumps(state))
)
conn.commit()
return True
except sqlite3.IntegrityError:
return False
finally:
conn.close()
def get_game(game_id):
"""Get game state by ID. Returns dict or None."""
conn = get_connection()
row = conn.execute("SELECT state FROM games WHERE game_id = ?", (game_id,)).fetchone()
conn.close()
if row:
return json.loads(row["state"])
return None
def update_game(game_id, state):
"""Update game state. Returns True if updated."""
conn = get_connection()
result = conn.execute(
"UPDATE games SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE game_id = ?",
(json.dumps(state), game_id)
)
conn.commit()
updated = result.rowcount > 0
conn.close()
return updated
def delete_game(game_id):
"""Delete a game. Returns True if deleted."""
conn = get_connection()
result = conn.execute("DELETE FROM games WHERE game_id = ?", (game_id,))
conn.commit()
deleted = result.rowcount > 0
conn.close()
return deleted
def list_games(limit=10):
"""List recent games. Returns list of dicts with game_id, created_at."""
conn = get_connection()
rows = conn.execute(
"SELECT game_id, created_at, updated_at FROM games ORDER BY updated_at DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [{"game_id": r["game_id"], "created_at": r["created_at"], "updated_at": r["updated_at"]}
for r in rows]
+63
View File
@@ -0,0 +1,63 @@
"""
Tests for SQLite persistence layer.
"""
import sys
import os
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services import persistence
# Each test file gets its own temp DB
_TEST_DB = os.path.join(tempfile.gettempdir(), f"pony_persist_only_{os.getpid()}.db")
persistence.DB_PATH = _TEST_DB
TEST_GAME_ID = "test-uuid-123"
TEST_STATE = {"pony": {"navn": "Test"}, "scene": 0}
def setup_function():
"""Drop and recreate DB before each test."""
if os.path.exists(_TEST_DB):
os.remove(_TEST_DB)
class TestPersistence:
def test_create_and_get(self):
assert persistence.create_game(TEST_GAME_ID, TEST_STATE)
state = persistence.get_game(TEST_GAME_ID)
assert state == TEST_STATE
def test_create_duplicate(self):
persistence.create_game(TEST_GAME_ID, TEST_STATE)
assert not persistence.create_game(TEST_GAME_ID, TEST_STATE)
def test_get_nonexistent(self):
assert persistence.get_game("no-such-id") is None
def test_update(self):
persistence.create_game(TEST_GAME_ID, TEST_STATE)
new_state = dict(TEST_STATE)
new_state["scene"] = 5
assert persistence.update_game(TEST_GAME_ID, new_state)
state = persistence.get_game(TEST_GAME_ID)
assert state["scene"] == 5
def test_update_nonexistent(self):
assert not persistence.update_game("no-such-id", TEST_STATE)
def test_delete(self):
persistence.create_game(TEST_GAME_ID, TEST_STATE)
assert persistence.delete_game(TEST_GAME_ID)
assert persistence.get_game(TEST_GAME_ID) is None
def test_delete_nonexistent(self):
assert not persistence.delete_game("no-such-id")
def test_list_games(self):
persistence.create_game("a", TEST_STATE)
persistence.create_game("b", TEST_STATE)
games = persistence.list_games()
assert len(games) == 2
+32 -6
View File
@@ -1,13 +1,18 @@
"""
Integration tests: full game flow through the API.
Integration tests: full game flow through the API with cookie-based sessions.
"""
import sys
import os
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.config import create_app
from app.services import persistence
# Use temp DB for tests
persistence.DB_PATH = tempfile.mktemp()
def make_client():
@@ -19,7 +24,6 @@ def make_client():
class TestGameFlow:
def test_full_game(self):
c = make_client()
# Start
r = c.post("/api/start", json={"type": 0, "tema": 0})
assert r.status_code == 200
data = r.get_json()
@@ -33,12 +37,18 @@ class TestGameFlow:
r = c.post("/api/kast")
assert r.status_code == 200
# After 5 rolls, game should be finished
# Game should be finished
r = c.get("/api/scene")
data = r.get_json()
assert data["finished"]
assert len(data["history"]) == 5
def test_health(self):
c = make_client()
r = c.get("/api/health")
assert r.status_code == 200
assert r.get_json()["ok"]
def test_invalid_pony_type(self):
c = make_client()
r = c.post("/api/start", json={"tema": 0})
@@ -54,16 +64,32 @@ class TestGameFlow:
r = c.get("/api/scene")
assert r.status_code == 404
def test_reset(self):
c = make_client()
c.post("/api/start", json={"type": 0, "tema": 0})
r = c.post("/api/reset")
assert r.status_code == 200
def test_all_themes(self):
c = make_client()
for tema_idx in range(5):
r = c.post("/api/start", json={"type": 2, "tema": tema_idx})
assert r.status_code == 200
data = r.get_json()
assert not data["finished"]
def test_all_pony_types(self):
c = make_client()
for pony_idx in range(4):
r = c.post("/api/start", json={"type": pony_idx, "tema": 0})
assert r.status_code == 200
assert r.status_code == 200
def test_persistence_survives_new_client(self):
"""Game state persists across separate requests."""
c = make_client()
c.post("/api/start", json={"type": 0, "tema": 0})
c.post("/api/kast") # scene 1
# New client with same cookies should see same game
# (testclient preserves cookies automatically)
r = c.get("/api/scene")
data = r.get_json()
assert len(data["history"]) == 1