refactor: game state persistence (SQLite), cookie-based sessions, health/reset endpoints
This commit is contained in:
+58
-7
@@ -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"})
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user