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:
co-authored by
Claude Sonnet 5
parent
5d6747770c
commit
1289079da5
@@ -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)
|
||||
Reference in New Issue
Block a user