Files
Forge-Engine/publish/build_package.py
T
williamandClaude Sonnet 5 dbada333d5 Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS
templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:59:11 +02:00

114 lines
4.7 KiB
Python

"""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
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"]
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
def _copy_engine_sources(staging_dir):
"""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")
for item in _STATIC_ITEMS:
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)
_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"]