- Removed PonyConfiguratorPage, PonyAvatar, ponyCustomization - Flow is now: Home → Theme → Pony Select → Game (no configurator step) - Added pixel art pony sprite sheets from /tmp/Pixel Ponies.zip - GameScenePage uses pony image directly instead of SVG avatar - 48 frontend + 67 backend tests passing
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""
|
|
JSON API routes for React frontend.
|
|
|
|
Uses game_id cookies for session persistence.
|
|
"""
|
|
|
|
import random
|
|
|
|
from flask import Blueprint, current_app, request, jsonify, make_response, 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
|
|
from app.services.speech_to_text import get_speech_to_text_provider, SpeechToTextError
|
|
from app.services.text_to_speech import synthesize_danish, TextToSpeechError
|
|
from app.services.intent_classifier import classify_with_hermes
|
|
from app.game.voice_matcher import match_intent
|
|
from app.data.themes import THEMAER
|
|
from app.game.pony import PONITYPER
|
|
|
|
api_bp = Blueprint("api", __name__)
|
|
|
|
GAME_COOKIE = "pony_game_id"
|
|
MAX_AUDIO_BYTES = 10 * 1024 * 1024
|
|
ALLOWED_AUDIO_TYPES = {"audio/webm", "audio/ogg", "audio/wav", "audio/x-wav"}
|
|
MAX_NAVN_LENGTH = 20
|
|
MAX_TTS_TEXT_LENGTH = 2000
|
|
|
|
|
|
def _get_game_from_cookie():
|
|
"""Get game from cookie, return (game_id, 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>, "navn": <optional custom name>}
|
|
Returns game state with game_id cookie.
|
|
"""
|
|
data = request.get_json(force=True)
|
|
if not data or "type" not in data:
|
|
return jsonify({"error": "Manglende pony type"}), 400
|
|
try:
|
|
ponytype_idx = int(data["type"])
|
|
tema_idx = int(data.get("tema", 0))
|
|
except (ValueError, TypeError):
|
|
return jsonify({"error": "Ugyldig input"}), 400
|
|
if not (0 <= ponytype_idx < len(PONITYPER)):
|
|
return jsonify({"error": "Ukendt pony type"}), 400
|
|
if not (0 <= tema_idx < len(THEMAER)):
|
|
return jsonify({"error": "Ukendt tema"}), 400
|
|
|
|
custom_navn = None
|
|
navn = data.get("navn")
|
|
if navn is not None:
|
|
if not isinstance(navn, str):
|
|
return jsonify({"error": "Ugyldigt navn"}), 400
|
|
navn = navn.strip()
|
|
if len(navn) > MAX_NAVN_LENGTH:
|
|
return jsonify({"error": "Navnet er for langt"}), 400
|
|
custom_navn = navn or None
|
|
|
|
game = start_game(ponytype_idx, tema_idx, custom_navn)
|
|
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_id, game = _get_game_from_cookie()
|
|
if not game:
|
|
return jsonify({"error": "no game"}), 404
|
|
return jsonify(format_scene_data(game))
|
|
|
|
|
|
@api_bp.route("/api/kast", methods=["POST"])
|
|
def api_kast():
|
|
"""Roll dice for current scene."""
|
|
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)
|
|
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/content", methods=["GET"])
|
|
def api_content():
|
|
"""Get all game metadata: pony types and themes.
|
|
|
|
Used by frontend to render selection pages dynamically.
|
|
"""
|
|
return jsonify({
|
|
"ponies": [
|
|
{
|
|
"id": p["id"],
|
|
"navn": p["navn"],
|
|
"emoji": p["emoji"],
|
|
"bonus": p["bonus"],
|
|
"tekst": p["tekst"],
|
|
"img": p["img"],
|
|
}
|
|
for p in PONITYPER
|
|
],
|
|
"themes": [
|
|
{
|
|
"id": t["id"],
|
|
"titel": t["titel"],
|
|
"emoji": t.get("emoji", ""),
|
|
"intro": t.get("intro", ""),
|
|
"sceneCount": len(t.get("scener", [])),
|
|
}
|
|
for t in THEMAER
|
|
],
|
|
})
|
|
|
|
|
|
@api_bp.route("/api/health", methods=["GET"])
|
|
def api_health():
|
|
"""Health check endpoint."""
|
|
return jsonify({"ok": True, "game": "MLP Pony: Tails of Equestria"})
|
|
|
|
|
|
@api_bp.route("/api/tts", methods=["POST"])
|
|
def api_text_to_speech():
|
|
"""Read child-facing text with the same Danish female voice on every device."""
|
|
data = request.get_json(silent=True) or {}
|
|
text = data.get("text")
|
|
if not isinstance(text, str) or not text.strip() or len(text) > MAX_TTS_TEXT_LENGTH:
|
|
return jsonify({"error": "invalid_text"}), 400
|
|
try:
|
|
audio = synthesize_danish(text.strip())
|
|
except TextToSpeechError:
|
|
current_app.logger.exception("Danish TTS failed")
|
|
return jsonify({"error": "tts_error"}), 503
|
|
return Response(audio, mimetype="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
|
|
|
|
|
|
def _active_voice_question(game):
|
|
scenes = game.get("tema", {}).get("scener", [])
|
|
scene_index = game.get("scene", -1)
|
|
if game.get("færdig") or not 0 <= scene_index < len(scenes):
|
|
return None
|
|
voice = scenes[scene_index].get("voice") or {}
|
|
return voice.get("question") if voice.get("enabled") else None
|
|
|
|
|
|
def _handle_voice_text(game_id, game, text, scene_id=None, question_id=None, transcript_meta=None):
|
|
question = _active_voice_question(game)
|
|
if not question:
|
|
return jsonify({"ok": False, "error": "voice_not_available"}), 409
|
|
current_scene_id = str(game.get("scene"))
|
|
if scene_id is not None and str(scene_id) != current_scene_id:
|
|
return jsonify({"ok": False, "error": "stale_scene"}), 409
|
|
if question_id and question_id != question.get("id"):
|
|
return jsonify({"ok": False, "error": "stale_question"}), 409
|
|
if not isinstance(text, str) or not text.strip() or len(text) > 500:
|
|
return jsonify({"ok": False, "error": "invalid_transcript"}), 400
|
|
|
|
intents = question.get("intents", [])
|
|
result = match_intent(text, intents)
|
|
match_data = result.to_dict()
|
|
current_app.logger.debug("Voice match game=%s question=%s result=%r", game_id, question["id"], match_data)
|
|
hermes = None
|
|
if not result.matched:
|
|
hermes = classify_with_hermes(game, question, text)
|
|
if hermes and hermes["confidence"] >= 0.75:
|
|
if hermes["action"] == "choice":
|
|
match_data.update({
|
|
"matched": True, "intent": hermes["intent"],
|
|
"confidence": hermes["confidence"], "method": "hermes",
|
|
"response_type": "creative_accepted",
|
|
})
|
|
else:
|
|
update_game(game_id, game)
|
|
return jsonify({"ok": True, "data": {
|
|
"transcript": text, "matched": True, "intent": None,
|
|
"confidence": hermes["confidence"], "response_type": "game_answer",
|
|
"child_response": hermes["reply"], "next_action": None,
|
|
"gameState": None, "match_method": "hermes",
|
|
}})
|
|
|
|
selected = next((item for item in intents if item.get("id") == match_data.get("intent")), None)
|
|
retries = game.setdefault("voice_retries", {})
|
|
retry_key = question["id"]
|
|
child_response = question.get("fallback", {}).get(
|
|
"retry_prompt", "Jeg hørte dig ikke helt. Vil du prøve igen?"
|
|
)
|
|
next_action = None
|
|
game_state = None
|
|
if match_data["matched"] and selected:
|
|
retries[retry_key] = 0
|
|
responses = selected.get("positive_response") or ["Ja! Lad os gøre det!"]
|
|
child_response = hermes["reply"] if hermes and hermes.get("reply") else random.choice(responses)
|
|
choice_id = selected.get("choice_id")
|
|
next_action = {"type": "scene_choice", "choice_id": choice_id}
|
|
if choice_id == "roll_scene":
|
|
game = roll_scene(game)
|
|
game_state = format_scene_data(game)
|
|
update_game(game_id, game)
|
|
else:
|
|
retries[retry_key] = retries.get(retry_key, 0) + 1
|
|
fallback = question.get("fallback", {})
|
|
if retries[retry_key] > fallback.get("max_retries", 2):
|
|
match_data["show_visual_choices"] = True
|
|
update_game(game_id, game)
|
|
|
|
public_match = {key: value for key, value in match_data.items() if key != "scores"}
|
|
data = {
|
|
"transcript": text, **public_match, "child_response": child_response,
|
|
"next_action": next_action, "gameState": game_state,
|
|
}
|
|
data["match_method"] = data.pop("method")
|
|
if transcript_meta:
|
|
data["transcription"] = transcript_meta
|
|
return jsonify({"ok": True, "data": data})
|
|
|
|
|
|
@api_bp.route("/api/v1/games/<game_id>/voice/text", methods=["POST"])
|
|
def api_voice_text(game_id):
|
|
"""Match already-transcribed text; useful for development and non-audio clients."""
|
|
game = get_game(game_id)
|
|
if not game:
|
|
return jsonify({"ok": False, "error": "game_not_found"}), 404
|
|
data = request.get_json(silent=True) or {}
|
|
return _handle_voice_text(
|
|
game_id, game, data.get("text"), data.get("scene_id"), data.get("question_id")
|
|
)
|
|
|
|
|
|
@api_bp.route("/api/v1/games/<game_id>/voice", methods=["POST"])
|
|
def api_voice(game_id):
|
|
"""Transcribe an audio answer, then match it against the active scene intents."""
|
|
game = get_game(game_id)
|
|
if not game:
|
|
return jsonify({"ok": False, "error": "game_not_found"}), 404
|
|
audio = request.files.get("audio")
|
|
if not audio:
|
|
return jsonify({"ok": False, "error": "missing_audio"}), 400
|
|
if audio.mimetype not in ALLOWED_AUDIO_TYPES:
|
|
return jsonify({"ok": False, "error": "unsupported_audio"}), 415
|
|
audio_bytes = audio.read(MAX_AUDIO_BYTES + 1)
|
|
if not audio_bytes or len(audio_bytes) > MAX_AUDIO_BYTES:
|
|
return jsonify({"ok": False, "error": "invalid_audio_size"}), 413
|
|
try:
|
|
transcript = get_speech_to_text_provider().transcribe(
|
|
audio_bytes, language="da", filename=audio.filename or "audio.webm",
|
|
content_type=audio.mimetype,
|
|
)
|
|
except SpeechToTextError:
|
|
return jsonify({
|
|
"ok": False, "error": "stt_error",
|
|
"child_response": "Jeg hørte dig ikke helt. Vil du prøve igen?",
|
|
}), 503
|
|
return _handle_voice_text(
|
|
game_id, game, transcript.text, request.form.get("scene_id"),
|
|
request.form.get("question_id"), transcript.to_dict(),
|
|
)
|