Retire l'export exécutable (.exe) et la partie publique en ligne (/jouer)
Build and deploy / test-python (push) Successful in 7m5s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Décision produit : seul l'export Web/SCORM (LMS) est pertinent — l'export
exécutable Windows autonome (publish/build_package.py, bouton "Publier")
et la partie publique par joueur (/jouer/<slug>, routes/public_play/,
"Publier en ligne") sont jugés redondants et retirés.

Conserve le mécanisme d'état "par joueur" (db/global_vars,
db/rows, per_player) : infrastructure générique déjà utilisée par
Score/Progression et testée indépendamment de toute route publique
(voir tests/test_player_state.py), aucune raison de la retirer.

_STATIC_ITEMS/_copy_characters (copie sélective des sprites CraftPix
réellement utilisés) migrent de build_package.py vers
build_scorm_package.py, seul appelant restant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 10:59:28 +02:00
co-authored by Claude Sonnet 5
parent 82d00b3800
commit e82eb7ce88
37 changed files with 115 additions and 1106 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
"""publish — empaquette un jeu en export Web/SCORM (voir
build_scorm_package.py) : le bouton "Exporter (Web/SCORM)" de la barre
de navigation d'un jeu (templates/base.html)."""
-165
View File
@@ -1,165 +0,0 @@
"""Assemble le zip exporté d'un jeu (bouton "Publier", voir
routes/publish/publish_game.py) : un dossier autonome contenant Python
portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie
figée du jeu (screens/db/filters/core minimal + templates/play.html +
le sous-ensemble utile de static/), sa base SQLite et ses fichiers
envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat).
Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un
dossier de travail temporaire, supprimé après envoi de la réponse) ni la
vraie base de données du jeu (copiée, jamais déplacée)."""
import os
import shutil
import tempfile
import zipfile
import db
import screens
from . import vendor_runtime
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
# templates/play.html) — jamais les assets propres à l'éditeur seul
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play", "characters"]
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
def _copy_characters(static_dir, used_forge_characters):
"""`static/characters/` contient 2 rangements bien distincts (voir le
plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat
Kenney (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
`animals/<animal>/<NN>/` (CraftPix, ~1,5-2 Go pour les 210 variantes)
— celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN)
correspondant aux personnages `animal-<animal>-<NN>` réellement
utilisés par CE jeu (used_forge_characters), pour ne pas gonfler
CHAQUE .zip exporté du poids de toute la bibliothèque. manifest.json
est toujours copié : screens/labels/animal_sprite_library.py le lit à
l'import, sans lui `screens` (copié intégralement juste après) ne
s'importerait plus dans le paquet exporté."""
src_root = os.path.join(_BASE_DIR, "static", "characters")
dst_root = os.path.join(static_dir, "characters")
for name in os.listdir(src_root):
if name == "animals":
continue
src = os.path.join(src_root, name)
dst = os.path.join(dst_root, name)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
os.makedirs(dst_root, exist_ok=True)
shutil.copy2(src, dst)
animals_src_root = os.path.join(src_root, "animals")
if not os.path.isdir(animals_src_root):
return
animals_dst_root = os.path.join(dst_root, "animals")
os.makedirs(animals_dst_root, exist_ok=True)
shutil.copy2(os.path.join(animals_src_root, "manifest.json"), os.path.join(animals_dst_root, "manifest.json"))
used_variants = set()
for slug in used_forge_characters:
# slug = "animal-<animal>-<NN>" (voir screens/labels/animal_sprite_library.py) ;
# ignore silencieusement tout ce qui ne colle pas à ce format —
# un personnage Kenney (déjà copié ci-dessus) n'a pas ce préfixe.
if not slug.startswith("animal-"):
continue
animal, _, nn = slug[len("animal-"):].rpartition("-")
if animal:
used_variants.add((animal, nn))
for animal, nn in used_variants:
src = os.path.join(animals_src_root, animal, nn)
if os.path.isdir(src):
shutil.copytree(src, os.path.join(animals_dst_root, animal, nn))
def _copy_engine_sources(staging_dir, slug):
"""Copie la partie du moteur nécessaire au mode jouable SEUL (jamais
l'éditeur ni l'authentification — voir le constat d'exploration dans
le plan : screens/ et db/ sont déjà totalement découplés de auth//
routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS
fichiers de core/ dont ils dépendent réellement)."""
for pkg in ("screens", "db", "filters"):
shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
core_dir = os.path.join(staging_dir, "core")
os.makedirs(core_dir, exist_ok=True)
for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"):
shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname))
templates_dir = os.path.join(staging_dir, "templates")
os.makedirs(templates_dir, exist_ok=True)
shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html"))
static_dir = os.path.join(staging_dir, "static")
used_forge_characters = screens.list_used_forge_characters(slug)
for item in _STATIC_ITEMS:
if item == "characters":
_copy_characters(static_dir, used_forge_characters)
continue
src = os.path.join(_BASE_DIR, "static", item)
dst = os.path.join(static_dir, item)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
def _copy_game_data(staging_dir, slug):
"""Copie le dossier RÉEL du jeu (game.db + uploads/, voir
db.game_dir) tel quel — jamais déplacé, jamais modifié."""
src = db.game_dir(slug)
dst = os.path.join(staging_dir, "projects", slug)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js"))
def _write_player_app(staging_dir, slug):
template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py")
with open(template_path, encoding="utf-8") as f:
content = f.read()
content = content.replace(_SLUG_PLACEHOLDER, slug)
with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f:
f.write(content)
def _write_run_bat(staging_dir):
src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat")
shutil.copy2(src, os.path.join(staging_dir, "run.bat"))
def _zip_dir(staging_dir, zip_path):
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
zf.write(full, rel)
def build_game_zip(slug):
"""Construit le zip et renvoie son chemin (dans un dossier temporaire
— à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir
routes/publish/publish_game.py)."""
game = db.game_meta(slug)
python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready()
staging_dir = tempfile.mkdtemp(prefix="forge_publish_")
try:
shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed"))
shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs"))
_copy_engine_sources(staging_dir, slug)
_copy_game_data(staging_dir, slug)
_write_player_app(staging_dir, slug)
_write_run_bat(staging_dir)
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip")
os.close(zip_fd)
_zip_dir(staging_dir, zip_path)
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
return zip_path, game["name"]
+58 -9
View File
@@ -1,12 +1,8 @@
"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir
routes/publish/export_scorm.py) : à la différence de build_package.py
(qui embarque Python + Flask, voir player_app.py), ce paquet est
STATIQUE — index.html porte tout le runtime jouable directement en
mémoire (window.FORGE_OFFLINE, voir static/js/play/offline/) puisqu'un
SCORM tourne SEUL dans le LMS du client, sans serveur Forge Engine
disponible. Rien de commun avec le zip .exe hors le sous-ensemble de
static/ et _copy_characters (mêmes assets, mêmes règles de copie
sélective) — réutilisés depuis build_package plutôt que dupliqués."""
routes/publish/export_scorm.py) — paquet STATIQUE : index.html porte tout
le runtime jouable directement en mémoire (window.FORGE_OFFLINE, voir
static/js/play/offline/) puisqu'un SCORM tourne SEUL dans le LMS du
client, sans serveur Forge Engine disponible."""
import base64
import json
import os
@@ -19,12 +15,65 @@ from flask import render_template
import db
import screens
from .build_package import _copy_characters, _STATIC_ITEMS
from .scorm_manifest import build_manifest_xml
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
# templates/play.html) — jamais les assets propres à l'éditeur seul
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play", "characters"]
def _copy_characters(static_dir, used_forge_characters):
"""`static/characters/` contient 2 rangements bien distincts (voir le
plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat
Kenney (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
`animals/<animal>/<NN>/` (CraftPix, ~1,5-2 Go pour les 210 variantes)
— celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN)
correspondant aux personnages `animal-<animal>-<NN>` réellement
utilisés par CE jeu (used_forge_characters), pour ne pas gonfler
CHAQUE .zip exporté du poids de toute la bibliothèque. manifest.json
est toujours copié : screens/labels/animal_sprite_library.py le lit à
l'import, sans lui `screens` (copié intégralement juste après) ne
s'importerait plus dans le paquet exporté."""
src_root = os.path.join(_BASE_DIR, "static", "characters")
dst_root = os.path.join(static_dir, "characters")
for name in os.listdir(src_root):
if name == "animals":
continue
src = os.path.join(src_root, name)
dst = os.path.join(dst_root, name)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
os.makedirs(dst_root, exist_ok=True)
shutil.copy2(src, dst)
animals_src_root = os.path.join(src_root, "animals")
if not os.path.isdir(animals_src_root):
return
animals_dst_root = os.path.join(dst_root, "animals")
os.makedirs(animals_dst_root, exist_ok=True)
shutil.copy2(os.path.join(animals_src_root, "manifest.json"), os.path.join(animals_dst_root, "manifest.json"))
used_variants = set()
for character_slug in used_forge_characters:
# character_slug = "animal-<animal>-<NN>" (voir
# screens/labels/animal_sprite_library.py) ; ignore silencieusement
# tout ce qui ne colle pas à ce format — un personnage Kenney
# (déjà copié ci-dessus) n'a pas ce préfixe.
if not character_slug.startswith("animal-"):
continue
animal, _, nn = character_slug[len("animal-"):].rpartition("-")
if animal:
used_variants.add((animal, nn))
for animal, nn in used_variants:
src = os.path.join(animals_src_root, animal, nn)
if os.path.isdir(src):
shutil.copytree(src, os.path.join(animals_dst_root, animal, nn))
def _copy_static_assets(staging_dir, slug):
static_dir = os.path.join(staging_dir, "static")
used_forge_characters = screens.list_used_forge_characters(slug)
-107
View File
@@ -1,107 +0,0 @@
"""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)
-4
View File
@@ -1,4 +0,0 @@
@echo off
cd /d "%~dp0"
python-embed\python.exe player_app.py
pause
-91
View File
@@ -1,91 +0,0 @@
"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter
un jeu en exécutable autonome : Python portable (embeddable, python.org)
et les paquets pip nécessaires (Flask + ses dépendances), mis en cache
localement sous data/publish_vendor/ (non commité, voir .gitignore —
même dossier que data/secret_key/data/users.db, déjà runtime-only).
La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces
~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les
publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui
reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne
concerne que la machine qui PUBLIE.
Fonctions séparées et mockables (download_python_embeddable/
vendor_flask) pour que les tests ne déclenchent jamais un vrai
téléchargement."""
import os
import subprocess
import sys
import urllib.request
import zipfile
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor")
PYTHON_VERSION = "3.11.9"
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64")
PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs")
# Mêmes versions que requirements.txt — seul Flask est listé explicitement,
# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker)
# suivent automatiquement via pip.
FLASK_SPEC = "Flask==3.0.3"
def download_python_embeddable():
"""Télécharge et extrait le ZIP Python embeddable officiel si absent
du cache — no-op si déjà présent (idempotent, sûr à rappeler à
chaque publication)."""
marker = os.path.join(PYTHON_EMBED_DIR, "python.exe")
if os.path.isfile(marker):
return PYTHON_EMBED_DIR
os.makedirs(PYTHON_EMBED_DIR, exist_ok=True)
zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip")
urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(PYTHON_EMBED_DIR)
os.remove(zip_path)
_enable_pylibs_path(PYTHON_EMBED_DIR)
return PYTHON_EMBED_DIR
def _enable_pylibs_path(python_embed_dir):
"""Le ZIP embeddable désactive site-packages par défaut
(python311._pth ne contient que "python311.zip" et "."). On ajoute une
ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver
tout le mécanisme "site" — les paquets vendorés (Flask...) sont de
simples dossiers purement Python, aucun besoin d'aller plus loin."""
pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")]
if not pth_files:
return
pth_path = os.path.join(python_embed_dir, pth_files[0])
with open(pth_path, encoding="utf-8") as f:
content = f.read()
if "pylibs" not in content:
with open(pth_path, "a", encoding="utf-8") as f:
f.write("\npylibs\n")
def vendor_flask():
"""pip install --target=... : installe Flask (paquets purs Python,
indépendants de la version exacte de l'interpréteur embeddable) dans
le cache, réutilisé par tous les exports suivants — no-op si déjà
fait."""
if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR):
return PYLIBS_DIR
os.makedirs(PYLIBS_DIR, exist_ok=True)
subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC],
check=True,
)
return PYLIBS_DIR
def ensure_vendor_ready():
"""Point d'entrée unique appelé par build_package.py — télécharge/
installe ce qui manque, puis renvoie (dossier_python_embed,
dossier_pylibs)."""
python_dir = download_python_embeddable()
pylibs_dir = vendor_flask()
return python_dir, pylibs_dir