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
-11
View File
@@ -19,20 +19,9 @@ from .flask_app import app
# Endpoints accessibles SANS être connecté — tout le reste exige une # Endpoints accessibles SANS être connecté — tout le reste exige une
# session valide. "static" (CSS/JS/images) doit rester public : la page # session valide. "static" (CSS/JS/images) doit rester public : la page
# de connexion elle-même en a besoin pour s'afficher. # de connexion elle-même en a besoin pour s'afficher.
#
# game_play_public/runtime_payload_public/flow_node_run_data_public/
# flow_node_run_variable_public (routes/public_play/) : la route PUBLIQUE
# /jouer/<slug>, pour un vrai visiteur anonyme (état par joueur, Phase 1)
# — chaque vue vérifie ELLE-MÊME db.is_public_played(slug) (404 sinon),
# cette garde générique ne fait qu'autoriser l'accès sans connexion, elle
# ne dispense d'aucune autre vérification.
_PUBLIC_ENDPOINTS = { _PUBLIC_ENDPOINTS = {
"static", "login", "login_2fa", "register", "register_2fa", "logout", "static", "login", "login_2fa", "register", "register_2fa", "logout",
"forgot_password", "reset_password", "forgot_password", "reset_password",
"game_play_public", "runtime_payload_public",
"flow_node_run_data_public", "flow_node_run_variable_public",
"flow_node_run_score_public", "flow_node_run_status_public",
"flow_node_run_add_row_public",
} }
# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion # Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion
-35
View File
@@ -1,35 +0,0 @@
"""Identité d'un visiteur anonyme de la route publique /jouer/<slug> (voir
routes/public_play/) — état par joueur, Phase 1. Un cookie NON SIGNÉ (pas
besoin de itsdangerous/app.secret_key ici) : c'est une simple clé de
PARTITION des données (quelles lignes/valeurs appartiennent à ce visiteur),
jamais un jeton d'autorisation — la contrefaçon la plus grave possible
serait de se faire passer pour un AUTRE joueur anonyme du même jeu, pas de
franchir une frontière de sécurité (l'aperçu créateur, lui, reste protégé
par core/auth_guard.py, complètement indépendant de ce mécanisme)."""
import secrets
from flask import request
PLAYER_ID_COOKIE = "forge_player_id"
_PLAYER_ID_MAX_AGE = 60 * 60 * 24 * 365 # 1 an
def get_or_create_player_id():
"""Renvoie (player_id, is_new) — is_new indique s'il faut poser le
cookie sur la réponse avant de la renvoyer (voir
set_player_id_cookie ci-dessous) : à utiliser aussi bien depuis une
route qui rend une page (GET /jouer/<slug>) qu'une route API JSON
(runtime-payload, run-data, run-variable), dont la réponse n'est
construite qu'APRÈS avoir déjà besoin du player_id pour agir."""
existing = request.cookies.get(PLAYER_ID_COOKIE)
if existing:
return existing, False
return secrets.token_urlsafe(16), True
def set_player_id_cookie(response, player_id):
response.set_cookie(
PLAYER_ID_COOKIE, player_id,
max_age=_PLAYER_ID_MAX_AGE, httponly=True, samesite="Lax",
)
return response
+1 -3
View File
@@ -29,8 +29,6 @@ from .connection import connect
from .games.list_games import list_games from .games.list_games import list_games
from .games.game_meta import game_meta from .games.game_meta import game_meta
from .games.is_public_played import is_public_played
from .games.set_public_played import set_public_played
from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE
from .games.create_game import create_game from .games.create_game import create_game
from .games.update_game_name import update_game_name from .games.update_game_name import update_game_name
@@ -85,7 +83,7 @@ __all__ = [
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status",
"slugify", "table_name_for", "game_dir", "db_path", "connect", "slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"is_public_played", "set_public_played", "get_game_type", "DEFAULT_GAME_TYPE", "get_game_type", "DEFAULT_GAME_TYPE",
"ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", "ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type",
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted", "get_onboarding_type_raw", "set_onboarding_type", "is_restricted",
"list_definitions", "get_definition", "create_definition", "rename_definition", "list_definitions", "get_definition", "create_definition", "rename_definition",
+3 -7
View File
@@ -3,13 +3,9 @@
import os import os
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que # Surchargeable via FORGE_PROJECTS_DIR (même schéma que
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur # auth/connection.py::FORGE_USERS_DB_PATH). Résolu ici, à l'IMPORT (pas
# joueur autonome (publish/player_app_template.py), qui pose cette # par un appel de fonction) : la variable d'environnement doit donc déjà
# variable AVANT d'importer db/ pour pointer vers son propre dossier # être posée avant le tout premier `import db`.
# "projects/" embarqué plutôt que celui, réel, du poste de développement.
# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable
# d'environnement doit donc déjà être posée avant le tout premier
# `import db`.
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join( PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects" os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
) )
-6
View File
@@ -1,5 +1,4 @@
from ..connection import connect from ..connection import connect
from .is_public_played import is_public_played
from .game_type_catalog import get_onboarding_type from .game_type_catalog import get_onboarding_type
@@ -9,11 +8,6 @@ def game_meta(slug):
conn.close() conn.close()
return { return {
"slug": slug, "name": row["value"] if row else slug, "slug": slug, "name": row["value"] if row else slug,
# État par joueur (Phase 1) : disponible partout où `game` est
# dans le contexte du template (base.html, la bascule "Publier en
# ligne" en a besoin pour afficher son état actuel), sans avoir à
# appeler is_public_played(slug) séparément dans chaque route.
"is_public_played": is_public_played(slug),
# Onboarding guidé (voir game_type_catalog.py) : utilisé par # Onboarding guidé (voir game_type_catalog.py) : utilisé par
# templates/base.html pour cacher le lien "Tableau de bord" à un # templates/base.html pour cacher le lien "Tableau de bord" à un
# compte "restreint" (quiz/embranchement/rpg). # compte "restreint" (quiz/embranchement/rpg).
+1 -1
View File
@@ -10,7 +10,7 @@ accessible ensuite, ou seulement sa version simplifiée (juste les écrans
en cartes — voir routes/games/game_dashboard.py, restricted). en cartes — voir routes/games/game_dashboard.py, restricted).
_meta['onboarding_type'] (une ligne _meta par projet, même convention que _meta['onboarding_type'] (une ligne _meta par projet, même convention que
is_public_played/l'ancien game_type) retient CE choix — jamais lu par le game_type) retient CE choix — jamais lu par le
rendu jouable, seulement par le routage (routes/games/game_dashboard.py, rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py).""" routes/screens/screens_new.py)."""
import db import db
+4 -3
View File
@@ -8,9 +8,10 @@ def get_game_type(slug):
(éditeur générique actuel — quiz/formulaires/contenus, écrans = (éditeur générique actuel — quiz/formulaires/contenus, écrans =
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié — éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
voir screens/scenes/, écrans = objets pixel-positionnés en couches). voir screens/scenes/, écrans = objets pixel-positionnés en couches).
Réutilise _meta comme is_public_played — aucune nouvelle table. Un jeu Stocké dans _meta (une table générique clé/valeur, déjà utilisée pour
créé avant l'existence de cette clé (absente de _meta) retombe sur d'autres réglages de projet) — aucune nouvelle table. Un jeu créé avant
"document", son comportement d'avant, inchangé.""" l'existence de cette clé (absente de _meta) retombe sur "document",
son comportement d'avant, inchangé."""
conn = connect(slug) conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone() row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone()
conn.close() conn.close()
-14
View File
@@ -1,14 +0,0 @@
from ..connection import connect
def is_public_played(slug):
"""Un jeu n'est jouable sur la route publique /jouer/<slug> (voir
routes/public_play/) que si le créateur l'a explicitement basculé
"Publier en ligne" dans son tableau de bord — jamais par défaut, pour
ne pas exposer un jeu en cours de conception. Réutilise la table
générique _meta (voir game_meta.py, déjà utilisée pour 'name'),
aucune nouvelle colonne/table nécessaire."""
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'is_public_played'").fetchone()
conn.close()
return bool(row and row["value"] == "1")
-14
View File
@@ -1,14 +0,0 @@
from ..connection import connect
def set_public_played(slug, value):
"""Bascule "Publier en ligne" (tableau de bord) — voir
is_public_played.py. `value` : bool."""
conn = connect(slug)
conn.execute(
"INSERT INTO _meta (key, value) VALUES ('is_public_played', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("1" if value else "0",),
)
conn.commit()
conn.close()
+2 -2
View File
@@ -1,3 +1,3 @@
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec """publish — empaquette un jeu en export Web/SCORM (voir
serveur interne, voir build_package.py) : le bouton "Publier" de la barre build_scorm_package.py) : le bouton "Exporter (Web/SCORM)" de la barre
de navigation d'un jeu (templates/base.html).""" 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 """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 routes/publish/export_scorm.py) — paquet STATIQUE : index.html porte tout
(qui embarque Python + Flask, voir player_app.py), ce paquet est le runtime jouable directement en mémoire (window.FORGE_OFFLINE, voir
STATIQUE — index.html porte tout le runtime jouable directement en static/js/play/offline/) puisqu'un SCORM tourne SEUL dans le LMS du
mémoire (window.FORGE_OFFLINE, voir static/js/play/offline/) puisqu'un client, sans serveur Forge Engine disponible."""
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."""
import base64 import base64
import json import json
import os import os
@@ -19,12 +15,65 @@ from flask import render_template
import db import db
import screens import screens
from .build_package import _copy_characters, _STATIC_ITEMS
from .scorm_manifest import build_manifest_xml from .scorm_manifest import build_manifest_xml
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__)) _PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR) _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): def _copy_static_assets(staging_dir, slug):
static_dir = os.path.join(staging_dir, "static") static_dir = os.path.join(staging_dir, "static")
used_forge_characters = screens.list_used_forge_characters(slug) 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
+1 -1
View File
@@ -1 +1 @@
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes, onboarding from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding
+1 -1
View File
@@ -1 +1 @@
from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played, game_scoring_view from . import index, games_new, game_dashboard, game_rename, game_delete, game_scoring_view
-16
View File
@@ -1,16 +0,0 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/toggle-public-played", methods=["POST"])
def game_toggle_public_played(slug):
"""Bascule "Publier en ligne" (voir db/games/is_public_played.py) —
active/désactive la route publique /jouer/<slug> (routes/public_play/),
jouable par n'importe quel visiteur anonyme avec sa propre partie
(état par joueur, Phase 1). Distinct de "Publier" (export .zip
exécutable mono-joueur, voir routes/publish/publish_game.py)."""
db.set_public_played(slug, request.form.get("value") == "1")
return redirect(request.referrer or url_for("game_dashboard", slug=slug))
-9
View File
@@ -1,9 +0,0 @@
from . import (
game_play_public,
runtime_payload_public,
flow_node_run_data_public,
flow_node_run_variable_public,
flow_node_run_score_public,
flow_node_run_status_public,
flow_node_run_add_row_public,
)
@@ -1,25 +0,0 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-add-row", methods=["POST"])
def flow_node_run_add_row_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_add_row.py (aperçu
créateur) — voir routes/public_play/game_play_public.py pour le
principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "ajouter_ligne":
return jsonify({"ok": False}), 400
row_id = screens.apply_add_row_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": row_id is not None, "row_id": row_id}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -1,33 +0,0 @@
from flask import jsonify, request, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
def flow_node_run_data_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_data.py (aperçu créateur)
— voir routes/public_play/game_play_public.py pour le principe
général. `player_id` (le cookie du visiteur) est passé à
apply_data_action pour qu'un objet per_player agisse sur SA ligne."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_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")}
elif node.get("target_row_id") == screens.LAST_INSERTED_ROW_ID:
# Phase 4 — voir routes/flow/flow_node_run_data.py.
body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("last_inserted_row_id")}
ok = screens.apply_data_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -1,25 +0,0 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-score", methods=["POST"])
def flow_node_run_score_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_score.py (aperçu
créateur) — voir routes/public_play/game_play_public.py pour le
principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_score":
return jsonify({"ok": False}), 400
ok = screens.apply_score_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -1,25 +0,0 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-status", methods=["POST"])
def flow_node_run_status_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_status.py (aperçu
créateur) — voir routes/public_play/game_play_public.py pour le
principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "definir_statut_partie":
return jsonify({"ok": False}), 400
ok = screens.apply_status_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -1,25 +0,0 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_variable.py (aperçu
créateur) — voir routes/public_play/game_play_public.py pour le
principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_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, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
-40
View File
@@ -1,40 +0,0 @@
import json
from flask import render_template, url_for, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>")
def game_play_public(slug):
"""Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) —
distincte de /game/<slug>/play (aperçu créateur, connecté, INCHANGÉE) :
un vrai visiteur anonyme, identifié par un cookie (voir
core/player_identity.py), avec sa propre partie (variables/objets
per_player, voir db/global_vars/, db/rows/). 404 si le créateur n'a
pas explicitement basculé "Publier en ligne" dans son tableau de bord
— jamais exposé par défaut."""
if not db.is_public_played(slug):
abort(404)
game = db.game_meta(slug)
player_id, is_new = get_or_create_player_id()
payload = screens.full_game_payload(slug, player_id)
resp = app.make_response(
render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
asset_url=lambda filename: url_for("static", filename=filename),
runtime_payload_url=url_for("runtime_payload_public", slug=slug),
flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999),
flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999),
flow_node_run_score_url=url_for("flow_node_run_score_public", slug=slug, node_id=999999),
flow_node_run_status_url=url_for("flow_node_run_status_public", slug=slug, node_id=999999),
flow_node_run_add_row_url=url_for("flow_node_run_add_row_public", slug=slug, node_id=999999),
)
)
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -1,20 +0,0 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/runtime-payload")
def runtime_payload_public(slug):
"""Miroir public de routes/flow/runtime_payload.py (aperçu créateur) —
voir routes/public_play/game_play_public.py pour le principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
resp = app.make_response(jsonify(screens.full_game_payload(slug, player_id)))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
+1 -1
View File
@@ -1 +1 @@
from . import publish_game, export_scorm from . import export_scorm
-28
View File
@@ -1,28 +0,0 @@
import os
from flask import after_this_request, send_file
import db
from publish.build_package import build_game_zip
from core.flask_app import app
@app.route("/game/<slug>/publish", methods=["POST"])
def publish_game(slug):
"""Construit et renvoie le zip exécutable autonome du jeu (voir
publish/build_package.py) — protégée par la même garde d'accès que
toutes les autres routes /game/<slug>/... (core/auth_guard.py),
aucune vérification supplémentaire nécessaire ici."""
zip_path, game_name = build_game_zip(slug)
@after_this_request
def _cleanup(response):
try:
os.remove(zip_path)
except OSError:
pass
return response
download_name = f"{db.slugify(game_name)}.zip"
return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip")
+1 -1
View File
@@ -24,7 +24,7 @@ def _load_manifest():
except FileNotFoundError: except FileNotFoundError:
# Poste de dev sans les assets animaux copiés (script pas encore # Poste de dev sans les assets animaux copiés (script pas encore
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun # lancé), ou paquet exporté pour un jeu qui n'utilise aucun
# personnage animal (voir publish/build_package.py, copie # personnage animal (voir publish/build_scorm_package.py, copie
# sélective) : aucun personnage animal disponible, pas une erreur. # sélective) : aucun personnage animal disponible, pas une erreur.
return {} return {}
+14 -13
View File
@@ -14,20 +14,21 @@ from ..scenes.render_scene_object import render_scene_object
def full_game_payload(slug, player_id=db.PLAYER_SHARED): def full_game_payload(slug, player_id=db.PLAYER_SHARED):
"""Toutes les données nécessaires au runtime JS de la page de jeu jouable """Toutes les données nécessaires au runtime JS de la page de jeu jouable
(/game/<slug>/play, ou /jouer/<slug> — voir routes/public_play/) : chaque (/game/<slug>/play, ou le paquet Web/SCORM exporté — voir
écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de publish/build_scorm_package.py) : chaque écran avec ses éléments (HTML
données, lus en direct), le graphe de logique (nœuds + fils) de chaque déjà généré, y compris les Répéteurs de données, lus en direct), le
écran, et un instantané des données du jeu (avec le type de chaque graphe de logique (nœuds + fils) de chaque écran, et un instantané des
champ, nécessaire pour évaluer une condition correctement) — un seul données du jeu (avec le type de chaque champ, nécessaire pour évaluer
aller-retour serveur, ensuite tout se joue côté client (aucune une condition correctement) — un seul aller-retour serveur, ensuite
navigation ne recharge la page, sauf pour appliquer une action "modifier tout se joue côté client (aucune navigation ne recharge la page, sauf
une donnée", qui doit passer par le serveur). pour appliquer une action "modifier une donnée", qui doit passer par
le serveur).
player_id (état par joueur, Phase 1) : PLAYER_SHARED pour l'aperçu player_id (état par joueur, Phase 1) : toujours PLAYER_SHARED
créateur (comportement inchangé — une seule partie partagée pendant la aujourd'hui (une seule partie partagée, pour la conception comme pour
conception), l'identifiant réel du visiteur pour une partie publique un paquet exporté) — le paramètre reste générique pour tout objet/
(voir core/player_identity.py) — chaque objet/variable per_player variable per_player (voir db/global_vars/, db/rows/), qui renvoie
renvoie alors la valeur propre à CE joueur.""" alors la valeur propre à CE joueur."""
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# écran "jeu_2d" construit "elements" depuis _scene_objects (objets de # écran "jeu_2d" construit "elements" depuis _scene_objects (objets de
# scène en pixels) plutôt que _screen_elements (DOM en %) — décidé # scène en pixels) plutôt que _screen_elements (DOM en %) — décidé
@@ -9,7 +9,7 @@ def list_used_forge_characters(slug):
réellement référencés par CE jeu — tous écrans, écrans-modèles compris réellement référencés par CE jeu — tous écrans, écrans-modèles compris
(même patron que full_game_payload.py : un personnage peut vivre dans (même patron que full_game_payload.py : un personnage peut vivre dans
un écran-modèle d'élément réutilisable). Utilisé par un écran-modèle d'élément réutilisable). Utilisé par
publish/build_package.py pour ne copier dans le .zip exporté que les publish/build_scorm_package.py pour ne copier dans le .zip exporté que les
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
(voir le plan "Bibliothèque de sprites animaux CraftPix").""" (voir le plan "Bibliothèque de sprites animaux CraftPix")."""
+10 -99
View File
@@ -54,26 +54,14 @@
(quiz/embranchement/rpg), la version complète pour "custom". --> (quiz/embranchement/rpg), la version complète pour "custom". -->
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a> <a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a> <a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button> <!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet
<!-- Phase 1.2 (feuille de route produit) : paquet STATIQUE (aucun STATIQUE (aucun Python/Flask embarqué, voir
Python/Flask embarqué, voir publish/build_scorm_package.py), pensé publish/build_scorm_package.py), pensé pour un import dans le LMS
pour un import dans le LMS du client (Moodle...) — distinct du du client (Moodle...). L'export exécutable Windows ("Publier",
zip exécutable ci-dessus, qui a besoin de Windows + double-clic. --> publish/build_package.py) et l'export en ligne (route publique
/jouer/<slug>, routes/public_play/) ont été retirés, jugés
redondants avec celui-ci. -->
<button type="button" class="gameNavLink" onclick="window.forgeOpenScormModal && window.forgeOpenScormModal()" title="Exporte le jeu en paquet Web/SCORM importable dans un LMS">🎓 Exporter (Web/SCORM)</button> <button type="button" class="gameNavLink" onclick="window.forgeOpenScormModal && window.forgeOpenScormModal()" title="Exporte le jeu en paquet Web/SCORM importable dans un LMS">🎓 Exporter (Web/SCORM)</button>
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
"Publier" ci-dessus (export .zip mono-joueur) — active/désactive la
route publique /jouer/<slug>, jouable par n'importe quel visiteur
anonyme avec SA PROPRE partie (voir routes/public_play/,
db/games/is_public_played.py). -->
<form method="post" action="{{ url_for('game_toggle_public_played', slug=game.slug) }}" style="display:inline;">
<input type="hidden" name="value" value="{{ '0' if game.is_public_played else '1' }}">
<button type="submit" class="gameNavLink" title="{{ 'Rendre le jeu privé (désactive /jouer)' if game.is_public_played else 'Rendre le jeu jouable par tous, chacun avec sa propre partie' }}">
{{ '🌐 En ligne ✓' if game.is_public_played else '🌐 Publier en ligne' }}
</button>
</form>
{% if game.is_public_played %}
<a href="{{ url_for('game_play_public', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Ouvre la partie publique, comme un vrai visiteur">🔗 Lien public</a>
{% endif %}
</nav> </nav>
{% endif %} {% endif %}
</div> </div>
@@ -107,89 +95,12 @@
<!-- Même remarque que pour le modal des codes de récupération ci-dessus : à L'INTÉRIEUR <!-- Même remarque que pour le modal des codes de récupération ci-dessus : à L'INTÉRIEUR
de <main> pour que pjax.js le remplace (et rejoue son script JS, voir de <main> pour que pjax.js le remplace (et rejoue son script JS, voir
runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé
dans #pageChrome (comme le bouton "📦 Publier" qui l'ouvre), son dans #pageChrome (comme le bouton "🎓 Exporter (Web/SCORM)" qui
script ne serait rejoué qu'au tout premier chargement complet de la l'ouvre), son script ne serait rejoué qu'au tout premier chargement
page. --> complet de la page. -->
<div class="modal" id="publishModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Publier « {{ game.name }} »</h2>
<p class="hint mb-4">Construit un exécutable Windows autonome (Python + serveur embarqués) : dézippé puis lancé en double-cliquant sur <code>run.bat</code>, le jeu se joue hors connexion, sans rien installer.</p>
<div id="publishProgress">
<p class="hint" id="publishStepText">Préparation du jeu...</p>
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="publishProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
</div>
<div id="publishError" class="notification is-danger" style="display:none;"></div>
<div id="publishDone" class="notification is-success" style="display:none;">✅ Téléchargement lancé — dézippe le fichier puis double-clique sur <code>run.bat</code>.</div>
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
<button type="button" class="button is-small" onclick="window.forgeClosePublishModal()">Fermer</button>
</div>
</div>
</div>
</div>
<style> <style>
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } } @keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
</style> </style>
<script>
(function () {
var modal = document.getElementById('publishModal');
var stepText = document.getElementById('publishStepText');
var progressBox = document.getElementById('publishProgress');
var errorBox = document.getElementById('publishError');
var doneBox = document.getElementById('publishDone');
var steps = ["Préparation du jeu...", "Copie des données...", "Compression du dossier..."];
var stepTimer = null;
window.forgeOpenPublishModal = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
modal.classList.add('is-active');
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
stepText.textContent = steps[stepIndex];
}, 1200);
var filename = "{{ game.slug }}.zip";
fetch("{{ url_for('publish_game', slug=game.slug) }}", { method: 'POST' })
.then(function (resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var disposition = resp.headers.get('Content-Disposition') || '';
var match = /filename="?([^";]+)"?/.exec(disposition);
if (match) filename = match[1];
return resp.blob();
})
.then(function (blob) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
doneBox.style.display = '';
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
})
.catch(function (err) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
errorBox.style.display = '';
errorBox.textContent = "Échec de la publication (" + err.message + "). Réessaie, ou vérifie ta connexion internet (nécessaire uniquement lors de la toute première publication sur ce poste).";
});
};
window.forgeClosePublishModal = function () {
modal.classList.remove('is-active');
if (stepTimer) clearInterval(stepTimer);
};
})();
</script>
<div class="modal" id="scormModal"> <div class="modal" id="scormModal">
<div class="modal-background"></div> <div class="modal-background"></div>
<div class="modal-content"> <div class="modal-content">
-22
View File
@@ -99,25 +99,3 @@ def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(clie
assert row["objet"] == "épée" assert row["objet"] == "épée"
def test_new_row_is_scoped_to_the_acting_player(client, game):
"""Un objet per_player (par défaut) : la ligne créée appartient au
joueur qui a agi, pas partagée avec les autres — voir db/rows/
resolve_row_player_key.py."""
from conftest import flask_app
definition_id = _create_inventory_object(client, game)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="ajouter_ligne", target_definition_id=definition_id,
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}")
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-add-row")
assert resp.get_json()["ok"] is True
definition = db.get_definition(game, definition_id)
assert db.list_rows(game, definition, player_id="__shared__") == []
bob_rows = db.list_rows(game, definition, player_id="quelquun-dautre")
assert bob_rows == []
+5 -4
View File
@@ -140,13 +140,14 @@ def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp
assert used == {"animal-bunny-01"} assert used == {"animal-bunny-01"}
def test_build_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path): def test_build_scorm_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_publish_animals")) slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_scorm_animals"))
screen_id = screens.create_screen(slug, "Scène 1") screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01") screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
from publish import build_package with client.application.test_request_context():
zip_path, _name = build_package.build_game_zip(slug) from publish import build_scorm_package
zip_path, _name = build_scorm_package.build_scorm_zip(slug)
try: try:
import zipfile import zipfile
with zipfile.ZipFile(zip_path) as zf: with zipfile.ZipFile(zip_path) as zf:
-116
View File
@@ -1,116 +0,0 @@
"""Tests de la route publique /jouer/<slug> (routes/public_play/) — état
par joueur, Phase 1. Contrairement aux autres tests, ceux-ci utilisent
DIRECTEMENT flask_app.test_client() (pas la fixture `client`) pour de
vrais visiteurs anonymes, jamais connectés — le but est justement de
vérifier que core/auth_guard.py laisse passer ces routes sans session."""
import json
import re
import db
from conftest import flask_app
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_public_route_404_until_explicitly_published(client, game):
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
client.post(f"/game/{game}/toggle-public-played", data={"value": "0"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
def test_public_route_works_without_any_login_session(client, game):
"""La garde globale (core/auth_guard.py) ne doit PAS exiger de session
pour cette route — c'est tout le sens de "publique"."""
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
with anon.session_transaction() as sess:
assert "user_id" not in sess
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
assert b"CSRF_TOKEN" in resp.data or b"gameData" in resp.data
def test_public_route_sets_player_id_cookie(client, game):
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert "forge_player_id" in resp.headers.get("Set-Cookie", "")
def test_two_anonymous_players_get_independent_variable_state(client, game):
"""Bout en bout, via HTTP : deux visiteurs anonymes (deux clients de
test SÉPARÉS, donc deux cookies forge_player_id différents) sur le
même jeu publié -> chacun sa propre valeur pour une variable
per_player, modifiée via l'action "Modifier une variable" (le vrai
chemin d'exécution du mode jouable, routes/public_play/
flow_node_run_variable_public.py)."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}") # pose le cookie
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
bob = flask_app.test_client()
bob.get(f"/jouer/{game}")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
assert db.get_global_variable(game, "score", player_id="__shared__")["value"] == "0"
alice_payload = alice.get(f"/jouer/{game}/runtime-payload").get_json()
bob_payload = bob.get(f"/jouer/{game}/runtime-payload").get_json()
assert alice_payload["variables"]["score"]["value"] == "10"
assert bob_payload["variables"]["score"]["value"] == "20"
def test_creator_preview_unaffected_by_public_players(client, game):
"""L'aperçu créateur (/game/<slug>/play, connecté) doit continuer de
voir/modifier PLAYER_SHARED, jamais l'état d'un joueur public."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}")
alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = client.get(f"/game/{game}/play")
assert resp.status_code == 200
creator_payload = client.get(f"/game/{game}/runtime-payload").get_json()
assert creator_payload["variables"]["score"]["value"] == "0"
-92
View File
@@ -1,92 +0,0 @@
"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
routes/publish/publish_game.py et publish/build_package.py). Le vrai
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
(fichiers présents, structure), jamais un vrai lancement du serveur
embarqué (nécessite un environnement Windows réel, voir le plan)."""
import os
import zipfile
import pytest
import publish.build_package as build_package
from tests.test_auth import anon_client # noqa: F401
@pytest.fixture
def fake_vendor(tmp_path, monkeypatch):
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
python_dir = tmp_path / "python-embed-amd64"
python_dir.mkdir()
(python_dir / "python.exe").write_bytes(b"fake-exe")
pylibs_dir = tmp_path / "pylibs"
pylibs_dir.mkdir()
(pylibs_dir / "flask").mkdir()
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
def _fake_ensure_vendor_ready():
return str(python_dir), str(pylibs_dir)
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
return python_dir, pylibs_dir
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
zip_path, game_name = build_package.build_game_zip(game)
try:
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
for expected in [
"run.bat", "player_app.py",
f"projects/{game}/game.db",
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
"templates/play.html",
"static/style.css", "static/csrf_fetch.js",
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
]:
assert expected in names, f"{expected} manquant du zip"
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
zip_path, _ = build_package.build_game_zip(game)
try:
with zipfile.ZipFile(zip_path) as zf:
player_app_src = zf.read("player_app.py").decode("utf-8")
assert f'SLUG = "{game}"' in player_app_src
assert "_SLUG_PLACEHOLDER" not in player_app_src
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
resp = client.post(f"/game/{game}/publish")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK" # signature d'un fichier zip
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
le projet d'un autre (même vérification que /game/<slug> lui-même,
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "publishisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
assert resp.status_code == 403
finally:
_cleanup_project("publishisolation@example.com")
+12 -37
View File
@@ -7,8 +7,6 @@ import json
import db import db
import screens import screens
from conftest import flask_app
def test_get_score_defaults_before_any_write(game): def test_get_score_defaults_before_any_write(game):
assert db.get_score(game) == {"player_id": db.PLAYER_SHARED, "score": 0, "status": "non_commence"} assert db.get_score(game) == {"player_id": db.PLAYER_SHARED, "score": 0, "status": "non_commence"}
@@ -104,43 +102,20 @@ def test_game_scoring_view_route_reads_player_score(client, game):
} }
def test_two_anonymous_players_get_independent_scores(client, game): def test_two_players_get_independent_scores(game):
"""Bout en bout via HTTP, même patron que """Le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé
test_public_play.py::test_two_anonymous_players_get_independent_variable_state — même garantie que db/global_vars (voir tests/test_player_state.py),
— le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé.""" vérifiée ici directement au niveau db/ (routes publiques retirées)."""
screen_id = screens.create_screen(game, "Accueil") db.set_score_value(game, 10, player_id="alice")
node_id = client.post( db.set_status(game, "reussi", player_id="alice")
f"/game/{game}/screens/{screen_id}/flow/nodes/add", db.set_score_value(game, 20, player_id="bob")
data=json.dumps({"node_type": "action", "action_type": "modifier_score", "data_operation": "incrementer", "data_value": "10"}),
content_type="application/json",
).get_json()["id"]
status_node_id = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "definir_statut_partie", "data_value": "reussi"}),
content_type="application/json",
).get_json()["id"]
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client() assert db.get_score(game, "alice") == {
alice_landing = alice.get(f"/jouer/{game}") "player_id": "alice", "score": 10, "status": "reussi",
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score") "updated_at": db.get_score(game, "alice")["updated_at"],
assert resp.get_json()["ok"] is True
alice.post(f"/jouer/{game}/flow/nodes/{status_node_id}/run-status")
bob = flask_app.test_client()
bob_landing = bob.get(f"/jouer/{game}")
bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
alice_player_id = alice_landing.headers["Set-Cookie"].split("forge_player_id=", 1)[1].split(";", 1)[0]
bob_player_id = bob_landing.headers["Set-Cookie"].split("forge_player_id=", 1)[1].split(";", 1)[0]
assert db.get_score(game, alice_player_id) == {
"player_id": alice_player_id, "score": 10, "status": "reussi",
"updated_at": db.get_score(game, alice_player_id)["updated_at"],
} }
assert db.get_score(game, bob_player_id)["score"] == 20 assert db.get_score(game, "bob")["score"] == 20
assert db.get_score(game, bob_player_id)["status"] == "non_commence" assert db.get_score(game, "bob")["status"] == "non_commence"
assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé