Onboarding guidé systématique + tableau de bord simplifié
Build and deploy / test-python (push) Failing after 1m48s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Comportement SYSTÉMATIQUE à chaque création de jeu (admin compris), pas
une formalité réservée à l'inscription : /onboarding (routes/onboarding/)
devient le point d'entrée unique — 4 cartes retournables (survol =
explication au dos), défilement horizontal animé vers le nom du jeu. Un
admin y repasse à volonté (pas de project_slug dédié, jamais bloqué/
redirigé vers un projet précédent) ; un compte "user" n'en a plus qu'un
créé d'office (routes/auth/register_2fa.py), guidé ici à la place.

- db/games/game_type_catalog.py : catalogue des 4 types (Quiz/
  Embranchement-escape game/RPG/Créer mon jeu de A à Z), _meta['onboarding_type']
  décide du "kind" du premier écran créé et si le tableau de bord complet
  reste accessible.
- routes/games/game_dashboard.py, templates/game_dashboard_simple.html :
  un type restreint (quiz/embranchement/rpg) voit désormais SON tableau
  de bord (même route que "custom"), rendu en version simplifiée — juste
  ses écrans en cartes avec un aperçu RÉEL du contenu (scène mise à
  l'échelle par container query CSS, adaptée à la largeur réelle de la
  carte). "+ Ajouter un écran" n'y propose pas de choix de type : imposé
  par le projet (routes/screens/screens_new.py), verrouillé aussi côté
  serveur.
- core/auth_guard.py : plus de blocage de game_dashboard par type — la
  restriction se fait au rendu, pas à l'accès à la route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 06:22:45 +02:00
co-authored by Claude Sonnet 5
parent 0f23024882
commit 76a50fa87a
23 changed files with 876 additions and 52 deletions
+24
View File
@@ -31,9 +31,20 @@ _PUBLIC_ENDPOINTS = {
"forgot_password", "reset_password", "forgot_password", "reset_password",
"game_play_public", "runtime_payload_public", "game_play_public", "runtime_payload_public",
"flow_node_run_data_public", "flow_node_run_variable_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", "flow_node_run_add_row_public",
} }
# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion
# requise, mais indépendants d'avoir un projet ou non (voir onboarding
# guidé ci-dessous) : un compte pas encore passé par /onboarding doit
# quand même pouvoir changer son mot de passe, son email, ou supprimer
# son compte.
_REACHABLE_WITHOUT_PROJECT = {
"onboarding_new", "profile", "profile_update_name", "profile_update_email",
"profile_regenerate_recovery_codes", "profile_change_password", "profile_delete",
}
@app.before_request @app.before_request
def _require_login_and_enforce_project_isolation(): def _require_login_and_enforce_project_isolation():
@@ -61,9 +72,22 @@ def _require_login_and_enforce_project_isolation():
if user["role"] != "admin": if user["role"] != "admin":
if endpoint in ("index", "games_new") and user.get("project_slug"): if endpoint in ("index", "games_new") and user.get("project_slug"):
return redirect(url_for("game_dashboard", slug=user["project_slug"])) return redirect(url_for("game_dashboard", slug=user["project_slug"]))
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un
# compte "user" fraîchement inscrit n'a plus de projet créé
# d'office (voir routes/auth/register_2fa.py) — tant qu'il n'en a
# pas, seules ces routes restent atteignables (toutes exigent déjà
# une connexion, absentes de _PUBLIC_ENDPOINTS).
if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT:
return redirect(url_for("onboarding_new"))
slug = request.view_args.get("slug") if request.view_args else None slug = request.view_args.get("slug") if request.view_args else None
if slug is not None and slug != user.get("project_slug"): if slug is not None and slug != user.get("project_slug"):
abort(403) abort(403)
# Type d'onboarding "restreint" (quiz/embranchement/rpg, voir
# db/games/game_type_catalog.py) : game_dashboard reste atteignable
# comme pour "custom", mais routes/games/game_dashboard.py y rend
# une version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/
# Éléments de jeu/Variables) — rien à bloquer ici, la restriction
# se fait au niveau du rendu, pas de l'accès à la route.
if endpoint == "game_delete": if endpoint == "game_delete":
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en # Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
# recréer un une fois supprimé (games_new le renvoie toujours # recréer un une fois supprimé (games_new le renvoie toujours
+13 -1
View File
@@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
vrai INSERT dans cette table. vrai INSERT dans cette table.
""" """
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
from .slugify import slugify from .slugify import slugify
from .table_name_for import table_name_for from .table_name_for import table_name_for
from .game_dir import game_dir from .game_dir import game_dir
@@ -36,6 +36,11 @@ 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
from .games.delete_game import delete_game from .games.delete_game import delete_game
from .games.move_game import move_game from .games.move_game import move_game
from .games.game_type_catalog import (
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
is_restricted,
)
from .definitions.list_definitions import list_definitions from .definitions.list_definitions import list_definitions
from .definitions.get_definition import get_definition from .definitions.get_definition import get_definition
@@ -66,6 +71,10 @@ from .global_vars.update_global_variable import update_global_variable
from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
from .scoring.get_score import get_score
from .scoring.set_score_value import set_score_value
from .scoring.set_status import set_status
from .custom_events.list_custom_events import list_custom_events from .custom_events.list_custom_events import list_custom_events
from .custom_events.get_custom_event import get_custom_event from .custom_events.get_custom_event import get_custom_event
from .custom_events.create_custom_event import create_custom_event from .custom_events.create_custom_event import create_custom_event
@@ -73,9 +82,12 @@ from .custom_events.update_custom_event import update_custom_event
__all__ = [ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
"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", "is_public_played", "set_public_played", "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", "list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition", "delete_definition",
+8 -5
View File
@@ -1,6 +1,6 @@
from ..connection import connect from ..connection import connect
from .is_public_played import is_public_played from .is_public_played import is_public_played
from .get_game_type import get_game_type from .game_type_catalog import get_onboarding_type
def game_meta(slug): def game_meta(slug):
@@ -14,8 +14,11 @@ def game_meta(slug):
# ligne" en a besoin pour afficher son état actuel), sans avoir à # ligne" en a besoin pour afficher son état actuel), sans avoir à
# appeler is_public_played(slug) séparément dans chaque route. # appeler is_public_played(slug) séparément dans chaque route.
"is_public_played": is_public_played(slug), "is_public_played": is_public_played(slug),
# Type de jeu (fondations multi-éditeurs) : "document" (éditeur # Onboarding guidé (voir game_type_catalog.py) : utilisé par
# générique) ou "jeu_2d" (éditeur de scène) — voir # templates/base.html pour cacher le lien "Tableau de bord" à un
# get_game_type.py et routes/screens/screen_edit.py. # compte "restreint" (quiz/embranchement/rpg).
"game_type": get_game_type(slug), "onboarding_type": get_onboarding_type(slug),
# Pas de "game_type" ici : depuis la fusion des moteurs (voir
# screens/screens_repo/ensure_schema.py), "document"/"jeu_2d" est
# une propriété PAR ÉCRAN (screen["kind"]), plus du projet.
} }
+98
View File
@@ -0,0 +1,98 @@
"""Onboarding guidé (voir routes/onboarding/onboarding_new.py) : les 4
choix proposés à CHAQUE création de projet — PAS un 3e type de moteur
(voir screens/screens_repo/ensure_schema.py, fusion des moteurs :
"document"/"jeu_2d" est une propriété PAR ÉCRAN), une classification
produit posée par-dessus. Chaque choix décide du "kind" du tout premier
écran créé (screen_kind ci-dessous, aussi imposé à tout écran ajouté
ensuite — voir routes/screens/screens_new.py) et si le tableau de bord
complet (game_dashboard, objets/éléments de jeu/variables) reste
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
rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py)."""
import db
DEFAULT_ONBOARDING_TYPE = "custom"
ONBOARDING_TYPES = {
"quiz": {
"label": "Quiz",
"tagline": "Questions, réponses, score.",
"icon": "🎯",
"description": "Gérer des questions/réponses, avec score.",
"screen_kind": "document",
"restricted": True,
},
"embranchement": {
"label": "Jeu à embranchement / escape game",
"tagline": "Chaque choix a une conséquence.",
"icon": "🗝️",
"description": (
"Former à travers le jeu sur des concepts métier avec des "
"conséquences observables ; réaliser des escape games sous "
"forme d'énigmes."
),
"screen_kind": "document",
"restricted": True,
},
"rpg": {
"label": "Jeu RPG",
"tagline": "Ton monde, ton héros, ton scénario.",
"icon": "🗺️",
"description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.",
"screen_kind": "jeu_2d",
"restricted": True,
},
"custom": {
"label": "Créer mon jeu de A à Z",
"tagline": "Toute la puissance du moteur, sans limite.",
"icon": "⚡",
"description": (
"Pour les utilisateurs avec des notions de dev/jeu vidéo : "
"accès à toute la puissance du moteur."
),
"screen_kind": "document",
"restricted": False,
},
}
def get_onboarding_type_raw(slug):
"""None si _meta['onboarding_type'] est absent — distingue un projet
JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par
l'admin via "+ Nouveau jeu") d'un projet explicitement "custom"."""
conn = db.connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'onboarding_type'").fetchone()
conn.close()
return row["value"] if row else None
def get_onboarding_type(slug):
return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE
def set_onboarding_type(slug, onboarding_type):
conn = db.connect(slug)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)",
(onboarding_type,),
)
conn.commit()
conn.close()
def is_restricted(onboarding_type_or_slug):
"""Accepte directement une clé de ONBOARDING_TYPES, ou un slug de
projet (résout alors son onboarding_type d'abord) — pratique aussi
bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a
déjà la clé)."""
onboarding_type = (
onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES
else get_onboarding_type(onboarding_type_or_slug)
)
return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"]
+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 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
+5
View File
@@ -52,5 +52,10 @@ def login_2fa():
return redirect(next_url) return redirect(next_url)
if user["role"] == "admin": if user["role"] == "admin":
return redirect(url_for("index")) return redirect(url_for("index"))
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) :
# un compte "user" peut ne pas encore avoir de projet du tout
# (inscription interrompue avant l'onboarding).
if not user.get("project_slug"):
return redirect(url_for("onboarding_new"))
return redirect(url_for("game_dashboard", slug=user["project_slug"])) return redirect(url_for("game_dashboard", slug=user["project_slug"]))
return render_template("auth/login_2fa.html", error=error) return render_template("auth/login_2fa.html", error=error)
+8 -12
View File
@@ -1,7 +1,6 @@
from flask import redirect, render_template, request, session, url_for from flask import redirect, render_template, request, session, url_for
import auth import auth
import db
from core.flask_app import app from core.flask_app import app
@@ -28,16 +27,6 @@ def register_2fa():
else: else:
auth.reset_failed_attempts(user_id) auth.reset_failed_attempts(user_id)
auth.confirm_totp(user_id) auth.confirm_totp(user_id)
# Un compte "user" (pas "admin") reçoit ICI son unique projet,
# nommé d'après son adresse email (voir create_user.py — le
# rôle est déjà tranché à la création : premier compte jamais
# créé = admin, tous les suivants = user) — un admin, lui,
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
# aucun projet ne lui est imposé d'office.
if user["role"] != "admin":
slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"])
auth.set_project_slug(user_id, slug)
user = auth.get_user_by_id(user_id)
session.pop("pending_register_user_id", None) session.pop("pending_register_user_id", None)
session["user_id"] = user_id session["user_id"] = user_id
# Générés ici, une seule fois, à l'instant même où la 2FA # Générés ici, une seule fois, à l'instant même où la 2FA
@@ -50,7 +39,14 @@ def register_2fa():
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id) session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id)
if user["role"] == "admin": if user["role"] == "admin":
return redirect(url_for("index")) return redirect(url_for("index"))
return redirect(url_for("game_dashboard", slug=user["project_slug"])) # Un compte "user" (pas "admin") n'a plus de projet créé
# d'office ici — voir routes/onboarding/onboarding_new.py : il
# choisit d'abord un type de jeu et nomme son projet, PUIS
# auth.set_project_slug() y est appelé (core/auth_guard.py
# renvoie ici tout compte sans project_slug). Un admin, lui,
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
# aucun projet ne lui est imposé d'office.
return redirect(url_for("onboarding_new"))
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"]) qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
return render_template( return render_template(
+35 -1
View File
@@ -13,8 +13,42 @@ def game_dashboard(slug):
accès direct à chaque élément — la barre de navigation du jeu (voir accès direct à chaque élément — la barre de navigation du jeu (voir
base.html) permet déjà de sauter directement vers chaque éditeur, cette base.html) permet déjà de sauter directement vers chaque éditeur, cette
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
liens.""" liens.
Onboarding guidé (voir db/games/game_type_catalog.py) : un compte
"restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE —
juste ses écrans, en cartes avec un aperçu de leur contenu, plus un
bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le
projet : "jeu_2d" pour un RPG, "document" pour les autres — voir
routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/
variables restent réservés à "Créer mon jeu de A à Z", la seule
formule pensée pour la puissance complète du moteur."""
game = db.game_meta(slug) game = db.game_meta(slug)
if db.is_restricted(slug):
return _render_simple_dashboard(slug, game)
return _render_full_dashboard(slug, game)
def _render_simple_dashboard(slug, game):
screen_list = screens.list_screens(slug)
previews = {}
for s in screen_list:
if s["kind"] == "jeu_2d":
objects = screens.list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
else:
previews[s["id"]] = {
"kind": "document",
"elements": screens.list_elements(slug, s["id"]),
}
return render_template(
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
)
def _render_full_dashboard(slug, game):
definitions = db.list_definitions(slug) definitions = db.list_definitions(slug)
# Détail complet (avec ses champs) de chaque objet, pour le panneau # Détail complet (avec ses champs) de chaque objet, pour le panneau
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon # d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
+1
View File
@@ -0,0 +1 @@
from . import onboarding_new
+66
View File
@@ -0,0 +1,66 @@
from flask import g, redirect, render_template, request, url_for
import auth
import db
import screens
from core.flask_app import app
@app.route("/onboarding", methods=["GET", "POST"])
def onboarding_new():
"""Point d'entrée UNIQUE pour créer un jeu — comportement SYSTÉMATIQUE
à chaque création, pas une formalité réservée à la toute première
inscription : chaque projet (celui d'un compte "user" comme n'importe
lequel des projets d'un admin) choisit son propre type ici. Prépare
aussi le futur multi-projet payant (plusieurs jeux de types
différents pour un même compte "user").
Un compte "user" fraîchement inscrit n'a plus de projet créé d'office
(voir routes/auth/register_2fa.py) : core/auth_guard.py le redirige
ici tant qu'il n'a pas encore de project_slug — et n'en aura jamais
qu'UN SEUL (voir core/auth_guard.py). Un admin, lui, n'a PAS de
project_slug dédié (illimité, voir create_user.py) : repasse ici à
volonté pour créer un projet de PLUS, jamais redirigé ailleurs.
Choisit un type (voir db.ONBOARDING_TYPES) puis nomme son jeu — crée
le projet ET son premier écran (du "kind" correspondant), puis envoie
vers son tableau de bord (routes/games/game_dashboard.py y rend une
version SIMPLIFIÉE pour un type restreint — quiz/embranchement/rpg —
ou la version complète pour "custom", même route pour tous)."""
user = g.current_user
if user["role"] != "admin" and user.get("project_slug"):
# Retour en arrière après coup (l'onboarding a déjà été fait) —
# ne recrée rien, renvoie directement vers son tableau de bord. Ne
# s'applique PAS à un admin (illimité, jamais "déjà fait" une fois
# pour toutes).
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
error = None
if request.method == "POST":
onboarding_type = request.form.get("onboarding_type", "")
name = request.form.get("name", "").strip()
if onboarding_type not in db.ONBOARDING_TYPES:
error = "Choisis un type de jeu."
elif not name:
error = "Donne un nom à ton jeu."
else:
return redirect(_create_project_for_user(user, onboarding_type, name))
return render_template(
"onboarding/onboarding_new.html",
onboarding_types=db.ONBOARDING_TYPES, error=error,
)
def _create_project_for_user(user, onboarding_type, name):
slug = db.create_game(name, owner_folder=db.slugify(user["email"]))
db.set_onboarding_type(slug, onboarding_type)
if user["role"] != "admin":
# project_slug = LE seul projet d'un compte "user" (voir
# core/auth_guard.py) — un admin n'utilise pas cette colonne, ses
# projets se retrouvent via db.list_games() comme avant.
auth.set_project_slug(user["id"], slug)
screen_kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
screens.create_screen(slug, name, kind=screen_kind)
return url_for("game_dashboard", slug=slug)
+14 -1
View File
@@ -1,5 +1,6 @@
from flask import request, redirect, url_for from flask import request, redirect, url_for
import db
import screens import screens
from core.flask_app import app from core.flask_app import app
@@ -8,7 +9,19 @@ from core.flask_app import app
@app.route("/game/<slug>/screens/new", methods=["POST"]) @app.route("/game/<slug>/screens/new", methods=["POST"])
def screens_new(slug): def screens_new(slug):
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
if db.is_restricted(slug):
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
# imposé par le projet (jeu_2d pour un RPG, document pour les
# autres), même valeur pour TOUS ses écrans (voir
# db/games/game_type_catalog.py).
onboarding_type = db.get_onboarding_type(slug)
kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
else:
kind = request.form.get("kind", "document")
if kind not in ("document", "jeu_2d"):
kind = "document"
if name: if name:
screen_id = screens.create_screen(slug, name) screen_id = screens.create_screen(slug, name, kind=kind)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
return redirect(url_for("game_dashboard", slug=slug, tab="screens")) return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
+41
View File
@@ -0,0 +1,41 @@
"""Script à usage unique (PAS exécuté au runtime du moteur) : pose la clé
_meta['onboarding_type'] sur tout projet existant qui ne l'a pas encore
(créé avant l'existence de l'onboarding guidé — voir routes/onboarding/
onboarding_new.py et db/games/game_type_catalog.py). Pas strictement
nécessaire : get_onboarding_type() retombe déjà sur DEFAULT_ONBOARDING_TYPE
("custom") pour tout projet sans cette clé — ce script rend juste ce choix
EXPLICITE dans les données plutôt qu'implicite, sans rien changer au
comportement observable.
Marque "custom" tout projet trouvé sans onboarding_type (comportement
neutre : tableau de bord complet garanti visible, comme aujourd'hui) —
aucune distinction "jeu_2d"/RPG à faire ici, cette notion n'existe plus au
niveau projet depuis la fusion des moteurs (écran par écran, voir
screens/screens_repo/ensure_schema.py) : un projet historiquement "jeu_2d"
reste "custom" comme n'importe quel autre, ses écrans gardent
individuellement leur kind="jeu_2d".
python scripts/backfill_onboarding_type.py
"""
import os
import sys
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _BASE_DIR)
import db # noqa: E402
def backfill():
games = db.list_games()
updated = 0
for g in games:
if db.get_onboarding_type_raw(g["slug"]) is None:
db.set_onboarding_type(g["slug"], "custom")
updated += 1
print(f"{g['slug']!r} -> 'custom'")
print(f"{updated}/{len(games)} projet(s) mis à jour.")
if __name__ == "__main__":
backfill()
+15
View File
@@ -0,0 +1,15 @@
// Dashboard simplifié (templates/game_dashboard_simple.html, voir
// routes/games/game_dashboard.py) — affiche/masque le petit formulaire
// "+ Ajouter un écran", même comportement que toggleDashCreate() du
// tableau de bord complet (static/js/screen_edit/tabs-and-blocks.js),
// repris ici pour ne pas dépendre de tout ce fichier (pensé pour l'éditeur
// complet, pas cette page).
function toggleDashCreate(tab) {
var bar = document.getElementById("dashCreate-" + tab);
if (!bar) return;
bar.style.display = bar.style.display === "none" ? "flex" : "none";
if (bar.style.display === "flex") {
var firstInput = bar.querySelector("input, select");
if (firstInput) firstInput.focus();
}
}
+65
View File
@@ -0,0 +1,65 @@
// Onboarding guidé (templates/onboarding/onboarding_new.html) — interactions
// pures DOM, aucune logique métier ici (voir routes/onboarding/
// onboarding_new.py pour la validation réelle, côté serveur). Deux
// "pages" (choix du type / nom du jeu) posées côte à côte dans
// #onboardingTrack, révélées par un défilement horizontal animé en CSS
// (voir .onboardingTrack--step2 dans static/style.css) plutôt qu'un vrai
// changement de page — pas de rechargement, pas d'aller-retour serveur
// tant que le formulaire n'est pas soumis pour de vrai.
function forgeOnboardingGoToStep(step) {
var track = document.getElementById("onboardingTrack");
if (track) track.classList.toggle("onboardingTrack--step2", step === 2);
if (step === 2) {
var nameField = document.getElementById("onboardingNameInput");
// Après la fin de l'animation de défilement (voir la durée de
// transition posée sur .onboardingTrack) — focus immédiat plus tôt
// ferait sauter le scroll du champ à moitié visible.
if (nameField) setTimeout(function () { nameField.focus(); }, 500);
}
}
function forgeOnboardingChooseType(card) {
document.querySelectorAll(".onboardingCard").forEach(function (c) {
c.classList.toggle("is-selected", c === card);
});
var hiddenType = document.getElementById("onboardingTypeHidden");
if (hiddenType) hiddenType.value = card.dataset.type || "";
forgeOnboardingUpdateSubmitState();
forgeOnboardingGoToStep(2);
}
function forgeOnboardingUpdateSubmitState() {
var nameField = document.getElementById("onboardingNameInput");
var hiddenType = document.getElementById("onboardingTypeHidden");
var submitBtn = document.getElementById("onboardingSubmitBtn");
if (!nameField || !hiddenType || !submitBtn) return;
submitBtn.disabled = !(hiddenType.value && nameField.value.trim());
}
function forgeOnboardingInit() {
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé
// par un <script src> posé à la fin du contenu de la page — les
// éléments dont on a besoin existent déjà à ce stade. Nécessaire aussi
// pour la navigation pjax (voir static/pjax.js — runScriptsIn
// ré-exécute ce script à chaque page swappée, mais DOMContentLoaded,
// lui, ne se redéclenche jamais après le tout premier chargement réel).
// Entrée en cascade des cartes — délai posé en JS plutôt qu'en CSS pur
// (nth-child) pour rester correct quel que soit le nombre de types
// affichés (voir db.ONBOARDING_TYPES, appelé à générer la liste).
document.querySelectorAll(".onboardingCard").forEach(function (card, i) {
card.style.animationDelay = (i * 70) + "ms";
card.addEventListener("click", function () { forgeOnboardingChooseType(card); });
});
var backBtn = document.getElementById("onboardingBackBtn");
if (backBtn) backBtn.addEventListener("click", function () { forgeOnboardingGoToStep(1); });
var nameField = document.getElementById("onboardingNameInput");
if (nameField) nameField.addEventListener("input", forgeOnboardingUpdateSubmitState);
forgeOnboardingUpdateSubmitState();
}
forgeOnboardingInit();
+118
View File
@@ -226,6 +226,87 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
.content{ max-width:760px; margin:0 auto; padding:28px 20px; } .content{ max-width:760px; margin:0 auto; padding:28px 20px; }
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; } .card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
/* Onboarding guidé (voir routes/onboarding/onboarding_new.py) — comportement
SYSTÉMATIQUE à chaque création de jeu (admin compris) : c'est la première
impression du moteur, pensée pour "vendre du rêve" plutôt qu'un simple
formulaire administratif. Deux "pages" côte à côte (choix du type / nom
du jeu) dans #onboardingTrack, révélées par un défilement horizontal
animé — voir static/js/onboarding/onboarding_new.js pour la mécanique
(jamais de JS mélangé au HTML, voir CLAUDE.md). */
.onboardingPage{ max-width:1200px; }
.onboardingError{ text-align:center; color:var(--accent); font-weight:600; margin-bottom:12px; }
.onboardingTitle{ display:block; text-align:center; font-size:2em; font-weight:700; margin:0 0 24px; }
.onboardingViewport{ overflow:hidden; }
.onboardingTrack{
display:flex; width:200%;
transition:transform .65s cubic-bezier(.65,0,.35,1);
}
.onboardingTrack.onboardingTrack--step2{ transform:translateX(-50%); }
.onboardingStep{ flex:0 0 50%; width:50%; box-sizing:border-box; padding:12px 24px; }
/* ---------- Étape 1 : cartes de type, grandes, retournables ---------- */
.onboardingGrid{ display:flex; gap:18px; height:66vh; min-height:420px; max-height:640px; }
.onboardingCard{
flex:1; position:relative; cursor:pointer; padding:0; margin:0; border:none; background:none;
border-radius:20px; perspective:1400px;
opacity:0; animation:forgeOnboardCardIn .5s ease both;
transition:flex-grow .3s ease;
}
.onboardingCard:hover{ flex-grow:1.25; }
.onboardingCardInner{
position:relative; display:block; width:100%; height:100%;
transform-style:preserve-3d; transition:transform .6s cubic-bezier(.4,.2,.2,1);
border-radius:20px; box-shadow:0 10px 30px rgba(0,0,0,.28);
}
.onboardingCard:hover .onboardingCardInner, .onboardingCard.is-selected .onboardingCardInner{ transform:rotateY(180deg); }
.onboardingCardFace{
position:absolute; inset:0; backface-visibility:hidden; border-radius:20px;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
padding:20px; text-align:center;
}
.onboardingCardFront{ color:#fff; }
.onboardingCardIcon{ font-size:3.4em; line-height:1; filter:drop-shadow(0 4px 10px rgba(0,0,0,.35)); }
.onboardingCardLabel{ font-weight:800; font-size:1.3em; letter-spacing:.01em; }
.onboardingCardBack{
transform:rotateY(180deg); background:var(--panel); border:2px solid var(--accent);
}
.onboardingCardDesc{ color:var(--text); font-size:1em; line-height:1.5; }
/* Une couleur de dégradé par type — voir data-type dans le template,
généré depuis db.ONBOARDING_TYPES (mêmes clés que ci-dessous). */
.onboardingCard[data-type="quiz"] .onboardingCardFront{ background:linear-gradient(150deg, #2ea3ff, #1560d8); }
.onboardingCard[data-type="embranchement"] .onboardingCardFront{ background:linear-gradient(150deg, #b06bff, #6a2fd6); }
.onboardingCard[data-type="rpg"] .onboardingCardFront{ background:linear-gradient(150deg, #33d182, #159a5c); }
.onboardingCard[data-type="custom"] .onboardingCardFront{ background:var(--gradient); color:#1a1108; }
.onboardingCard[data-type="custom"] .onboardingCardIcon{ filter:drop-shadow(0 4px 10px rgba(0,0,0,.2)); }
/* ---------- Étape 2 : nom du jeu, grand champ centré ---------- */
.onboardingStep--name{ display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:66vh; }
.onboardingBackBtn{
align-self:flex-start; background:none; border:none; color:var(--forge-text-muted, #8b95a7);
font-size:.95em; cursor:pointer; padding:6px 0; margin-bottom:auto;
}
.onboardingBackBtn:hover{ color:var(--accent); }
.onboardingNameBox{ width:100%; max-width:520px; text-align:center; }
.onboardingNameInput{
width:100%; text-align:center; font-size:1.5em; font-weight:600; padding:18px 16px;
border-radius:14px; border:2px solid var(--border); background:var(--panel); color:var(--text);
transition:border-color .18s ease, box-shadow .18s ease;
}
.onboardingNameInput:focus{ outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(255,95,46,.2); }
.onboardingSubmitBtn{
margin-top:20px; background:var(--gradient); border:none; font-weight:700; letter-spacing:.01em;
font-size:1.05em; padding:12px 28px; transition:opacity .18s ease, transform .12s ease;
}
.onboardingSubmitBtn:disabled{ opacity:.4; cursor:not-allowed; }
.onboardingSubmitBtn:not(:disabled):hover{ transform:translateY(-2px); }
@keyframes forgeOnboardCardIn{ from{ opacity:0; transform:translateY(14px); } to{ opacity:1; transform:translateY(0); } }
@media (prefers-reduced-motion: reduce){
.onboardingCard{ animation:none; opacity:1; }
.onboardingTrack, .onboardingCardInner{ transition:none; }
}
.inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; } .inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; }
.inline-form .fieldRow input{ flex:1; } .inline-form .fieldRow input{ flex:1; }
.fieldRow{ display:flex; gap:10px; } .fieldRow{ display:flex; gap:10px; }
@@ -773,6 +854,43 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; } .dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; }
.dashDangerZone{ margin-top:28px; } .dashDangerZone{ margin-top:28px; }
/* Dashboard simplifié (templates/game_dashboard_simple.html, voir
routes/games/game_dashboard.py) — un compte "restreint" (quiz/
embranchement/rpg) ne voit que ses écrans, en cartes avec un aperçu
RÉEL de leur contenu (élements % réutilisés tels quels pour un écran
document — voir elstyle/eltransform — ou objets de scène mis à
l'échelle pour un écran jeu_2d, voir --scenePreviewScale posé par
carte). */
.screenCardGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr)); gap:20px; margin-top:14px; }
.screenCard{
border:1px solid var(--border); border-radius:14px; overflow:hidden; background:var(--panel);
transition:transform .18s ease, box-shadow .18s ease;
}
.screenCard:hover{ transform:translateY(-3px); box-shadow:0 10px 24px rgba(0,0,0,.25); }
.screenCardPreviewLink{ display:block; }
.screenPreviewBox{
position:relative; width:100%; overflow:hidden; container-type:inline-size;
background:#0b0d12; border-bottom:1px solid var(--border);
}
/* Scène "jeu_2d" (positionnement en PIXELS fixes, voir render_scene_object.py)
mise à l'échelle EXACTEMENT à la largeur réelle de .screenPreviewBox —
"cqw" (1% de la largeur du conteneur, posé ci-dessus) rend ce calcul
toujours juste, quelle que soit la largeur réellement occupée par la
carte (grille responsive) — jamais une largeur fixe supposée. */
.screenPreviewScene{
position:absolute; top:0; left:0; transform-origin:top left;
transform:scale(calc(100cqw / var(--scene-w)));
}
.screenPreviewEmpty{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:var(--forge-text-muted, #8b95a7); font-size:.85em;
}
.screenCardFooter{ display:flex; align-items:center; justify-content:space-between; gap:8px; padding:10px 12px; }
.screenCardName{ font-weight:600; color:var(--text); text-decoration:none; }
.screenCardName:hover{ color:var(--accent); }
.screenCardActions{ display:flex; gap:4px; }
.screenCardActions form{ display:inline; }
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */ /* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec /* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
beaucoup de champs rend le tableau illisible, ce menu permet d'en beaucoup de champs rend le tableau illisible, ce menu permet d'en
+5 -1
View File
@@ -48,7 +48,11 @@
délibérément le DERNIER lien — une action à part (ouvre l'aperçu délibérément le DERNIER lien — une action à part (ouvre l'aperçu
jouable dans un nouvel onglet), pas une page de gestion de plus. --> jouable dans un nouvel onglet), pas une page de gestion de plus. -->
<nav class="gameNavBar" aria-label="Navigation du jeu"> <nav class="gameNavBar" aria-label="Navigation du jeu">
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a> <!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) : même
route pour tous les comptes — routes/games/game_dashboard.py y rend
une version simplifiée (juste les écrans) pour un type "restreint"
(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> <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> <button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de <!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}{{ game.name }} — Écrans{% endblock %}
{% block content_class %}content-wide{% endblock %}
{% block content %}
{#- Dashboard simplifié (voir routes/games/game_dashboard.py) — un compte
"restreint" (quiz/embranchement/rpg, voir db/games/game_type_catalog.py)
ne voit QUE ses écrans, en cartes avec un aperçu réel de leur contenu
(pas d'onglets Objets/Éléments de jeu/Variables, réservés à "Créer mon
jeu de A à Z"). Ajouter un écran n'y demande pas de type : imposé par
le projet, voir routes/screens/screens_new.py. -#}
{#- Aperçu adapté à la taille RÉELLE de la carte (pas une largeur fixe
supposée) : container-type:inline-size + l'unité "cqw" (1% de la
largeur du conteneur le plus proche qui la déclare) permettent à
.screenPreviewScene de se mettre à l'échelle EXACTEMENT à la largeur
réelle de .screenPreviewBox, quelle que soit la largeur de la grille
ou la taille de la fenêtre — sans JS ni recalcul serveur. Le ratio de
la boîte elle-même vient de la vraie proportion de la scène
(scene_width/scene_height), pas de preview_aspect (propre aux écrans
"document", sans rapport avec une scène "jeu_2d"). -#}
{% macro screen_preview(s, preview) %}
{% if preview.kind == 'jeu_2d' %}
<div class="screenPreviewBox" style="aspect-ratio:{{ s.scene_width }} / {{ s.scene_height }};">
<div class="screenPreviewScene" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px; --scene-w:{{ s.scene_width }}px;">
{% for obj in preview.objects %}{{ obj.rendered_html|safe }}{% endfor %}
</div>
{% if not preview.objects %}<div class="screenPreviewEmpty">Écran vide</div>{% endif %}
</div>
{% else %}
<div class="screenPreviewBox" style="aspect-ratio:{{ (s.preview_aspect or '9/16')|replace('/', ' / ') }};">
{% for el in preview.elements if not el.parent_id %}
<div style="{{ el|elstyle }}"><div style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div></div>
{% endfor %}
{% if not preview.elements %}<div class="screenPreviewEmpty">Écran vide</div>{% endif %}
</div>
{% endif %}
{% endmacro %}
<div class="dashPanelHeader">
<h2 class="title is-4 mb-0">{{ game.name }}</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('screens')">+ Ajouter un écran</button>
</div>
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-screens" style="display:none;">
<input type="text" name="name" placeholder="Nom du nouvel écran (ex. Question 2, Scène 2...)" required autocomplete="off">
<button type="submit" class="primary">Créer</button>
</form>
{% if screen_list %}
<div class="screenCardGrid">
{% for s in screen_list %}
<div class="screenCard">
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}" class="screenCardPreviewLink">
{{ screen_preview(s, previews[s.id]) }}
</a>
<div class="screenCardFooter">
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}" class="screenCardName">
{{ s.name }}{% if loop.first %} <span class="tag is-info is-light">★ Départ</span>{% endif %}
</a>
<div class="screenCardActions">
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="up"><button type="submit" class="button is-small" title="Monter" {{ 'disabled' if loop.first else '' }}>↑</button></form>
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="down"><button type="submit" class="button is-small" title="Descendre" {{ 'disabled' if loop.last else '' }}>↓</button></form>
<form method="post" action="{{ url_for('screen_delete', slug=game.slug, screen_id=s.id) }}"
onsubmit="return confirm('Supprimer l\'écran « {{ s.name }} » et tout son contenu ?');">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun écran pour l'instant — clique sur "+ Ajouter un écran" pour commencer.</p>
{% endif %}
<script src="{{ url_for('static', filename='js/game_dashboard_simple.js') }}"></script>
{% endblock %}
+10 -17
View File
@@ -11,24 +11,17 @@
<div class="twoCol"> <div class="twoCol">
<div class="colLeft"> <div class="colLeft">
<h2>Créer un jeu</h2> <h2>Créer un jeu</h2>
<form method="post" action="{{ url_for('games_new') }}" class="card"> <!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) :
<div class="formField"> comportement SYSTÉMATIQUE à chaque création de jeu, pas une
<label for="name">Nom du nouveau jeu</label> formalité réservée à l'inscription — un admin y repasse à
<input type="text" id="name" name="name" placeholder="ex. CyberQuest RGPD" required> volonté (illimité, pas de project_slug dédié qui bloquerait le
retour). Choisir un type ici, ce n'est pas juste pour un compte
"user" : ça prépare aussi le jour où un admin (ou un compte
payant multi-projet) gère plusieurs jeux de types différents. -->
<div class="card">
<p class="hint" style="margin-top:0;">Choisis un type de jeu, nomme-le : Forge Engine crée le projet et t'envoie directement sur l'éditeur adapté.</p>
<div class="actionsRow"><a href="{{ url_for('onboarding_new') }}" class="button primary">+ Créer un jeu</a></div>
</div> </div>
<!-- Fondations multi-éditeurs : le type choisi ici gate quel éditeur
(générique ou scène 2D) et quel rendu jouable ce jeu utilisera,
voir db/games/get_game_type.py — non modifiable ensuite. -->
<div class="formField">
<label for="game_type">Type de jeu</label>
<select id="game_type" name="game_type">
<option value="document">Document — quiz, formulaire, contenu (éditeur actuel)</option>
<option value="jeu_2d">Jeu 2D — RPG/serious game (scène, personnages, collision)</option>
</select>
</div>
<div class="actionsRow"><button type="submit" class="primary">+ Créer le jeu</button></div>
<p class="hint" style="margin-top:6px;">Crée un dossier avec index.html / index.css / index.js reliés entre eux, et une base de données dédiée à ce jeu.</p>
</form>
</div> </div>
<div class="colRight"> <div class="colRight">
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Forge Engine — Créer mon jeu{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content_class %}content-wide onboardingPage{% endblock %}
{% block content %}
{% if error %}<p class="hint onboardingError">⚠️ {{ error }}</p>{% endif %}
<form method="post" action="{{ url_for('onboarding_new') }}" id="onboardingForm">
<input type="hidden" name="onboarding_type" id="onboardingTypeHidden">
<div class="onboardingViewport">
<div class="onboardingTrack" id="onboardingTrack">
<section class="onboardingStep onboardingStep--pick">
<h2 class="onboardingTitle">Quel jeu veux-tu créer ?</h2>
<div class="onboardingGrid">
{% for key, info in onboarding_types.items() %}
<button type="button" class="onboardingCard" data-type="{{ key }}" data-label="{{ info.label }}">
<span class="onboardingCardInner">
<span class="onboardingCardFace onboardingCardFront">
<span class="onboardingCardIcon">{{ info.icon }}</span>
<span class="onboardingCardLabel">{{ info.label }}</span>
</span>
<span class="onboardingCardFace onboardingCardBack">
<span class="onboardingCardDesc">{{ info.description }}</span>
</span>
</span>
</button>
{% endfor %}
</div>
</section>
<section class="onboardingStep onboardingStep--name">
<button type="button" class="onboardingBackBtn" id="onboardingBackBtn">← Changer de type</button>
<div class="onboardingNameBox">
<label class="onboardingTitle" for="onboardingNameInput">Quel est le nom de votre jeu ?</label>
<input type="text" name="name" id="onboardingNameInput" class="onboardingNameInput" placeholder="ex. CyberQuest RGPD" autocomplete="off">
<button type="submit" id="onboardingSubmitBtn" class="primary onboardingSubmitBtn" disabled>Créer mon jeu 🚀</button>
</div>
</section>
</div>
</div>
</form>
<script src="{{ url_for('static', filename='js/onboarding/onboarding_new.js') }}"></script>
{% endblock %}
+45 -9
View File
@@ -34,19 +34,41 @@ def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"
def _confirm_2fa(client): def _confirm_2fa(client):
"""Récupère la clé TOTP affichée sur la page de confirmation et """Récupère la clé TOTP affichée sur la page de confirmation et
soumet le code du moment — comme un humain qui vient de scanner le soumet le code du moment — comme un humain qui vient de scanner le
QR code avec son application.""" QR code avec son application. Un compte "user" n'a plus de projet
créé d'office ici (voir routes/onboarding/onboarding_new.py) — la
redirection renvoie désormais vers /onboarding, pas /game/<slug> ;
voir _complete_onboarding ci-dessous pour les tests qui ont besoin
d'un vrai projet ensuite."""
html = client.get("/register/2fa").get_data(as_text=True) html = client.get("/register/2fa").get_data(as_text=True)
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1) secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
code = pyotp.TOTP(secret).now() code = pyotp.TOTP(secret).now()
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
def _complete_onboarding(client, name="Mon jeu", onboarding_type="custom"):
"""Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py)
— la plupart des tests de ce fichier n'ont pas besoin de tester
l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un
project_slug réel pour la suite ; "custom" est le choix le plus proche
du comportement historique (tableau de bord complet)."""
return client.post(
"/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False
)
def _cleanup_project(email): def _cleanup_project(email):
game_dir = db.game_dir(db.slugify(email))
import os import os
import shutil user = auth.get_user_by_email(email)
if os.path.isdir(game_dir): if user and user.get("project_slug"):
shutil.rmtree(game_dir) if os.path.isdir(db.game_dir(user["project_slug"])):
# db.delete_game (voir db/games/project_slug.py — structure de
# dossiers par utilisateur) nettoie aussi le dossier
# propriétaire s'il devient vide, contrairement à un simple
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
# vides s'accumulent sous projects/ d'un run de tests à l'autre
# (déjà observé : ça a fini par provoquer une collision de nom
# dans db.move_game, un dossier vide comptant comme "occupé").
db.delete_game(user["project_slug"])
def test_anonymous_request_redirects_to_login(anon_client): def test_anonymous_request_redirects_to_login(anon_client):
@@ -100,13 +122,23 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie
user = auth.get_user_by_email("flow@example.com") user = auth.get_user_by_email("flow@example.com")
assert user["totp_confirmed"] == 1 assert user["totp_confirmed"] == 1
assert user["role"] == "user" # jamais le premier compte de la session de tests assert user["role"] == "user" # jamais le premier compte de la session de tests
assert user["project_slug"] == db.slugify("flow@example.com") # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus
# de projet créé d'office ici — connecté mais sans project_slug tant
# qu'il n'a pas choisi un type de jeu.
assert user["project_slug"] is None
try: try:
assert resp.headers["Location"] == "/game/" + user["project_slug"] assert resp.headers["Location"] == "/onboarding"
# Connecté pour de vrai maintenant. # Connecté pour de vrai maintenant, mais renvoyé vers l'onboarding
# tant qu'aucun projet n'existe (voir core/auth_guard.py).
resp2 = anon_client.get("/", follow_redirects=False) resp2 = anon_client.get("/", follow_redirects=False)
assert resp2.status_code == 302 assert resp2.status_code == 302
assert resp2.headers["Location"] == "/game/" + user["project_slug"] assert resp2.headers["Location"] == "/onboarding"
onboard_resp = _complete_onboarding(anon_client)
assert onboard_resp.status_code == 302
user = auth.get_user_by_email("flow@example.com")
assert user["project_slug"] is not None
assert onboard_resp.headers["Location"] == "/game/" + user["project_slug"]
finally: finally:
_cleanup_project("flow@example.com") _cleanup_project("flow@example.com")
@@ -123,6 +155,7 @@ def test_wrong_totp_code_is_rejected(anon_client):
def test_login_requires_correct_password_then_totp(anon_client): def test_login_requires_correct_password_then_totp(anon_client):
_register(anon_client, "login@example.com") _register(anon_client, "login@example.com")
resp, secret = _confirm_2fa(anon_client) resp, secret = _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
slug = auth.get_user_by_email("login@example.com")["project_slug"] slug = auth.get_user_by_email("login@example.com")["project_slug"]
try: try:
anon_client.post("/logout") anon_client.post("/logout")
@@ -146,6 +179,7 @@ def test_login_requires_correct_password_then_totp(anon_client):
def test_non_admin_user_is_isolated_to_their_own_project(anon_client): def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
_register(anon_client, "isolated@example.com") _register(anon_client, "isolated@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("isolated@example.com") user = auth.get_user_by_email("isolated@example.com")
try: try:
# Un autre projet (celui de l'admin partagé par conftest.py, ou # Un autre projet (celui de l'admin partagé par conftest.py, ou
@@ -173,6 +207,7 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client):
retour — bloqué plutôt que risqué.""" retour — bloqué plutôt que risqué."""
_register(anon_client, "nodelete@example.com") _register(anon_client, "nodelete@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("nodelete@example.com") user = auth.get_user_by_email("nodelete@example.com")
try: try:
resp = anon_client.post(f"/game/{user['project_slug']}/delete") resp = anon_client.post(f"/game/{user['project_slug']}/delete")
@@ -281,6 +316,7 @@ def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
def test_login_with_a_recovery_code_instead_of_totp(anon_client): def test_login_with_a_recovery_code_instead_of_totp(anon_client):
_register(anon_client, "recoverylogin@example.com") _register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("recoverylogin@example.com") user = auth.get_user_by_email("recoverylogin@example.com")
slug = user["project_slug"] slug = user["project_slug"]
codes = auth.generate_recovery_codes(user["id"]) codes = auth.generate_recovery_codes(user["id"])
+171
View File
@@ -0,0 +1,171 @@
"""Onboarding guidé (routes/onboarding/onboarding_new.py) : un compte
"user" fraîchement inscrit choisit un type de jeu (Quiz/Embranchement/
RPG/Créer mon jeu de A à Z) avant d'avoir accès à quoi que ce soit — voir
le plan "Fusion des deux moteurs + structure de dossiers + onboarding
guidé". Réutilise les fixtures de test_auth.py (inscription/2FA)."""
import os
import auth
import db
import screens
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401
def test_account_without_a_project_is_redirected_to_onboarding(anon_client):
_register(anon_client, "needsonboarding@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.get("/", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/onboarding"
resp = anon_client.get("/onboarding")
assert resp.status_code == 200
assert "Quiz" in resp.get_data(as_text=True)
finally:
_cleanup_project("needsonboarding@example.com")
def test_quiz_lands_on_simplified_dashboard_with_one_document_screen(anon_client):
"""Le dashboard simplifié (routes/games/game_dashboard.py) — un compte
restreint (quiz/embranchement/rpg) atterrit maintenant sur SON
tableau de bord (même route que "custom"), juste rendu en version
simplifiée : que des écrans en cartes, pas d'onglets Objets/Éléments
de jeu/Variables (réservés à "Créer mon jeu de A à Z")."""
_register(anon_client, "quizuser@example.com")
_confirm_2fa(anon_client)
try:
resp = _complete_onboarding(anon_client, name="Mon Quiz", onboarding_type="quiz")
assert resp.status_code == 302
user = auth.get_user_by_email("quizuser@example.com")
slug = user["project_slug"]
assert resp.headers["Location"] == f"/game/{slug}"
assert db.get_onboarding_type(slug) == "quiz"
screen_list = screens.list_screens(slug)
assert len(screen_list) == 1
assert screen_list[0]["kind"] == "document"
html = anon_client.get(f"/game/{slug}").get_data(as_text=True)
assert "screenCardGrid" in html
assert "+ Ajouter un écran" in html
assert 'name="kind"' not in html # pas de choix de type à l'ajout
assert "openNewObjectPanel" not in html # onglet Objets du dashboard complet absent
# L'éditeur de l'écran, lui, reste atteignable comme avant.
edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True)
assert "scene-editor.js" not in edit_html
finally:
_cleanup_project("quizuser@example.com")
def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_client):
_register(anon_client, "rpguser@example.com")
_confirm_2fa(anon_client)
try:
resp = _complete_onboarding(anon_client, name="Mon RPG", onboarding_type="rpg")
slug = auth.get_user_by_email("rpguser@example.com")["project_slug"]
assert resp.headers["Location"] == f"/game/{slug}"
screen_list = screens.list_screens(slug)
assert screen_list[0]["kind"] == "jeu_2d"
html = anon_client.get(f"/game/{slug}").get_data(as_text=True)
assert "screenPreviewScene" in html
assert 'name="kind"' not in html
# "+ Ajouter un écran" ne propose pas de choix de type — imposé
# jeu_2d par le projet, même en tentant de forcer "document" via
# un POST direct (défense en profondeur, pas seulement l'UI).
anon_client.post(f"/game/{slug}/screens/new", data={"name": "Scène 2", "kind": "document"}, follow_redirects=False)
new_screen_list = screens.list_screens(slug)
assert len(new_screen_list) == 2
assert new_screen_list[1]["kind"] == "jeu_2d"
edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True)
assert "scene-editor.js" in edit_html
finally:
_cleanup_project("rpguser@example.com")
def test_custom_keeps_todays_full_dashboard_behavior(anon_client):
_register(anon_client, "customuser@example.com")
_confirm_2fa(anon_client)
try:
resp = _complete_onboarding(anon_client, name="Mon jeu complet", onboarding_type="custom")
assert resp.status_code == 302
slug = auth.get_user_by_email("customuser@example.com")["project_slug"]
assert resp.headers["Location"] == f"/game/{slug}"
html = anon_client.get(f"/game/{slug}").get_data(as_text=True)
assert "Tableau de bord" in html or "gameNavLink" in html
finally:
_cleanup_project("customuser@example.com")
def test_onboarding_rejects_unknown_type(anon_client):
_register(anon_client, "badtype@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/onboarding", data={"onboarding_type": "n_importe_quoi", "name": "Test"})
assert resp.status_code == 200
assert auth.get_user_by_email("badtype@example.com")["project_slug"] is None
finally:
_cleanup_project("badtype@example.com")
def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client):
_register(anon_client, "revisit@example.com")
_confirm_2fa(anon_client)
try:
first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="custom")
first_slug = auth.get_user_by_email("revisit@example.com")["project_slug"]
resp = anon_client.get("/onboarding", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == first.headers["Location"]
again = anon_client.post("/onboarding", data={"onboarding_type": "quiz", "name": "Second jeu"}, follow_redirects=False)
assert auth.get_user_by_email("revisit@example.com")["project_slug"] == first_slug
assert again.headers["Location"] == first.headers["Location"]
finally:
_cleanup_project("revisit@example.com")
def test_admin_is_never_redirected_to_onboarding(client, game):
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 200
def test_admin_can_create_several_games_of_different_types_via_onboarding(client):
"""Comportement systématique à CHAQUE création de jeu, pas une
formalité réservée à la toute première inscription (voir le plan) —
un admin, illimité (pas de project_slug dédié), doit pouvoir repasser
par /onboarding autant de fois qu'il crée un nouveau jeu, sans jamais
être bloqué/redirigé vers un projet précédent."""
resp1 = _complete_onboarding(client, name="Jeu Quiz Admin", onboarding_type="quiz")
assert resp1.status_code == 302
slug1 = resp1.headers["Location"].split("/game/", 1)[1].split("/", 1)[0]
slug2 = None
try:
# project_slug reste NON posé sur le compte admin (voir
# auth/create_user.py — colonne réservée à un compte "user" à
# projet unique) : repasser sur /onboarding ne redirige PAS vers
# le premier projet créé, la page de choix s'affiche à nouveau.
resp = client.get("/onboarding")
assert resp.status_code == 200
assert "Quiz" in resp.get_data(as_text=True)
resp2 = _complete_onboarding(client, name="Jeu RPG Admin", onboarding_type="rpg")
assert resp2.status_code == 302
slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0]
assert slug2 != slug1
slugs = {g["slug"] for g in db.list_games()}
assert slug1 in slugs
assert slug2 in slugs
finally:
for slug in (slug1, slug2):
if slug and os.path.isdir(db.game_dir(slug)):
db.delete_game(slug)
+8 -2
View File
@@ -5,8 +5,9 @@ import pyotp
import auth import auth
import db import db
from db.games.project_slug import build_slug, split_slug
from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401 from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401
def test_profile_page_shows_current_user_info(anon_client): def test_profile_page_shows_current_user_info(anon_client):
@@ -122,6 +123,7 @@ def test_delete_account_requires_the_confirmation_phrase(anon_client):
def test_delete_account_removes_user_and_their_project(anon_client): def test_delete_account_removes_user_and_their_project(anon_client):
_register(anon_client, "deleteforreal@example.com") _register(anon_client, "deleteforreal@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("deleteforreal@example.com") user = auth.get_user_by_email("deleteforreal@example.com")
slug = user["project_slug"] slug = user["project_slug"]
import os import os
@@ -218,6 +220,7 @@ def test_update_email_renames_the_user_project_folder(anon_client):
_register(anon_client, "oldmail@example.com") _register(anon_client, "oldmail@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
old_user = auth.get_user_by_email("oldmail@example.com") old_user = auth.get_user_by_email("oldmail@example.com")
old_slug = old_user["project_slug"] old_slug = old_user["project_slug"]
assert os.path.isdir(db.game_dir(old_slug)) assert os.path.isdir(db.game_dir(old_slug))
@@ -229,7 +232,10 @@ def test_update_email_renames_the_user_project_folder(anon_client):
new_user = auth.get_user_by_email("newmail@example.com") new_user = auth.get_user_by_email("newmail@example.com")
assert new_user is not None assert new_user is not None
new_slug = new_user["project_slug"] new_slug = new_user["project_slug"]
assert new_slug == db.slugify("newmail@example.com") # Structure de dossiers par utilisateur (voir db/games/
# project_slug.py) : seul le dossier PROPRIÉTAIRE change de nom,
# le dossier projet (project_part) reste le même.
assert new_slug == build_slug(db.slugify("newmail@example.com"), split_slug(old_slug)[1])
assert not os.path.isdir(db.game_dir(old_slug)) assert not os.path.isdir(db.game_dir(old_slug))
assert os.path.isdir(db.game_dir(new_slug)) assert os.path.isdir(db.game_dir(new_slug))
+2 -1
View File
@@ -80,10 +80,11 @@ def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_clie
code supplémentaire — un compte non-admin ne doit pas pouvoir publier 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, 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).""" voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
from tests.test_auth import _register, _confirm_2fa, _cleanup_project from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "publishisolation@example.com") _register(anon_client, "publishisolation@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try: try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish") resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
assert resp.status_code == 403 assert resp.status_code == 403