Dev #10
@@ -19,20 +19,9 @@ from .flask_app import app
|
||||
# Endpoints accessibles SANS être connecté — tout le reste exige une
|
||||
# session valide. "static" (CSS/JS/images) doit rester public : la page
|
||||
# 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 = {
|
||||
"static", "login", "login_2fa", "register", "register_2fa", "logout",
|
||||
"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
|
||||
|
||||
@@ -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
@@ -29,8 +29,6 @@ from .connection import connect
|
||||
|
||||
from .games.list_games import list_games
|
||||
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.create_game import create_game
|
||||
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",
|
||||
"slugify", "table_name_for", "game_dir", "db_path", "connect",
|
||||
"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",
|
||||
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted",
|
||||
"list_definitions", "get_definition", "create_definition", "rename_definition",
|
||||
|
||||
+3
-7
@@ -3,13 +3,9 @@
|
||||
import os
|
||||
|
||||
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que
|
||||
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur
|
||||
# joueur autonome (publish/player_app_template.py), qui pose cette
|
||||
# variable AVANT d'importer db/ pour pointer vers son propre dossier
|
||||
# "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`.
|
||||
# auth/connection.py::FORGE_USERS_DB_PATH). 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(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from ..connection import connect
|
||||
from .is_public_played import is_public_played
|
||||
from .game_type_catalog import get_onboarding_type
|
||||
|
||||
|
||||
@@ -9,11 +8,6 @@ def game_meta(slug):
|
||||
conn.close()
|
||||
return {
|
||||
"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
|
||||
# templates/base.html pour cacher le lien "Tableau de bord" à un
|
||||
# compte "restreint" (quiz/embranchement/rpg).
|
||||
|
||||
@@ -10,7 +10,7 @@ accessible ensuite, ou seulement sa version simplifiée (juste les écrans
|
||||
en cartes — voir routes/games/game_dashboard.py, restricted).
|
||||
|
||||
_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,
|
||||
routes/screens/screens_new.py)."""
|
||||
import db
|
||||
|
||||
@@ -8,9 +8,10 @@ def get_game_type(slug):
|
||||
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
|
||||
é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).
|
||||
Réutilise _meta comme is_public_played — aucune nouvelle table. Un jeu
|
||||
créé avant l'existence de cette clé (absente de _meta) retombe sur
|
||||
"document", son comportement d'avant, inchangé."""
|
||||
Stocké dans _meta (une table générique clé/valeur, déjà utilisée pour
|
||||
d'autres réglages de projet) — aucune nouvelle table. Un jeu créé avant
|
||||
l'existence de cette clé (absente de _meta) retombe sur "document",
|
||||
son comportement d'avant, inchangé."""
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone()
|
||||
conn.close()
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python-embed\python.exe player_app.py
|
||||
pause
|
||||
@@ -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
@@ -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 @@
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
from . import publish_game, export_scorm
|
||||
from . import export_scorm
|
||||
|
||||
@@ -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")
|
||||
@@ -24,7 +24,7 @@ def _load_manifest():
|
||||
except FileNotFoundError:
|
||||
# Poste de dev sans les assets animaux copiés (script pas encore
|
||||
# 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.
|
||||
return {}
|
||||
|
||||
|
||||
@@ -14,20 +14,21 @@ from ..scenes.render_scene_object import render_scene_object
|
||||
|
||||
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
|
||||
(/game/<slug>/play, ou /jouer/<slug> — voir routes/public_play/) : chaque
|
||||
écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de
|
||||
données, lus en direct), le graphe de logique (nœuds + fils) de chaque
|
||||
écran, et un instantané des données du jeu (avec le type de chaque
|
||||
champ, nécessaire pour évaluer une condition correctement) — un seul
|
||||
aller-retour serveur, ensuite tout se joue côté client (aucune
|
||||
navigation ne recharge la page, sauf pour appliquer une action "modifier
|
||||
une donnée", qui doit passer par le serveur).
|
||||
(/game/<slug>/play, ou le paquet Web/SCORM exporté — voir
|
||||
publish/build_scorm_package.py) : chaque écran avec ses éléments (HTML
|
||||
déjà généré, y compris les Répéteurs de données, lus en direct), le
|
||||
graphe de logique (nœuds + fils) de chaque écran, et un instantané des
|
||||
données du jeu (avec le type de chaque champ, nécessaire pour évaluer
|
||||
une condition correctement) — un seul aller-retour serveur, ensuite
|
||||
tout se joue côté client (aucune navigation ne recharge la page, sauf
|
||||
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
|
||||
créateur (comportement inchangé — une seule partie partagée pendant la
|
||||
conception), l'identifiant réel du visiteur pour une partie publique
|
||||
(voir core/player_identity.py) — chaque objet/variable per_player
|
||||
renvoie alors la valeur propre à CE joueur."""
|
||||
player_id (état par joueur, Phase 1) : toujours PLAYER_SHARED
|
||||
aujourd'hui (une seule partie partagée, pour la conception comme pour
|
||||
un paquet exporté) — le paramètre reste générique pour tout objet/
|
||||
variable per_player (voir db/global_vars/, db/rows/), qui renvoie
|
||||
alors la valeur propre à CE joueur."""
|
||||
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
|
||||
# écran "jeu_2d" construit "elements" depuis _scene_objects (objets de
|
||||
# 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
|
||||
(même patron que full_game_payload.py : un personnage peut vivre dans
|
||||
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
|
||||
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
|
||||
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
|
||||
|
||||
+10
-99
@@ -54,26 +54,14 @@
|
||||
(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_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) : paquet STATIQUE (aucun
|
||||
Python/Flask embarqué, voir publish/build_scorm_package.py), pensé
|
||||
pour un import dans le LMS du client (Moodle...) — distinct du
|
||||
zip exécutable ci-dessus, qui a besoin de Windows + double-clic. -->
|
||||
<!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet
|
||||
STATIQUE (aucun Python/Flask embarqué, voir
|
||||
publish/build_scorm_package.py), pensé pour un import dans le LMS
|
||||
du client (Moodle...). L'export exécutable Windows ("Publier",
|
||||
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>
|
||||
<!-- "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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -107,89 +95,12 @@
|
||||
<!-- 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
|
||||
runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé
|
||||
dans #pageChrome (comme le bouton "📦 Publier" qui l'ouvre), son
|
||||
script ne serait rejoué qu'au tout premier chargement 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>
|
||||
dans #pageChrome (comme le bouton "🎓 Exporter (Web/SCORM)" qui
|
||||
l'ouvre), son script ne serait rejoué qu'au tout premier chargement
|
||||
complet de la page. -->
|
||||
<style>
|
||||
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
</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-background"></div>
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -99,25 +99,3 @@ def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(clie
|
||||
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 == []
|
||||
|
||||
@@ -140,13 +140,14 @@ def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp
|
||||
assert used == {"animal-bunny-01"}
|
||||
|
||||
|
||||
def test_build_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"))
|
||||
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_scorm_animals"))
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
|
||||
|
||||
from publish import build_package
|
||||
zip_path, _name = build_package.build_game_zip(slug)
|
||||
with client.application.test_request_context():
|
||||
from publish import build_scorm_package
|
||||
zip_path, _name = build_scorm_package.build_scorm_zip(slug)
|
||||
try:
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -7,8 +7,6 @@ import json
|
||||
import db
|
||||
import screens
|
||||
|
||||
from conftest import flask_app
|
||||
|
||||
|
||||
def test_get_score_defaults_before_any_write(game):
|
||||
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):
|
||||
"""Bout en bout via HTTP, même patron que
|
||||
test_public_play.py::test_two_anonymous_players_get_independent_variable_state
|
||||
— le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé."""
|
||||
screen_id = screens.create_screen(game, "Accueil")
|
||||
node_id = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
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"})
|
||||
def test_two_players_get_independent_scores(game):
|
||||
"""Le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé
|
||||
— même garantie que db/global_vars (voir tests/test_player_state.py),
|
||||
vérifiée ici directement au niveau db/ (routes publiques retirées)."""
|
||||
db.set_score_value(game, 10, player_id="alice")
|
||||
db.set_status(game, "reussi", player_id="alice")
|
||||
db.set_score_value(game, 20, player_id="bob")
|
||||
|
||||
alice = flask_app.test_client()
|
||||
alice_landing = alice.get(f"/jouer/{game}")
|
||||
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
|
||||
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, "alice") == {
|
||||
"player_id": "alice", "score": 10, "status": "reussi",
|
||||
"updated_at": db.get_score(game, "alice")["updated_at"],
|
||||
}
|
||||
assert db.get_score(game, bob_player_id)["score"] == 20
|
||||
assert db.get_score(game, bob_player_id)["status"] == "non_commence"
|
||||
assert db.get_score(game, "bob")["score"] == 20
|
||||
assert db.get_score(game, "bob")["status"] == "non_commence"
|
||||
assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user