Ajoute "Publier" : exporte un jeu en exécutable Windows autonome

Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).

Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.

- db/constants.py : PROJECTS_DIR devient surchargeable via
  FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
  joueur autonome pointe ainsi vers son propre dossier "projects/"
  embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
  cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
  embeddable officiel (python.org) et vendore Flask via pip --target ;
  fonctions séparées et mockables pour ne jamais déclencher de vrai
  téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
  core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
  dur au moment de la publication, csrf_token() factice puisqu'aucune
  session n'existe dans cet export). sys.path doit être complété
  manuellement au démarrage : le python311._pth de la distribution
  embeddable ne référence que le dossier de python.exe lui-même, jamais
  celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
  (jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
  réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
  garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
  de suivi serveur réel — le build est rapide) qui déclenche le
  téléchargement du zip via un blob, comme la modale des codes de
  récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
  pjax.js la remplace et rejoue son script à chaque navigation).

Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.

tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-29 09:58:32 +02:00
co-authored by Claude Sonnet 5
parent 5d6747770c
commit 1289079da5
11 changed files with 539 additions and 2 deletions
+107
View File
@@ -0,0 +1,107 @@
"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL
jeu (voir publish/build_package.py) : aucune installation externe requise,
lancé par double-clic sur run.bat (Python portable + Flask embarqués à
côté de ce fichier). Reprend telles quelles les routes utilisées par le
mode jouable (voir templates/play.html) — jamais l'éditeur ni
l'authentification, absents de cet export.
SLUG est remplacé littéralement par build_package.py au moment de la
publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté
tel quel depuis le dépôt source."""
import json
import os
import socket
import sys
import threading
import webbrowser
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py)
# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/)
# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus
# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/
# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à
# côté de ce fichier.
sys.path.insert(0, _BASE_DIR)
# Doit être posé AVANT tout import de db/ : db/constants.py lit
# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le
# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du
# poste où le jeu a été publié.
os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects")
from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402
from core.flask_app import app # noqa: E402
from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname)
import db # noqa: E402
import screens # noqa: E402
# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) —
# play.html appelle quand même csrf_token() en Jinja (voir base.html/
# play.html) : un no-op le laisse fonctionner tel quel, sans le forker.
app.jinja_env.globals["csrf_token"] = lambda: ""
SLUG = "_SLUG_PLACEHOLDER"
@app.route("/")
def index():
return redirect(url_for("game_play", slug=SLUG))
@app.route("/game/<slug>/play")
def game_play(slug):
game = db.game_meta(SLUG)
payload = screens.full_game_payload(SLUG)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
)
@app.route("/game/<slug>/runtime-payload")
def runtime_payload(slug):
return jsonify(screens.full_game_payload(SLUG))
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
def flow_node_run_data(slug, node_id):
node = screens.get_flow_node(SLUG, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("clicked_row_id")}
ok = screens.apply_data_action(SLUG, node)
return jsonify({"ok": ok})
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable(slug, node_id):
node = screens.get_flow_node(SLUG, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(SLUG, node)
return jsonify({"ok": ok})
@app.route("/game/<slug>/uploads/<path:filename>")
def uploaded_file(slug, filename):
return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename)
def _open_browser(port):
webbrowser.open(f"http://127.0.0.1:{port}/")
if __name__ == "__main__":
# Port libre choisi au hasard par l'OS (jamais le même serveur à
# relancer sur un port déjà pris par une partie précédente encore
# ouverte) — le navigateur est ouvert automatiquement, l'utilisateur
# n'a jamais besoin de connaître ni taper le port.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
threading.Timer(1.0, _open_browser, args=(port,)).start()
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)