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",
"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
# 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
def _require_login_and_enforce_project_isolation():
@@ -61,9 +72,22 @@ def _require_login_and_enforce_project_isolation():
if user["role"] != "admin":
if endpoint in ("index", "games_new") and user.get("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
if slug is not None and slug != user.get("project_slug"):
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":
# 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
+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.
"""
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 .table_name_for import table_name_for
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.delete_game import delete_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.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_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.get_custom_event import get_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__ = [
"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",
"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",
"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",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition",
+8 -5
View File
@@ -1,6 +1,6 @@
from ..connection import connect
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):
@@ -14,8 +14,11 @@ def game_meta(slug):
# 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),
# Type de jeu (fondations multi-éditeurs) : "document" (éditeur
# générique) ou "jeu_2d" (éditeur de scène) — voir
# get_game_type.py et routes/screens/screen_edit.py.
"game_type": get_game_type(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).
"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)
if user["role"] == "admin":
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 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
import auth
import db
from core.flask_app import app
@@ -28,16 +27,6 @@ def register_2fa():
else:
auth.reset_failed_attempts(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["user_id"] = user_id
# 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)
if user["role"] == "admin":
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"])
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
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
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)
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)
# 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
+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
import db
import screens
from core.flask_app import app
@@ -8,7 +9,19 @@ from core.flask_app import app
@app.route("/game/<slug>/screens/new", methods=["POST"])
def screens_new(slug):
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:
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("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; }
.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 input{ flex:1; }
.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; }
.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) ---------- */
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
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
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
<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>
<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
+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="colLeft">
<h2>Créer un jeu</h2>
<form method="post" action="{{ url_for('games_new') }}" class="card">
<div class="formField">
<label for="name">Nom du nouveau jeu</label>
<input type="text" id="name" name="name" placeholder="ex. CyberQuest RGPD" required>
<!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) :
comportement SYSTÉMATIQUE à chaque création de jeu, pas une
formalité réservée à l'inscription — un admin y repasse à
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>
<!-- 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 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):
"""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
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)
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
code = pyotp.TOTP(secret).now()
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):
game_dir = db.game_dir(db.slugify(email))
import os
import shutil
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
user = auth.get_user_by_email(email)
if user and user.get("project_slug"):
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):
@@ -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")
assert user["totp_confirmed"] == 1
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:
assert resp.headers["Location"] == "/game/" + user["project_slug"]
# Connecté pour de vrai maintenant.
assert resp.headers["Location"] == "/onboarding"
# 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)
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:
_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):
_register(anon_client, "login@example.com")
resp, secret = _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
slug = auth.get_user_by_email("login@example.com")["project_slug"]
try:
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):
_register(anon_client, "isolated@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("isolated@example.com")
try:
# 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é."""
_register(anon_client, "nodelete@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("nodelete@example.com")
try:
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):
_register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("recoverylogin@example.com")
slug = user["project_slug"]
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 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):
@@ -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):
_register(anon_client, "deleteforreal@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("deleteforreal@example.com")
slug = user["project_slug"]
import os
@@ -218,6 +220,7 @@ def test_update_email_renames_the_user_project_folder(anon_client):
_register(anon_client, "oldmail@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
old_user = auth.get_user_by_email("oldmail@example.com")
old_slug = old_user["project_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")
assert new_user is not None
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 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
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, _cleanup_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