Compare commits
2
Commits
b1c995ab46
...
a16a30683c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16a30683c | ||
|
|
7ebc9b143f |
@@ -1,748 +1,143 @@
|
|||||||
# Forge Engine — prototype (lot de fonctionnalités n°1)
|
# Forge Engine
|
||||||
|
|
||||||
Outil no-code : créer des jeux, définir leurs objets de données (comme des
|
Éditeur no-code de serious games 2D pour la formation professionnelle,
|
||||||
tables de base de données, avec des champs typés et des relations entre
|
dans le navigateur. Créé pour un public **non technique** (formateurs,
|
||||||
eux), et remplir ces données via des formulaires générés automatiquement.
|
RH) : aucune ligne de code, aucun graphe de logique générique — un
|
||||||
|
assistant pas à pas pour chaque mécanique de jeu.
|
||||||
|
|
||||||
Python + SQLite + Flask. Chaque jeu créé a sa propre base de données réelle
|
> Voir `.claude/Forge_Engine_Cadrage.pdf` (non versionné, document
|
||||||
(fichier `.db`), pas de simulation : définir un objet exécute un vrai
|
> business interne) pour le positionnement produit complet et l'analyse
|
||||||
`CREATE TABLE`, remplir le formulaire exécute un vrai `INSERT`.
|
> de marché qui a guidé les choix ci-dessous.
|
||||||
|
|
||||||
## Lancer (pour tester maintenant, en ligne de commande)
|
## Lancer le projet
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd forge-engine
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python3 app.py
|
python3 app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Le navigateur s'ouvre automatiquement sur http://127.0.0.1:5050.
|
Le navigateur s'ouvre automatiquement sur http://127.0.0.1:5050.
|
||||||
|
|
||||||
*(Ceci est la version de développement. Comme convenu, une version finale
|
## Ce que permet Forge Engine aujourd'hui
|
||||||
empaquetée — double-clic, sans terminal ni installation — sera produite une
|
|
||||||
fois que toutes les fonctionnalités prévues auront été ajoutées.)*
|
|
||||||
|
|
||||||
## Ce qui est construit dans ce lot
|
Chaque jeu créé est un **RPG 2D** : une ou plusieurs scènes (des décors à
|
||||||
|
taille fixe), sur lesquelles on pose des personnages, un fond, et des
|
||||||
|
widgets d'interface — tout au glisser-déposer, sans jamais toucher à du
|
||||||
|
HTML/CSS/JS.
|
||||||
|
|
||||||
1. **Créer un jeu** : bouton sur la page d'accueil → crée
|
- **Personnages** : bibliothèque de sprites prêts à l'emploi (personnages
|
||||||
`projects/<nom-du-jeu>/index.html` + `index.css` + `index.js` (reliés
|
Kenney CC0 + animaux CraftPix pour les comptes admin), animés
|
||||||
entre eux), et `projects/<nom-du-jeu>/game.db` (base SQLite dédiée).
|
automatiquement (idle, marche...). Un rôle — **Joueur** (déplacement au
|
||||||
2. **Définir un objet** (comme une table de base de données) : nom + liste
|
clavier, suivi par la caméra), **PNJ** ou **Ennemi** — se choisit
|
||||||
de champs typés (texte, texte long, nombre entier, nombre décimal,
|
explicitement par personnage ; un personnage fraîchement posé est
|
||||||
oui/non, date, relation vers un autre objet déjà défini dans ce jeu).
|
toujours "PNJ" par défaut.
|
||||||
Chaque objet défini devient une vraie table SQL (`obj_<nom>`), avec les
|
- **Collision** (onglet dédié de l'éditeur de scène) : pour chaque objet
|
||||||
bonnes colonnes et, pour les relations, une vraie clé étrangère.
|
de la scène (jamais le joueur lui-même), un assistant "+ Action"
|
||||||
3. **Formulaire généré automatiquement** : une fois un objet défini, le
|
construit une règle "déclencheur → action" pièce par pièce (jamais un
|
||||||
moteur lit sa définition et construit lui-même le formulaire de saisie
|
formulaire à plusieurs champs) — à la collision ou dans un périmètre,
|
||||||
adapté (bon type de champ HTML selon le type choisi, menu déroulant
|
déclencher une quête, ou une interaction au clavier ("Appuie sur ...")
|
||||||
peuplé avec les entrées existantes pour les relations). Valider le
|
qui elle-même déclenche une quête. Toute la liste se met à jour en
|
||||||
formulaire enregistre une vraie ligne dans la table SQL correspondante.
|
AJAX (ajout/suppression d'objet, changement de rôle/nom), sans jamais
|
||||||
4. **CRUD complet** sur les trois niveaux :
|
recharger la page.
|
||||||
- **Jeu** : renommer, supprimer (dossier + base de données).
|
- **Quêtes & dialogues** : chaque quête a un titre, un objectif, une
|
||||||
- **Objet** (page "✏️ Modifier cet objet") : renommer, ajouter un champ
|
récompense, un statut et un résultat ; son dialogue se rédige
|
||||||
(vrai `ALTER TABLE ... ADD COLUMN`), retirer un champ (vrai
|
séparément (3 colonnes — Nouvelle / En cours / Terminée — pour faire
|
||||||
`ALTER TABLE ... DROP COLUMN`), supprimer l'objet entier (vrai
|
varier ce que dit un PNJ selon l'avancement).
|
||||||
`DROP TABLE`) — **bloqué** si un autre objet a une relation vers
|
- **Widgets d'interface** : Boîte de dialogue, Boîte à quiz, Score — posés
|
||||||
celui-ci, avec indication de quel(s) objet(s).
|
comme n'importe quel objet de scène, gérés automatiquement par le
|
||||||
- **Donnée** (une ligne) : modifier, supprimer — **bloqué** si une autre
|
moteur de jeu (jamais de logique à câbler), avec leur propre style
|
||||||
ligne pointe vers elle via une relation, avec indication de combien et
|
(police, couleurs) réglable une fois posés.
|
||||||
depuis quel objet.
|
- **Export SCORM 1.2** natif (`routes/publish/export_scorm.py`) : un
|
||||||
|
paquet .zip autonome (imsmanifest.xml + runtime hors-ligne), prêt à
|
||||||
|
déposer dans un LMS (Moodle, 360Learning...) — score et statut de la
|
||||||
|
partie remontent automatiquement.
|
||||||
|
|
||||||
5. **Écrans de jeu** (`🖥️ Écrans` depuis le tableau de bord d'un jeu) —
|
### Onboarding
|
||||||
éditeur façon Elementor/Bricks Builder, pensé pour quelqu'un qui ne
|
|
||||||
connaît ni le HTML ni le CSS :
|
|
||||||
- **3 panneaux** : à gauche, une grille d'icônes pour ajouter un élément
|
|
||||||
— aucun nom de balise HTML visible nulle part. Au centre, l'écran en
|
|
||||||
très grand (repères Portrait / Paysage / Carré, juste visuels — l'écran
|
|
||||||
réel du joueur reste toujours responsive). À droite, les propriétés de
|
|
||||||
l'élément sélectionné, groupées par thème et repliables comme un
|
|
||||||
accordéon (Position & taille, Contenu, Texte, Disposition, Espacement,
|
|
||||||
Bordure, Actions...) — un seul groupe utile ouvert par défaut, les
|
|
||||||
autres se déplient d'un clic sur leur titre, pour garder le panneau
|
|
||||||
lisible même avec beaucoup de réglages.
|
|
||||||
- **20 types d'éléments** : Texte, Titre, Bouton, Lien, Image (URL ou
|
|
||||||
fichier envoyé depuis ton ordinateur — l'envoi enregistre aussitôt
|
|
||||||
l'image, sans clic supplémentaire à faire pour la voir apparaître),
|
|
||||||
Vidéo (idem), Champ de
|
|
||||||
formulaire — texte / email / mot de passe, Case à cocher, Bouton radio,
|
|
||||||
Zone de texte, Liste déroulante, Groupe de champs, Liste à puces, Liste
|
|
||||||
numérotée, Tableau, Conteneur / décor, Séparateur, Élément de jeu du
|
|
||||||
catalogue, et le **Répéteur de données** (voir plus bas).
|
|
||||||
- **Un réglage = un seul champ adapté** : case à cocher pour "gras" ou
|
|
||||||
"obligatoire", curseur pour une taille/un arrondi/un espacement, pastille
|
|
||||||
de couleur, boutons ⬅️/⬛/➡️/☰ pour l'alignement — jamais de nom de
|
|
||||||
propriété CSS à taper. Chaque élément dispose maintenant, en plus de ses
|
|
||||||
réglages propres, d'un socle commun : **visibilité** (visible / masqué /
|
|
||||||
invisible-mais-garde-sa-place), **espacement** (marge intérieure et
|
|
||||||
extérieure), **bordure** (épaisseur, couleur, style), et pour les
|
|
||||||
conteneurs/listes/tableaux, une **disposition interne** façon flexbox
|
|
||||||
(empilement libre / en ligne / en colonne, avec l'espacement entre
|
|
||||||
éléments). Les textes ont en plus gras, italique, souligné, barré et un
|
|
||||||
choix de police (polices système + quelques Google Fonts).
|
|
||||||
- **Position & taille visibles et modifiables** : 4 champs (X, Y,
|
|
||||||
Largeur, Hauteur, en %) dans le panneau de droite, synchronisés avec le
|
|
||||||
glisser-déposer sur l'écran.
|
|
||||||
- **Glisser-déposer et redimensionnement à la souris**, exactement comme
|
|
||||||
avant : on peut vraiment attraper un élément et le faire glisser sur
|
|
||||||
l'écran (un simple clic sans glisser le sélectionne juste). Sans jamais
|
|
||||||
toucher à un fichier HTML/CSS : tout est stocké en base et généré à la
|
|
||||||
volée.
|
|
||||||
- **ID unique par élément** : chaque élément affiche son identifiant
|
|
||||||
(`elt-<numéro>`) dans ses propriétés — attribué automatiquement, garanti
|
|
||||||
unique dans le jeu.
|
|
||||||
- **Enchaînement des écrans** : liste ordonnée (premier écran, deuxième
|
|
||||||
écran...), réorganisable (↑ / ↓).
|
|
||||||
- **🔀 Logique de la scène (éditeur de flow à nœuds)** : la logique d'un
|
|
||||||
écran (ce qui se passe au clic ou à la soumission d'un formulaire) ne
|
|
||||||
se règle plus dans les propriétés de chaque élément, mais dans un
|
|
||||||
panneau dédié, rétractable, en bas de l'éditeur d'écran — un vrai
|
|
||||||
canevas où l'on pose des **nœuds** et où on les relie par des fils,
|
|
||||||
dans l'esprit de Salesforce Flow ou d'Unity Visual Scripting, mais
|
|
||||||
appliqué directement aux éléments et aux objets de données déjà
|
|
||||||
définis dans le jeu :
|
|
||||||
- **Déclencheur** (contour bleu) : "Au clic sur..." ou "À la
|
|
||||||
soumission de..." un élément de l'écran — c'est le point de départ
|
|
||||||
du graphe.
|
|
||||||
- **Condition** (contour orange) : compare un champ d'une ligne d'un
|
|
||||||
objet à une valeur (égal, différent, supérieur, inférieur...), et
|
|
||||||
propose deux fils de sortie, **Vrai** et **Faux**, à relier chacun à
|
|
||||||
la suite du graphe qui doit s'exécuter dans ce cas.
|
|
||||||
- **Action** (contour vert) : les mêmes effets qu'avant — changer
|
|
||||||
d'écran, modifier un élément (visibilité, couleurs, taille), ou
|
|
||||||
modifier une donnée d'un objet (texte, nombre, Vrai/Faux, bascule,
|
|
||||||
+/- un montant).
|
|
||||||
Pour relier deux nœuds : clique le point de sortie du premier (côté
|
|
||||||
droit), puis le point d'entrée du second (côté gauche) — un fil
|
|
||||||
apparaît. Reclique un fil pour le supprimer. Chaque nœud peut être
|
|
||||||
glissé sur le canevas pour organiser le graphe, et supprimé avec sa
|
|
||||||
petite croix. Une action "modifier une donnée" met à jour la vraie
|
|
||||||
base de données du jeu, et tout Répéteur de données affichant cet
|
|
||||||
objet à l'écran se met aussitôt à jour tout seul, sans recharger la
|
|
||||||
page.
|
|
||||||
**Plusieurs actions pour un même déclencheur** : le point de sortie
|
|
||||||
d'un nœud (Déclencheur, ou une branche Vrai/Faux d'une Condition) peut
|
|
||||||
être relié à PLUSIEURS nœuds suivants — au clic, tous s'exécutent (pas
|
|
||||||
seulement le premier relié).
|
|
||||||
|
|
||||||
**Effet "va-et-vient" (état à deux positions)** : pour une action
|
Un seul parcours de création à l'inscription : **RPG**. La carte de choix
|
||||||
"Modifier un élément" sur une couleur, une largeur ou une hauteur, une
|
(`templates/onboarding/onboarding_new.html`) se retourne pour présenter
|
||||||
case à cocher "Va-et-vient" fait apparaître une deuxième valeur — le
|
l'argumentaire produit.
|
||||||
1er clic applique la 2de valeur, le 2e clic revient à la 1re, et ainsi
|
|
||||||
de suite indéfiniment (par exemple : couleur de fond blanche au
|
|
||||||
départ, bleue au clic, blanche au reclic...). Cet état est mémorisé
|
|
||||||
uniquement dans la page du joueur (pas en base) : il repart de zéro si
|
|
||||||
l'écran est rechargé.
|
|
||||||
|
|
||||||
**Limites connues de cette première version** : un nœud ne peut pas
|
### Ce qui a été délibérément retiré
|
||||||
être modifié une fois créé (il faut le supprimer et en recréer un avec
|
|
||||||
les bons réglages) ; une condition compare toujours une ligne précise
|
|
||||||
d'un objet choisie à la création du nœud (pas encore "la ligne sur
|
|
||||||
laquelle le joueur vient de cliquer" dans un Répéteur) ; il n'y a pas
|
|
||||||
encore de nœud "ET / OU" pour combiner plusieurs conditions avant un
|
|
||||||
branchement ; l'effet "va-et-vient" ne survit pas à un rechargement de
|
|
||||||
page (pour un état qui doit être sauvegardé, utiliser plutôt une
|
|
||||||
Condition sur un champ Oui/Non d'un objet, combinée à une action
|
|
||||||
"Modifier une donnée" en "Basculer Vrai/Faux").
|
|
||||||
- **Mise en page de l'éditeur d'écran, revue** : la page ne défile plus
|
|
||||||
jamais elle-même — elle occupe exactement la hauteur de la fenêtre.
|
|
||||||
Les trois panneaux (ajout d'éléments, écran, propriétés) s'arrêtent
|
|
||||||
tous au même niveau en bas, et défilent chacun pour soi si leur
|
|
||||||
contenu dépasse (la liste d'éléments, l'écran lui-même en orientation
|
|
||||||
portrait sur un grand écran, etc.). La grille "Ajouter un élément" est
|
|
||||||
désormais repliable (clique son titre) pour libérer de la place. Le
|
|
||||||
panneau "🔀 Logique de la scène", une fois ouvert, partage la hauteur
|
|
||||||
de la fenêtre avec le reste au lieu de s'afficher par-dessus : une
|
|
||||||
poignée juste au-dessus de son titre permet de régler sa hauteur à la
|
|
||||||
souris (glisser vers le haut pour l'agrandir, vers le bas pour le
|
|
||||||
réduire).
|
|
||||||
- **Répéteur de données** : relie un élément à un objet défini dans ce jeu
|
|
||||||
(voir "Définir un objet" plus haut) et affiche automatiquement une ligne
|
|
||||||
par donnée existante, à partir d'un modèle de texte avec des
|
|
||||||
`{{nom_du_champ}}` — par exemple, un objet "Niveau" avec des champs
|
|
||||||
"nom" et "description" peut s'afficher comme `{{nom}} — {{description}}`
|
|
||||||
répété pour chaque niveau existant, sans rien recopier à la main. La
|
|
||||||
disposition interne (empilement/ligne/colonne + espacement) s'applique
|
|
||||||
à la liste générée. Réglage "Modèle de ligne" : au lieu du modèle de
|
|
||||||
texte, on peut choisir un élément de jeu du catalogue — chaque ligne
|
|
||||||
est alors affichée avec sa mise en forme complète (voir "Éléments de
|
|
||||||
jeu" ci-dessous) plutôt qu'en texte brut.
|
|
||||||
- **Imbrication réelle d'éléments** : un Conteneur / décor, un Répéteur de
|
|
||||||
données ou un Groupe de champs peut désormais contenir d'autres
|
|
||||||
éléments *physiquement* — texte, image, bouton, un autre conteneur...
|
|
||||||
posés dedans plutôt qu'à côté. Dans le panneau de droite, un élément de
|
|
||||||
ce type affiche une section "Contenu du conteneur" : la liste de ce
|
|
||||||
qu'il contient déjà, et la même grille d'icônes que "Ajouter un
|
|
||||||
élément" pour y placer un nouvel élément. Comme ces éléments deviennent
|
|
||||||
de vrais enfants dans la page générée, la disposition interne (ligne /
|
|
||||||
colonne), l'espacement intérieur (padding) et la bordure du parent
|
|
||||||
s'appliquent réellement à eux — exactement comme le ferait une vraie
|
|
||||||
mise en page flexbox. Un élément posé à l'intérieur d'un Répéteur de
|
|
||||||
données reçoit lui aussi les `{{nom_du_champ}}` de la ligne en cours :
|
|
||||||
il se répète donc avec le reste. L'imbrication peut se faire sur
|
|
||||||
plusieurs niveaux (un conteneur dans un conteneur, etc.). Un élément
|
|
||||||
posé à l'intérieur d'un autre garde sa hauteur naturelle (comme sur une
|
|
||||||
vraie page web) au lieu de forcer 100% de la hauteur du parent — sinon
|
|
||||||
un deuxième élément ajouté dans le même conteneur se retrouvait poussé
|
|
||||||
hors de la zone visible et disparaissait.
|
|
||||||
- **Échelle (zoom)** : un nouveau réglage curseur, disponible sur tous les
|
|
||||||
éléments (dans "Disposition"), pour agrandir ou réduire un élément sans
|
|
||||||
toucher à sa largeur/hauteur — utile par exemple pour un effet de
|
|
||||||
zoom au survol ou une icône plus petite que sa zone cliquable.
|
|
||||||
- **Disposition interne complète** (conteneurs, répéteurs, listes,
|
|
||||||
tableaux, groupes de champs) : en plus de ligne/colonne, les variantes
|
|
||||||
inversées (ordre inversé), l'alignement transversal (étirés / au
|
|
||||||
début / centrés / à la fin), la répartition dans le sens de la
|
|
||||||
disposition (au début / au centre / à la fin / espacement égal entre,
|
|
||||||
autour, ou uniforme) et une case "autoriser le retour à la ligne" —
|
|
||||||
de quoi retrouver, avec des mots simples, tout ce qu'une vraie
|
|
||||||
disposition flexbox permet de régler.
|
|
||||||
- **Taille dans le conteneur** (nouveau réglage "Taille dans le
|
|
||||||
conteneur", disponible sur tous les éléments) : deux curseurs Largeur /
|
|
||||||
Hauteur en pixels, à 0 par défaut (comportement automatique — un
|
|
||||||
élément posé à l'intérieur d'un conteneur/répéteur/groupe de champs
|
|
||||||
remplit la largeur disponible et s'ajuste en hauteur à son contenu).
|
|
||||||
Une valeur non nulle impose une taille fixe et n'est plus comprimée par
|
|
||||||
la disposition flex environnante : c'est ce qui permet de redimensionner
|
|
||||||
une image (ou n'importe quel autre élément) posée à l'intérieur d'un
|
|
||||||
conteneur — jusqu'ici impossible.
|
|
||||||
- **Correction — collision en disposition "ligne"** : dans un conteneur
|
|
||||||
réglé sur "Alignés côte à côte (ligne)", chaque élément posé à
|
|
||||||
l'intérieur réclamait par défaut 100% de la largeur du conteneur (comme
|
|
||||||
dans les autres dispositions) — deux éléments côte à côte se
|
|
||||||
retrouvaient donc à se disputer toute la largeur, et la disposition
|
|
||||||
flex les comprimait fortement pour les faire tenir (un des deux, souvent
|
|
||||||
une image, pouvait finir écrasé à quelques pixels de large, voire
|
|
||||||
disparaître visuellement). Par défaut, un élément posé dans une
|
|
||||||
disposition en ligne prend maintenant sa taille naturelle (comme le
|
|
||||||
ferait un élément Elementor/Webflow), ce qui rend aussi immédiatement
|
|
||||||
visibles "Espacement égal entre eux / autour / uniforme" : tant que
|
|
||||||
chaque élément occupait 100% de la largeur, il n'y avait aucun espace
|
|
||||||
restant à répartir entre eux.
|
|
||||||
- **Image — ajustement dans son cadre (object-fit)** : nouveau réglage
|
|
||||||
(Remplir en recadrant / Tout montrer sans déformer / Étirer) qui
|
|
||||||
contrôle comment l'image se comporte quand sa taille réelle ne
|
|
||||||
correspond pas exactement à la taille de son cadre — utile dès qu'une
|
|
||||||
largeur et une hauteur fixes sont données à une image (voir "Taille
|
|
||||||
dans le conteneur" ci-dessus) pour éviter qu'elle soit déformée.
|
|
||||||
"Remplir en recadrant" est désormais la valeur par défaut (avant, une
|
|
||||||
image étirée dans un cadre qui n'avait pas ses proportions était
|
|
||||||
déformée sans recours).
|
|
||||||
- **Nommer un élément** : chaque élément peut recevoir un nom (ex: "Bouton
|
|
||||||
Valider", "Image du héros") dans ses propriétés, pour s'y retrouver
|
|
||||||
dans les listes et dans le choix d'une cible d'action sans avoir à
|
|
||||||
deviner lequel est lequel parmi plusieurs éléments du même type. Sans
|
|
||||||
nom, l'élément garde son libellé de type par défaut (ex: "Image").
|
|
||||||
- **Sélection directe d'un élément imbriqué** : plus besoin de repasser
|
|
||||||
par la liste "Éléments de cet écran" à chaque fois — un clic sur
|
|
||||||
l'écran sélectionne précisément l'élément touché, même s'il est
|
|
||||||
physiquement à l'intérieur d'un conteneur, d'un répéteur ou d'un
|
|
||||||
groupe de champs (le glisser-déposer, lui, continue de déplacer la
|
|
||||||
boîte de plus haut niveau, comme avant).
|
|
||||||
- **Éléments de jeu** (`🧩` depuis le tableau de bord) : un catalogue de
|
|
||||||
conteneurs réutilisables (Personnage, Outil, icône de mail...), chacun
|
|
||||||
bâti exactement comme un écran — on ouvre "✏️ Modifier le contenu" et on
|
|
||||||
y imbrique/stylise des éléments avec l'éditeur d'écran normal (c'est en
|
|
||||||
réalité un écran caché, invisible dans la liste des écrans du jeu et en
|
|
||||||
mode jouable). On peut optionnellement le lier à un objet, pour utiliser
|
|
||||||
`{{nom_du_champ}}` dans son contenu. Une fois construit, il a deux
|
|
||||||
usages :
|
|
||||||
- **Posé directement sur un écran** (bouton "🧩 Élément de jeu" dans
|
|
||||||
"+ Ajouter un élément") : tout son contenu est copié en profondeur en
|
|
||||||
éléments indépendants sur cet écran, modifiables séparément par la
|
|
||||||
suite — comme avant. Limite de cette version : si l'élément est lié à
|
|
||||||
un objet, `{{nom_du_champ}}` n'est alors résolu par aucune ligne
|
|
||||||
précise et reste affiché tel quel ; ce mode convient surtout aux
|
|
||||||
éléments décoratifs (icônes, outils, décor) sans lien à un objet.
|
|
||||||
- **Choisi comme "Modèle de ligne" d'un Répéteur de données** (réglage
|
|
||||||
du Répéteur, à la place du modèle de texte) : pour chaque ligne de
|
|
||||||
l'objet affiché par le Répéteur, son contenu est réaffiché en direct
|
|
||||||
(jamais copié en base) avec les `{{nom_du_champ}}` de cette ligne —
|
|
||||||
c'est ainsi qu'un objet "Mail" avec 5 lignes peut s'afficher comme 5
|
|
||||||
cartes stylisées (icône + sujet + expéditeur...) plutôt que comme du
|
|
||||||
texte brut. Le Répéteur et l'éditeur d'écran n'ont besoin de rien
|
|
||||||
connaître du contenu de l'élément de jeu utilisé : la mise en forme
|
|
||||||
vit entièrement dans le catalogue, réutilisable d'un écran (ou d'un
|
|
||||||
jeu) à l'autre.
|
|
||||||
Supprimer un élément de jeu du catalogue supprime aussi son contenu
|
|
||||||
(l'écran caché) ; c'est bloqué tant qu'au moins un exemplaire est posé
|
|
||||||
directement sur un écran (voir "Suppression protégée" ci-dessous).
|
|
||||||
- **Mode jouable** (`▶️ Jouer`) : joue réellement l'enchaînement des
|
|
||||||
écrans et déclenche les actions au clic, dans le navigateur, à partir
|
|
||||||
des données enregistrées (y compris les Répéteurs, lus en direct) —
|
|
||||||
aucun fichier généré.
|
|
||||||
- Suppression protégée : un écran ciblé par une action, ou un élément de
|
|
||||||
jeu posé sur un écran, ne peuvent pas être supprimés tant que la
|
|
||||||
référence existe.
|
|
||||||
- Limite volontaire de cette version : le Tableau et la Liste déroulante
|
|
||||||
se remplissent encore via un champ de texte (une ligne par entrée), pas
|
|
||||||
en y glissant d'autres éléments un par un — l'imbrication réelle
|
|
||||||
(Conteneur / Répéteur de données / Groupe de champs) est, elle,
|
|
||||||
disponible (voir "Imbrication réelle d'éléments" ci-dessus).
|
|
||||||
- **Corrections** : le bouton "Définir un nouvel objet" du tableau de bord
|
|
||||||
a désormais le même style de carte que les autres raccourcis (Écrans,
|
|
||||||
Éléments de jeu, Jouer) ; une erreur `database is locked` pouvait
|
|
||||||
survenir en supprimant un élément (deux requêtes SQLite concurrentes
|
|
||||||
sur le serveur de développement) — corrigé en activant le mode WAL et
|
|
||||||
un délai d'attente sur les verrous de la base.
|
|
||||||
- **Confusion "Ajouter un élément" vs "Ajouter DANS ce conteneur"** :
|
|
||||||
les deux grilles se ressemblaient à l'identique (mêmes icônes, même
|
|
||||||
mise en page), alors qu'elles ne posent pas l'élément au même endroit
|
|
||||||
— celle de gauche le pose directement SUR L'ÉCRAN, celle de droite (qui
|
|
||||||
n'apparaît que si un conteneur/répéteur/groupe de champs est
|
|
||||||
sélectionné) le pose À L'INTÉRIEUR de cet élément. Cliquer par erreur
|
|
||||||
sur celle de gauche pendant qu'un conteneur est sélectionné posait un
|
|
||||||
nouvel élément par-dessus tout le reste (toujours à la même position
|
|
||||||
par défaut), qui pouvait donner l'impression trompeuse qu'un "élément
|
|
||||||
indésirable" venait d'apparaître et d'en cacher un autre. Corrigé par
|
|
||||||
trois changements : la grille de gauche s'appelle maintenant "Ajouter
|
|
||||||
un élément **sur l'écran**" (sans ambiguïté) ; celle de droite
|
|
||||||
s'appelle "📥 Ajouter **DANS ce conteneur**" avec un habillage vert
|
|
||||||
distinct ; et un avertissement apparaît dans le panneau de gauche
|
|
||||||
quand un conteneur est sélectionné, pour rappeler laquelle des deux
|
|
||||||
grilles utiliser. Un nouvel élément posé directement sur l'écran est
|
|
||||||
aussi désormais légèrement décalé par rapport au précédent (au lieu de
|
|
||||||
toujours atterrir exactement à la même position), pour limiter les
|
|
||||||
recouvrements accidentels même en cas d'usage normal.
|
|
||||||
- **Une couleur de fond apparaissait toute seule en changeant la
|
|
||||||
disposition/l'alignement d'un conteneur, et un élément pouvait
|
|
||||||
"disparaître"** : le panneau de propriétés d'un élément envoie TOUS ses
|
|
||||||
réglages dans un seul formulaire — donc enregistrer un changement de
|
|
||||||
disposition envoyait aussi, par exemple, le champ de couleur de fond,
|
|
||||||
même non modifié. Or un `<input type="color">` affiche toujours une
|
|
||||||
valeur (la couleur par défaut du réglage, purement indicative, quand
|
|
||||||
rien n'a encore été choisi) — cette valeur d'aperçu était donc écrite
|
|
||||||
"en dur" dans le style à chaque enregistrement, quel que soit le champ
|
|
||||||
réellement modifié, ce qui faisait apparaître un fond qui n'avait
|
|
||||||
jamais été demandé (et, en s'accumulant avec d'autres réglages par
|
|
||||||
défaut, pouvait rendre un élément visuellement méconnaissable). Corrigé
|
|
||||||
à la racine : un réglage n'est désormais écrit dans le style/les
|
|
||||||
attributs que s'il a été explicitement modifié — sa valeur par défaut
|
|
||||||
reste un simple aperçu tant qu'on n'y touche pas. Les réglages dont la
|
|
||||||
valeur par défaut a un effet visuel réellement voulu dès la création
|
|
||||||
(ex. "Ajustement dans son cadre" = cover pour une image, afin qu'elle
|
|
||||||
se recadre proprement une fois redimensionnée plutôt que d'être
|
|
||||||
étirée) sont désormais fixés directement à la pose de l'élément, pas
|
|
||||||
au premier enregistrement du panneau — cela ne dépend donc plus de
|
|
||||||
l'ordre dans lequel les réglages sont modifiés.
|
|
||||||
|
|
||||||
### Sur l'ordre de création avec des relations
|
Une première version du moteur exposait aussi un type d'écran "document"
|
||||||
|
— un éditeur générique façon no-code (blocs de logique en nœuds, timeline
|
||||||
|
d'animation, définitions d'objets/champs/relations façon base de
|
||||||
|
données, templates réutilisables). Cette complexité ne correspond à
|
||||||
|
aucun besoin du public cible (formateurs non techniques) et a été
|
||||||
|
supprimée pour recentrer entièrement le produit sur le jeu 2D — voir
|
||||||
|
l'historique Git pour le détail de ce retrait.
|
||||||
|
|
||||||
Une relation pointe toujours vers un objet **déjà défini**. Donc si tu veux
|
## Ce qui manque encore pour une mise sur le marché (voir le cadrage)
|
||||||
un objet "Niveau" avec un champ relation vers "Parcours", il faut créer
|
|
||||||
l'objet **"Parcours" en premier** (même sans aucune donnée dedans), puis
|
|
||||||
créer "Niveau" et choisir "Parcours" comme objet lié pour son champ
|
|
||||||
relation. C'est l'inverse de l'exemple donné : c'est l'objet **visé** par
|
|
||||||
la relation qui doit exister avant l'objet qui la porte — pas l'objet qui
|
|
||||||
la porte avant l'objet visé.
|
|
||||||
|
|
||||||
### Navigation sans rechargement de page (zéro rechargement)
|
Sur les 6 fonctionnalités listées comme bloquantes (condition d'achat) par
|
||||||
|
le document de cadrage, seul l'**export SCORM** est construit. Restent à
|
||||||
|
faire : support **xAPI**, intégration **LTI 1.3**, **hébergement UE +
|
||||||
|
RGPD** (DPA), accessibilité **RGAA/WCAG 2.2 AA**. L'export HTML5
|
||||||
|
responsive existe déjà via le mode Jouer.
|
||||||
|
|
||||||
Toute la navigation dans l'application (pas seulement l'éditeur d'écran)
|
## Structure du code
|
||||||
passe maintenant par `static/pjax.js`, une petite couche "PJAX" (dans
|
|
||||||
l'esprit de Turbo/Hotwire) : chaque clic sur un lien interne et chaque
|
|
||||||
soumission de formulaire interne est intercepté, la page suivante est
|
|
||||||
récupérée en arrière-plan (`fetch`), et seuls le `<title>`, l'en-tête (fil
|
|
||||||
d'Ariane) et le contenu principal (`<main>`) sont remplacés — au lieu de
|
|
||||||
laisser le navigateur recharger toute la page. L'URL affichée et le bouton
|
|
||||||
"précédent" du navigateur restent corrects grâce à
|
|
||||||
`history.pushState`/`popstate`.
|
|
||||||
|
|
||||||
Pourquoi PJAX plutôt qu'une réécriture complète en SPA (React ou autre) :
|
|
||||||
le moteur reste 100% rendu côté serveur en Jinja2, ce qui est le bon choix
|
|
||||||
pour un outil interne piloté par une base SQLite par jeu — une SPA aurait
|
|
||||||
demandé de dupliquer toute la logique d'affichage côté client (une API
|
|
||||||
JSON, un routeur, un state management) pour un gain quasi nul ici, alors
|
|
||||||
que PJAX obtient le même résultat perçu ("zéro rechargement", historique
|
|
||||||
correct, formulaires qui marchent) en ne touchant qu'à une seule couche
|
|
||||||
fine, sans toucher aux routes Flask ni aux templates existants.
|
|
||||||
|
|
||||||
Points d'attention si tu ajoutes une page avec un script propre à cette
|
|
||||||
page (dans `{% block content %}` ou via `extra_head`) :
|
|
||||||
|
|
||||||
- Ce script est réinjecté et **réexécuté** à chaque navigation vers cette
|
|
||||||
page (y compris quand on y revient plusieurs fois) : évite `let`/`const`
|
|
||||||
au premier niveau du script (une redéclaration lèverait une erreur) — une
|
|
||||||
déclaration de fonction ou une variable `var` est sans risque.
|
|
||||||
- Pour désactiver PJAX sur un lien ou un formulaire précis (rare), ajoute
|
|
||||||
l'attribut `data-no-pjax`. Les liens `target="_blank"` (ex. "▶️ Jouer")
|
|
||||||
et les liens de téléchargement (`download`) sont déjà ignorés
|
|
||||||
automatiquement.
|
|
||||||
- Les formulaires protégés par un `confirm()` JavaScript (ex. suppression)
|
|
||||||
continuent de fonctionner normalement : si l'utilisateur annule la
|
|
||||||
boîte de dialogue, PJAX n'intercepte rien et rien ne se passe.
|
|
||||||
|
|
||||||
### Éditeur d'écran : tout est automatique (déplacer, redimensionner, régler)
|
|
||||||
|
|
||||||
L'éditeur d'écran (`templates/screen_edit.html`) va plus loin que le PJAX
|
|
||||||
général ci-dessus, parce qu'il s'agit d'un usage bien plus intensif :
|
|
||||||
déplacer/redimensionner un élément à la souris et régler ses propriétés
|
|
||||||
sont des actions qu'on répète en continu pendant qu'on construit un écran,
|
|
||||||
pas des navigations occasionnelles. Deux comportements dédiés, indépendants
|
|
||||||
de `pjax.js` :
|
|
||||||
|
|
||||||
- **Glisser-déposer et redimensionnement** ne provoquent plus aucune
|
|
||||||
navigation. Tant que l'élément déplacé/redimensionné reste celui déjà
|
|
||||||
sélectionné, rien n'est rechargé : sa nouvelle position/taille est
|
|
||||||
envoyée en arrière-plan (`fetch`) et les champs "X/Y/Largeur/Hauteur" du
|
|
||||||
panneau de droite sont mis à jour directement en JavaScript. Cliquer
|
|
||||||
(sans glisser) sur un AUTRE élément pour le sélectionner ne recharge pas
|
|
||||||
la page non plus : seul le bloc central de l'éditeur (`#builder3` — la
|
|
||||||
liste d'éléments, le canevas et le panneau de propriétés) est regénéré
|
|
||||||
via une requête en arrière-plan et réinjecté, sans toucher au reste de la
|
|
||||||
page (l'éditeur de logique en bas, par exemple, garde son état).
|
|
||||||
- **Le panneau de propriétés s'enregistre entièrement tout seul** — il n'y
|
|
||||||
a plus de bouton "Enregistrer". Chaque réglage se sauvegarde dès qu'on le
|
|
||||||
change : immédiatement pour une case à cocher, une couleur ou une liste
|
|
||||||
déroulante ; après une courte pause (500 ms) pour un champ texte ou un
|
|
||||||
slider qu'on est en train de glisser, pour éviter d'envoyer une requête à
|
|
||||||
chaque caractère tapé. Un indicateur ("Enregistrement..." / "Enregistré
|
|
||||||
automatiquement ✓") remplace l'ancien bouton. Après chaque sauvegarde,
|
|
||||||
seul le canevas (`#canvas`) est rafraîchi pour refléter le changement
|
|
||||||
visuellement — le formulaire de propriétés lui-même n'est jamais
|
|
||||||
retouché, pour ne jamais faire perdre le focus ou la position du curseur
|
|
||||||
pendant qu'on tape.
|
|
||||||
|
|
||||||
Point d'attention si tu ajoutes un nouveau contrôle ou une nouvelle zone
|
|
||||||
dans ce panneau : toute logique branchée avec `addEventListener` (pas un
|
|
||||||
attribut `onclick`/`onchange` inline) doit être (re)branchée dans
|
|
||||||
`initBuilderPanel()` — cette fonction est rappelée après chaque changement
|
|
||||||
de sélection, puisque `#builder3` est entièrement regénéré à ce moment-là.
|
|
||||||
|
|
||||||
### Cliquer une ligne de Répéteur (déclencheur + action "Ouvrir la ligne cliquée")
|
|
||||||
|
|
||||||
Un Répéteur affiche une ligne par entrée d'un objet de données (ex : la
|
|
||||||
liste des mails d'une boîte de réception), mais toutes ses lignes
|
|
||||||
partagent le même modèle d'éléments — il n'existait donc aucun moyen de
|
|
||||||
dire "au clic sur CETTE ligne précise, ouvre CE mail précis" : un
|
|
||||||
déclencheur ne pouvait viser qu'un élément fixe posé une fois pour toutes
|
|
||||||
sur l'écran.
|
|
||||||
|
|
||||||
Ça fonctionne maintenant ainsi :
|
|
||||||
|
|
||||||
- Chaque ligne rendue par un Répéteur porte un attribut `data-row-id` (le
|
|
||||||
vrai id de la ligne de données, voir `screens/rendering/render_repeater.py`)
|
|
||||||
— à ne pas confondre avec `data-element-id`, qui reste l'id du MODÈLE de
|
|
||||||
ligne et se répète à l'identique sur chaque ligne.
|
|
||||||
- Dans l'éditeur de logique, choisir le Répéteur lui-même comme "Élément"
|
|
||||||
d'un nœud Déclencheur ("Au clic") fait réagir n'importe laquelle de ses
|
|
||||||
lignes au clic (le clic remonte naturellement jusqu'au conteneur du
|
|
||||||
Répéteur).
|
|
||||||
- La nouvelle action **"Ouvrir la ligne de Répéteur cliquée"** retient
|
|
||||||
quelle ligne a réellement été cliquée (`window.lastClickedRowId` /
|
|
||||||
`window.lastClickedDefinitionId`, capturés au clic dans `bindClicks()`
|
|
||||||
de `templates/play.html`), affiche l'écran de détail choisi, puis
|
|
||||||
résout tous les `{{champ}}` restés tels quels sur cet écran
|
|
||||||
(`applyOpenRowBindings()`) avec les valeurs de CETTE ligne — alors que
|
|
||||||
jusqu'ici `{{champ}}` ne se résolvait qu'à l'intérieur d'un Répéteur
|
|
||||||
(limitation encore documentée dans les tests pour un élément posé
|
|
||||||
directement depuis le catalogue).
|
|
||||||
- Limite connue : si le déclencheur est posé sur un élément à l'INTÉRIEUR
|
|
||||||
du modèle de ligne (ex: juste le titre) plutôt que sur le Répéteur
|
|
||||||
lui-même, `data-definition-id` n'est pas disponible sur cet élément et
|
|
||||||
l'action ne saura pas résoudre la donnée — pose toujours le déclencheur
|
|
||||||
sur le Répéteur.
|
|
||||||
|
|
||||||
### Un seul écran qui s'adapte à la partie (filtre de Répéteur + déclencheur "affichage")
|
|
||||||
|
|
||||||
Certains jeux (ex: une boîte mail avec plusieurs niveaux) n'ont besoin que
|
|
||||||
d'un SEUL écran de jeu, dont le contenu doit changer selon l'état de la
|
|
||||||
partie (niveau atteint, outils débloqués...) plutôt que d'un écran par
|
|
||||||
niveau à maintenir en synchronisation. Deux briques rendent ça possible :
|
|
||||||
|
|
||||||
- **Filtre sur un Répéteur** (nouveaux réglages "Filtre" dans ses
|
|
||||||
propriétés) : ne garde que les lignes dont un champ correspond à une
|
|
||||||
valeur — fixe (ex. `3`) OU une référence `{{NomDeLObjet.nom_du_champ}}`
|
|
||||||
qui va lire la valeur ACTUELLE de ce champ sur la ligne la plus récente
|
|
||||||
de cet autre objet (voir `screens/rendering/filter_repeater_rows.py`).
|
|
||||||
Convention : un objet utilisé comme "état de partie" (ex. un objet
|
|
||||||
"Partie" avec un champ "niveau_courant") ne garde qu'UNE seule ligne,
|
|
||||||
mise à jour en place par des actions "Modifier une donnée" plutôt que
|
|
||||||
d'en créer une nouvelle à chaque fois — la référence prend toujours la
|
|
||||||
ligne la plus récente. Le filtre est réévalué à chaque régénération du
|
|
||||||
HTML du jeu (chargement de `/play`, et rafraîchissement après toute
|
|
||||||
action "Modifier une donnée"), donc automatiquement à jour.
|
|
||||||
- **Déclencheur "À l'affichage de l'écran"** (nouvel événement, en plus de
|
|
||||||
"Au clic" et "À la soumission") : contrairement aux autres déclencheurs,
|
|
||||||
il ne cible pas un élément précis mais l'ÉCRAN ENTIER, et s'exécute
|
|
||||||
automatiquement — au premier affichage, à chaque retour sur cet écran, ET
|
|
||||||
après toute donnée modifiée pendant qu'on y est déjà (voir
|
|
||||||
`runScreenShowTriggers()` dans `templates/play.html`, appelée depuis
|
|
||||||
`showScreen()` et `refreshRuntimeData()`). Combiné à un nœud Condition et
|
|
||||||
une action "Modifier un élément → Visibilité", ça permet de cacher/montrer
|
|
||||||
un élément selon l'état de la partie SANS qu'un clic soit nécessaire pour
|
|
||||||
le réévaluer — la limite qui empêchait un outil de réapparaître "débloqué"
|
|
||||||
simplement en revenant sur l'écran. À éviter avec le mode "va-et-vient"
|
|
||||||
d'une action liée : il serait réévalué à chaque affichage et basculerait
|
|
||||||
de façon imprévisible plutôt que de rester stable.
|
|
||||||
|
|
||||||
Point d'attention : le filtre de Répéteur ne fait pas de vraie jointure —
|
|
||||||
la référence `{{Objet.champ}}` prend la ligne la plus récente de l'objet
|
|
||||||
visé, pas "la ligne liée à la ligne courante d'un autre Répéteur" ; pour un
|
|
||||||
état de partie à une seule ligne (le cas d'usage visé), ça suffit.
|
|
||||||
|
|
||||||
## Jauge liée à une donnée, bornage automatique, onglets, conditions combinées
|
|
||||||
|
|
||||||
Quatre briques qui se combinent bien avec le déclencheur "À l'affichage de
|
|
||||||
l'écran" ci-dessus, pour construire un écran qui réagit à l'état de la
|
|
||||||
partie sans code :
|
|
||||||
|
|
||||||
- **Widget "Jauge (liée à une donnée)"** (nouveau widget, icône 📊) :
|
|
||||||
affiche une barre dont la largeur ET la couleur suivent en direct un
|
|
||||||
champ numérique d'un objet — pas besoin d'ajouter la moindre action, la
|
|
||||||
jauge se relit automatiquement à chaque rafraîchissement des données
|
|
||||||
(chargement de `/play`, après toute action "Modifier une donnée"). Ses
|
|
||||||
réglages : Objet, Champ, Min/Max (bornes d'affichage — la barre est visuellement
|
|
||||||
clampée à 0%/100% même si la vraie valeur dépasse), Couleur basse/haute
|
|
||||||
(dégradé interpolé linéairement selon la position dans la plage), et une
|
|
||||||
case "Afficher la valeur" pour incruster le nombre au centre de la barre.
|
|
||||||
Lit toujours la ligne la PLUS RÉCENTE de l'objet visé (même convention
|
|
||||||
"état de partie à une seule ligne" que le filtre de Répéteur). Voir
|
|
||||||
`screens/rendering/render_jauge.py`.
|
|
||||||
- **Bornage automatique des champs numériques** : un champ "Nombre entier"
|
|
||||||
ou "Nombre décimal" peut désormais avoir un Min et/ou un Max réglés à la
|
|
||||||
création de l'objet (ou ajoutés après coup en édition). Toute action
|
|
||||||
"Modifier une donnée" (Augmenter de, Diminuer de, Définir à...) qui
|
|
||||||
ferait sortir la valeur de cette plage est automatiquement RAMENÉE à la
|
|
||||||
borne dépassée, plutôt que de continuer à s'accumuler sans limite — plus
|
|
||||||
besoin de poser une Condition séparée juste pour empêcher une jauge de
|
|
||||||
réputation de dépasser 100 ou de passer sous 0. Voir
|
|
||||||
`_clamp_to_field_bounds()` dans `screens/data_actions/apply_data_action.py`.
|
|
||||||
- **Panneau à onglets / visibilité mutuellement exclusive** : nouveau type
|
|
||||||
d'action "Afficher cet élément, masquer tous ses frères (onglets /
|
|
||||||
exclusif)" (`activer_onglet`). Au lieu de poser une action "Modifier
|
|
||||||
élément → Visibilité" par bouton à masquer (N-1 actions pour N onglets),
|
|
||||||
UNE SEULE action suffit : au clic, elle montre l'élément ciblé et cache
|
|
||||||
automatiquement tous les autres éléments qui partagent le même parent
|
|
||||||
(même conteneur) dans l'écran — exactement le comportement d'un jeu
|
|
||||||
d'onglets ou d'un menu à options exclusives. Voir la fonction
|
|
||||||
`runActionNode()` (branche `'activer_onglet'`) dans `templates/play.html`.
|
|
||||||
- **Conditions combinées (ET / OU)** : un nœud Condition peut désormais
|
|
||||||
tester PLUSIEURS champs à la fois dans un seul nœud, au lieu d'enchaîner
|
|
||||||
plusieurs nœuds Condition pour un ET, ou de dupliquer une action derrière
|
|
||||||
plusieurs nœuds Condition pour un OU. Dans le formulaire d'un nœud
|
|
||||||
Condition, le bouton "+ Ajouter une condition" ajoute une clause
|
|
||||||
supplémentaire (Objet / Ligne / Champ / Condition / Valeur, comme la
|
|
||||||
clause principale) et fait apparaître un sélecteur "Combiner les
|
|
||||||
conditions avec : ET / OU". Exemple : "réputation < 20 OU niveau ≥ 3".
|
|
||||||
Compatibilité : un nœud créé AVANT cette fonctionnalité (ou un nœud à une
|
|
||||||
seule clause) n'a pas de `cond_clauses` en base et garde exactement son
|
|
||||||
ancien comportement (une seule comparaison) — voir `cond_clauses` /
|
|
||||||
`cond_combinator` dans `screens/flow/ensure_flow_schema.py` et
|
|
||||||
`evaluateConditionNode()` / `evaluateConditionClause()` dans
|
|
||||||
`templates/play.html`.
|
|
||||||
|
|
||||||
## Survol, séquences temporisées, surbrillance, overlay, verrouillage
|
|
||||||
|
|
||||||
Cinq briques de confort, chacune contournable en théorie mais coûteuse en
|
|
||||||
câblage manuel sans elles :
|
|
||||||
|
|
||||||
- **Interactions au survol** : un nouveau réglage "Texte affiché au survol
|
|
||||||
de la souris", disponible sur TOUS les widgets (via UNIVERSAL_CONTROLS —
|
|
||||||
voir `screens/widgets/control_groups/hover_controls.py`), échange le
|
|
||||||
texte affiché contre ce texte alternatif tant que la souris survole
|
|
||||||
l'élément, puis le restaure au départ de la souris — exactement le cas
|
|
||||||
d'usage visé ("survoler un nom révèle l'adresse réelle, survoler un lien
|
|
||||||
révèle l'URL réelle"). Laissé vide = aucun changement, donc aucune
|
|
||||||
régression sur les éléments déjà créés. Ignoré volontairement sur les
|
|
||||||
éléments qui ont des enfants (conteneurs), pour ne jamais écraser une
|
|
||||||
mise en page imbriquée. Voir l'attribut `data-hover-text` posé par
|
|
||||||
`render_element_html.py` et `bindHoverTexts()` dans `templates/play.html`.
|
|
||||||
- **Séquences temporisées** : nouveau type d'action "Attendre quelques
|
|
||||||
secondes avant de continuer" (`attendre`), qui suspend la SUITE du fil de
|
|
||||||
logique (les nœuds reliés après lui) pendant N secondes avant de
|
|
||||||
continuer — combiné au déclencheur "À l'affichage de l'écran" (voir plus
|
|
||||||
haut), ça permet un mail ou un événement qui "arrive" tout seul quelques
|
|
||||||
secondes après l'ouverture de l'écran, sans action du joueur. Le joueur
|
|
||||||
continue d'interagir normalement avec le reste de l'écran pendant
|
|
||||||
l'attente. Voir la branche `'attendre'` de `runActionNode()` dans
|
|
||||||
`templates/play.html`.
|
|
||||||
- **Surbrillance générique dynamique** : nouvelle valeur "Surbrillance"
|
|
||||||
pour l'action "Modifier un élément", applicable à N'IMPORTE QUEL élément
|
|
||||||
(pas seulement les boutons) — pose un liseré doré clignotant (`.forgeHighlight`,
|
|
||||||
animation CSS) pour attirer l'attention du joueur vers ce qu'il doit
|
|
||||||
toucher ensuite (ex. un mentor qui "montre" un bouton), sans avoir à
|
|
||||||
poser une bordure de couleur togglée à la main. Activer / Désactiver /
|
|
||||||
Basculer, comme la Visibilité.
|
|
||||||
- **Overlay / modale réutilisable** : nouveau widget "Superposition / boîte
|
|
||||||
de dialogue" (icône 🪟), prêt à l'emploi — contrairement à tous les autres
|
|
||||||
widgets, sa position ne dépend PAS de l'endroit où il est glissé sur le
|
|
||||||
canevas (`position:fixed; inset:0`, codé en dur) : il couvre TOUJOURS tout
|
|
||||||
l'écran, avec un voile semi-transparent et une boîte centrée qui contient
|
|
||||||
les éléments posés à l'intérieur (comme un conteneur normal). Démarre
|
|
||||||
MASQUÉ par défaut à sa création (sinon il couvrirait l'écran dès qu'on le
|
|
||||||
pose) — l'ouverture et la fermeture réutilisent l'action existante
|
|
||||||
"Modifier un élément → Visibilité" (rendre visible / masquer), fermeture
|
|
||||||
manuelle uniquement, sans mécanisme de clic-en-dehors-pour-fermer. Voir
|
|
||||||
`screens/rendering/render_overlay.py`.
|
|
||||||
- **Verrouillage d'un élément après décision** : nouvelle valeur
|
|
||||||
"Désactivé" pour l'action "Modifier un élément", DISTINCTE de
|
|
||||||
"Invisible" — l'élément reste visible mais devient grisé et inerte
|
|
||||||
(`.forgeDisabled` : opacité réduite + `pointer-events:none`, qui bloque
|
|
||||||
aussi le déclencheur "Au clic" éventuellement posé dessus). Utile pour un
|
|
||||||
bouton "Répondre" qui reste affiché mais ne doit plus pouvoir être
|
|
||||||
recliqué une fois le mail traité. Activer / Désactiver / Basculer.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
Le code est organisé en un fichier par fonction et un dossier par
|
|
||||||
responsabilité — pensé pour rester modifiable, testable et évolutif à
|
|
||||||
mesure que le moteur grandit, plutôt que quelques gros fichiers monolithiques.
|
|
||||||
`app.py` reste le point d'entrée (`python3 app.py` ne change pas), mais il
|
|
||||||
n'est plus qu'un mince assemblage : toute la logique vit dans les dossiers
|
|
||||||
ci-dessous.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
app.py point d'entrée : assemble core/ + routes/, lance le serveur
|
app.py point d'entrée : assemble core/ + routes/, lance le serveur
|
||||||
|
|
||||||
core/ pièces transverses de l'application Flask
|
core/ pièces transverses (instance Flask, filtres Jinja, CSRF, auth)
|
||||||
flask_app.py crée l'instance Flask partagée (app)
|
filters/ filtres Jinja restants (colname)
|
||||||
jinja_filters.py enregistre les filtres Jinja (colname, elstyle, elabel)
|
|
||||||
|
|
||||||
filters/ un fichier par filtre Jinja
|
routes/ une route HTTP = un fichier, regroupées par domaine
|
||||||
colname_filter.py
|
auth/ inscription, connexion, 2FA, mot de passe
|
||||||
element_style_filter.py
|
onboarding/ parcours de création guidé ("Quel jeu veux-tu créer ?")
|
||||||
|
games/ créer/renommer/supprimer un jeu, tableau de bord ("Mes écrans")
|
||||||
routes/ un fichier par route HTTP, regroupées par domaine
|
screens/ écrans du jeu (liste, création, renommage, taille de scène...)
|
||||||
games/ créer/renommer/supprimer un jeu, tableau de bord
|
scenes/ objets de scène (personnage/décor/fond/widgets), rôle, collision, nom...
|
||||||
objects/ définitions d'objet, champs, données (CRUD)
|
quests/ quêtes + dialogues
|
||||||
screens/ écrans du jeu (liste, création, édition...)
|
custom_events/ événements personnalisés (déclenchés/écoutés depuis une scène)
|
||||||
elements/ éléments posés sur un écran
|
flow/, flow_blocks/ moteur de logique à nœuds (partagé, utilisé par les règles de collision)
|
||||||
element_types/ catalogue d'éléments de jeu réutilisables
|
animations/ clips d'animation (Animate.css + images-clés)
|
||||||
flow/ éditeur de logique à nœuds (déclencheur/condition/action)
|
global_vars/ variables globales du jeu
|
||||||
legacy_actions/ ancien système d'actions par élément (conservé, non utilisé par l'interface actuelle)
|
publish/ export SCORM
|
||||||
|
play/, public_play/ mode jouable
|
||||||
uploads/ envoi de fichiers (images/vidéos)
|
uploads/ envoi de fichiers (images/vidéos)
|
||||||
play/ mode jouable
|
|
||||||
|
|
||||||
db/ couche données "objets" — un fichier par fonction
|
db/ couche données bas niveau (SQLite, un fichier .db par jeu)
|
||||||
connection.py, slugify.py, table_name_for.py, game_dir.py, db_path.py, constants.py
|
games/ cycle de vie d'un jeu, catalogue d'onboarding
|
||||||
games/ cycle de vie d'un jeu
|
definitions/, rows/ objets de données typés + leurs lignes (utilisés par le flow)
|
||||||
definitions/ définitions d'objet et leurs champs
|
quests/ quêtes et leur dialogue
|
||||||
rows/ lignes de données (CRUD)
|
custom_events/, global_vars/, scoring/
|
||||||
|
|
||||||
screens/ couche données + rendu "écrans de jeu" — un fichier par fonction
|
screens/ couche rendu — un fichier par fonction
|
||||||
widgets/ catalogue de widgets, réglages ("controls"), groupes de réglages
|
scenes/ objets de scène : ajout, rendu, rôle, collision, commandes, dialogue...
|
||||||
rendering/ construction du HTML réel d'un élément (récursif)
|
rendering/ personnage (data/animations/rôle), collision, dialogue, quêtes
|
||||||
screens_repo/ CRUD des écrans
|
flow/ moteur de logique à nœuds (partagé)
|
||||||
elements/ CRUD des éléments posés sur un écran
|
data_actions/ exécution d'une action de flow (modifier une donnée, un score...)
|
||||||
element_types/ catalogue d'éléments de jeu réutilisables (conteneurs imbriqués)
|
animations/, custom_events/, labels/, payload/, screens_repo/
|
||||||
flow/ moteur de logique à nœuds
|
|
||||||
data_actions/ exécution d'une action "modifier une donnée"
|
|
||||||
legacy_actions/ ancien système d'actions (conservé)
|
|
||||||
labels/ constantes d'affichage (libellés)
|
|
||||||
payload/ assemble les données du mode jouable
|
|
||||||
|
|
||||||
templates/ pages HTML (Jinja2) — inchangé
|
templates/ pages HTML (Jinja2)
|
||||||
static/ CSS + JS — inchangé
|
static/ CSS + JS (static/js/scenes/ = éditeur de scène, static/js/play/ = moteur de jeu)
|
||||||
projects/ un sous-dossier par jeu créé (généré à l'usage) — inchangé
|
projects/ un sous-dossier par jeu créé (généré à l'usage, non versionné)
|
||||||
|
|
||||||
tests/ suite de tests automatisés (pytest)
|
tests/ suite pytest
|
||||||
conftest.py fixtures partagées (client Flask de test, jeu de test jetable)
|
|
||||||
test_db_layer.py couche données, sans Flask
|
|
||||||
test_screens_and_elements.py écrans/éléments/éléments de jeu/répéteur, via le client de test
|
|
||||||
test_flow.py éditeur de logique à nœuds
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Chaque `import db` / `import screens` continue de fonctionner exactement
|
## Tests
|
||||||
comme avant (`db.connect(...)`, `screens.render_element_html(...)`, etc.) :
|
|
||||||
`db/__init__.py` et `screens/__init__.py` réexportent l'intégralité de
|
|
||||||
l'API publique historique, pour que ce découpage interne reste invisible du
|
|
||||||
reste du moteur — aucune route, aucun template n'a eu besoin de changer.
|
|
||||||
|
|
||||||
### Lancer les tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Corrections (retours d'usage)
|
## Notes techniques
|
||||||
|
|
||||||
- **ID d'élément en champ non modifiable** : dans le panneau de propriétés,
|
- **Navigation sans rechargement** : la plupart des actions de l'éditeur
|
||||||
l'ID de l'élément (`elt-…`) est maintenant affiché dans un champ texte
|
de scène (ajout/suppression d'objet, sélection, changement de rôle/nom,
|
||||||
`readonly`, plutôt qu'en simple texte.
|
règles de collision) passent par des appels `fetch()` dédiés qui ne
|
||||||
- **Bouton "+10 points" instable en mode Jouer (corrigé)** : un bouton relié
|
touchent que le fragment de page concerné. La navigation entre pages
|
||||||
à une action "Modifier une donnée" (ex: incrémenter un score) pouvait, après
|
différentes (ex. "Mes écrans" ↔ une scène) passe par `static/pjax.js`
|
||||||
plusieurs clics, appliquer l'effet plusieurs fois d'un coup (+100, -20, ou
|
(interception des clics/formulaires, remplacement de `<main>` +
|
||||||
retomber à 0 au lieu de ±10). Cause : chaque donnée modifiée déclenche un
|
`#pageChrome`) plutôt qu'un rechargement complet — toute règle CSS dont
|
||||||
rafraîchissement qui ré-attache les gestionnaires de clic sur toute la
|
dépend une page doit donc vivre dans `static/style.css` (chargé
|
||||||
scène ; un bouton ordinaire (contrairement à un Répéteur ou une Jauge) garde
|
partout), jamais dans un `<style>` inline propre à cette seule page,
|
||||||
le même nœud HTML d'un rafraîchissement à l'autre, donc ses gestionnaires de
|
qui ne serait pas repris lors d'une navigation pjax vers elle.
|
||||||
clic s'empilaient au lieu d'être remplacés. Un clic fini par déclencher
|
- **Serveur de développement en mode `threaded=True`** (`app.py`) : une
|
||||||
l'action N fois pour N rafraîchissements passés. Corrigé en marquant chaque
|
scène charge plusieurs images en parallèle (personnages, fond) — sans
|
||||||
élément déjà relié, pour ne l'attacher qu'une seule fois.
|
ce réglage, le serveur Werkzeug mono-thread les sert une par une.
|
||||||
- **Textes d'aide allégés** : les explications détaillées entre parenthèses
|
|
||||||
affichées un peu partout dans l'éditeur ont été retirées ou raccourcies —
|
|
||||||
sur tous les écrans, pas seulement l'éditeur de scène (nouvelle passe
|
|
||||||
complète sur tous les labels, options de menus déroulants et placeholders).
|
|
||||||
- **Espacement (gap) inefficace en disposition par défaut, corrigé** : le
|
|
||||||
réglage "Espace entre les éléments" n'avait aucun effet tant que la
|
|
||||||
"Disposition interne" restait sur "Empilement libre" — le CSS `gap` ne
|
|
||||||
fonctionne que sur un conteneur flex/grid, or "Empilement libre" ne posait
|
|
||||||
aucun `display:flex`. Un conteneur/répéteur/groupe de champs applique
|
|
||||||
maintenant flex + colonne par défaut dès qu'aucune disposition n'a été
|
|
||||||
choisie explicitement (visuellement identique à l'empilement d'avant),
|
|
||||||
donc l'espacement fonctionne dès la création, sans avoir à toucher la
|
|
||||||
disposition au préalable.
|
|
||||||
- **Marges et bordures réglables par côté** : en plus des réglages globaux
|
|
||||||
existants ("Marge intérieure", "Marge extérieure", "Épaisseur de la
|
|
||||||
bordure"), chaque côté (haut/droite/bas/gauche) peut désormais être réglé
|
|
||||||
individuellement — utile par exemple pour une seule bordure basse, ou un
|
|
||||||
padding asymétrique. Les réglages par côté, une fois utilisés, priment sur
|
|
||||||
le réglage global correspondant.
|
|
||||||
- **Apparence de l'éditeur après suppression d'un élément** : supprimer un
|
|
||||||
élément qui a un parent (posé à l'intérieur d'un conteneur/répéteur/groupe
|
|
||||||
de champs) resélectionne maintenant ce parent, au lieu de laisser le
|
|
||||||
panneau de propriétés vide.
|
|
||||||
|
|
||||||
- **Timeline d'animation (Animate.css + animations personnalisées)** : dans
|
|
||||||
l'éditeur d'écran, un nouveau panneau rétractable "🎬 Timeline d'animation"
|
|
||||||
est positionné sous "🔀 Logique de la scène". Il permet de poser des clips
|
|
||||||
d'animation sur n'importe quel élément de l'écran, sur un axe temporel en
|
|
||||||
secondes (depuis l'affichage de l'écran) :
|
|
||||||
- **Animations Animate.css** : choix parmi le catalogue complet de la
|
|
||||||
bibliothèque (apparitions, rebonds, glissements, zooms, rotations,
|
|
||||||
retournements, etc.), avec durée, délai, easing et nombre de répétitions
|
|
||||||
réglables.
|
|
||||||
- **Animations personnalisées** : un éditeur d'images-clés (keyframes) —
|
|
||||||
chaque image-clé a un pourcentage (0-100) et une liste de propriétés CSS
|
|
||||||
(ex. `transform: scale(1.2); opacity: 0.5;`), converties en `@keyframes`
|
|
||||||
CSS au moment de la lecture.
|
|
||||||
- Chaque clip se pose et se règle à la souris directement sur la
|
|
||||||
timeline : glisser un clip change son instant de départ, glisser son
|
|
||||||
bord droit change sa durée ; cliquer dessus (sans glisser) ouvre son
|
|
||||||
formulaire d'édition détaillé, avec un bouton "▶ Aperçu" pour voir
|
|
||||||
l'animation directement sur le canevas d'édition.
|
|
||||||
- En mode Jouer, chaque clip se déclenche automatiquement à son instant de
|
|
||||||
départ lors de l'affichage de l'écran qui le contient.
|
|
||||||
- **Échelle/animation d'un élément posé sur l'écran, corrigée** : l'échelle
|
|
||||||
("Disposition" → curseur "Échelle") et les animations de la timeline
|
|
||||||
restaient visuellement "coincées" à l'intérieur de la boîte d'origine de
|
|
||||||
l'élément, comme si l'effet s'appliquait à un contenu emprisonné dans un
|
|
||||||
cadre immobile — c'était bien le cas : ces réglages vivaient sur la balise
|
|
||||||
intérieure, alors que le cadre qui porte réellement la position/taille de
|
|
||||||
l'élément sur l'écran (et son contour de sélection dans l'éditeur) restait
|
|
||||||
fixe autour. L'échelle et les animations visent maintenant ce cadre
|
|
||||||
directement, comme le reste de l'élément — plus de recadrage parasite. Un
|
|
||||||
élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de champs n'a
|
|
||||||
jamais eu ce souci (pas de cadre séparé pour lui).
|
|
||||||
- **Format d'aperçu (Portrait/Paysage/Carré) mémorisé par écran** : ce choix
|
|
||||||
ne vivait qu'en mémoire dans le navigateur — poser ou supprimer un
|
|
||||||
élément, ou sélectionner un autre élément, redemande ce panneau au
|
|
||||||
serveur, qui repartait alors systématiquement sur Portrait. Résultat :
|
|
||||||
l'écran de travail semblait "s'agrandir ou rétrécir" au fil des actions
|
|
||||||
quand on travaillait en Paysage ou en Carré (retour brutal en Portrait à
|
|
||||||
chaque fois). Le format choisi est maintenant enregistré par écran et
|
|
||||||
restauré à chaque fois que ce panneau est réaffiché.
|
|
||||||
- **Zone de jeu à proportion fixe en mode Jouer, contre la déformation en
|
|
||||||
%** : les positions et tailles des éléments sont en pourcentage de
|
|
||||||
l'écran, ce qui les déformait dès que la fenêtre du joueur n'avait pas
|
|
||||||
exactement la même proportion que celle choisie à la conception (ex. un
|
|
||||||
écran conçu en Portrait, joué dans une fenêtre large, étirait/écrasait
|
|
||||||
tout). La zone de jeu garde maintenant toujours la proportion de l'écran
|
|
||||||
affiché (Portrait/Paysage/Carré, par écran), avec des bandes noires
|
|
||||||
(letterboxing) si besoin — comme un lecteur vidéo — pour rester fidèle à
|
|
||||||
ce qui a été conçu, quel que soit l'écran du joueur.
|
|
||||||
|
|
||||||
## Prochaines fonctionnalités
|
|
||||||
|
|
||||||
À ajouter au fur et à mesure, comme convenu.
|
|
||||||
|
|||||||
+41
-13
@@ -1,9 +1,10 @@
|
|||||||
"""Garde d'accès globale — connexion obligatoire pour tout le moteur, et
|
"""Garde d'accès globale — connexion obligatoire pour tout le moteur, et
|
||||||
isolation par utilisateur d'un SEUL projet (sauf le rôle "admin",
|
isolation par PROPRIÉTAIRE de projet, pour TOUS les rôles (y compris
|
||||||
illimité comme avant l'authentification). Un seul before_request plutôt
|
"admin" — voir plus bas, un admin garde d'autres privilèges mais plus
|
||||||
qu'un décorateur à poser sur chacune des ~80 routes existantes : moins de
|
aucun accès aux projets des autres comptes, faille corrigée). Un seul
|
||||||
risque d'en oublier une, et aucun fichier de routes existant n'a besoin
|
before_request plutôt qu'un décorateur à poser sur chacune des ~80 routes
|
||||||
d'être modifié.
|
existantes : moins de risque d'en oublier une, et aucun fichier de routes
|
||||||
|
existant n'a besoin d'être modifié.
|
||||||
|
|
||||||
Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py)
|
Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py)
|
||||||
pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
|
pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
|
||||||
@@ -13,6 +14,7 @@ rester cohérent avec l'ordre d'import du reste du moteur."""
|
|||||||
from flask import g, redirect, request, session, url_for, abort
|
from flask import g, redirect, request, session, url_for, abort
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
|
from db.games.project_slug import split_slug
|
||||||
|
|
||||||
from .flask_app import app
|
from .flask_app import app
|
||||||
|
|
||||||
@@ -53,11 +55,40 @@ def _require_login_and_enforce_project_isolation():
|
|||||||
return redirect(url_for("login"))
|
return redirect(url_for("login"))
|
||||||
g.current_user = user
|
g.current_user = user
|
||||||
|
|
||||||
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug) — un
|
# Propriété d'un projet = le segment "propriétaire" de son slug (voir
|
||||||
# "admin" reste illimité, exactement comme avant l'authentification.
|
# db/games/project_slug.py — projects/<owner_folder>/<projet>/,
|
||||||
# Les routes de gestion multi-jeux (page d'accueil, "+ Nouveau jeu")
|
# owner_folder est l'id du compte qui l'a créé, voir
|
||||||
# n'ont pas leur place pour un compte à projet unique : redirigées
|
# db/games/create_game.py) comparé à l'id du compte CONNECTÉ — pour
|
||||||
# directement vers son propre tableau de bord plutôt qu'un 403 sec.
|
# TOUS les rôles, admin compris. Corrige une faille : auparavant ce
|
||||||
|
# contrôle (comme tout le reste de cette fonction) était sauté pour
|
||||||
|
# "admin", qui pouvait donc ouvrir/modifier/supprimer le projet de
|
||||||
|
# N'IMPORTE QUEL autre compte en connaissant son slug.
|
||||||
|
#
|
||||||
|
# Un slug "à plat" (un seul segment, `split_slug` renvoie alors
|
||||||
|
# `project_part=None`) n'a jamais été rattaché à un compte précis —
|
||||||
|
# avant l'introduction de project_slug, c'était déjà la norme
|
||||||
|
# (voir scripts/migrate_flat_project_slugs.py), et `db.create_game()`
|
||||||
|
# sans `owner_folder` produit encore ce format aujourd'hui (utilisé
|
||||||
|
# par une bonne partie de la suite de tests comme jeu jetable, sans
|
||||||
|
# compte associé). Réservé au rôle "admin" (repli sur le comportement
|
||||||
|
# "illimité" historique pour ce cas précis) — jamais un "user", qui
|
||||||
|
# n'a par construction aucun projet à plat légitime.
|
||||||
|
slug = request.view_args.get("slug") if request.view_args else None
|
||||||
|
if slug is not None:
|
||||||
|
owner_folder, project_part = split_slug(slug)
|
||||||
|
if project_part is None:
|
||||||
|
if user["role"] != "admin":
|
||||||
|
abort(403)
|
||||||
|
elif owner_folder != str(user["id"]):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug,
|
||||||
|
# simple raccourci de confort ici — la sécurité elle-même est déjà
|
||||||
|
# assurée ci-dessus) — un "admin" peut en avoir plusieurs, exactement
|
||||||
|
# comme avant l'authentification. Les routes de gestion multi-jeux
|
||||||
|
# (page d'accueil, "+ Nouveau jeu") n'ont pas leur place pour un
|
||||||
|
# compte à projet unique : redirigées directement vers son propre
|
||||||
|
# tableau de bord plutôt qu'un 403 sec.
|
||||||
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"]))
|
||||||
@@ -68,9 +99,6 @@ def _require_login_and_enforce_project_isolation():
|
|||||||
# une connexion, absentes de _PUBLIC_ENDPOINTS).
|
# une connexion, absentes de _PUBLIC_ENDPOINTS).
|
||||||
if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT:
|
if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT:
|
||||||
return redirect(url_for("onboarding_new"))
|
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
|
# Type d'onboarding "restreint" (quiz/embranchement/rpg, voir
|
||||||
# db/games/game_type_catalog.py) : game_dashboard reste atteignable
|
# db/games/game_type_catalog.py) : game_dashboard reste atteignable
|
||||||
# comme pour "custom", mais routes/games/game_dashboard.py y rend
|
# comme pour "custom", mais routes/games/game_dashboard.py y rend
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ def create_game(name, owner_folder=None, project_slug_override=None):
|
|||||||
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
|
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
|
||||||
|
|
||||||
owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py —
|
owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py —
|
||||||
structure de dossiers par utilisateur), typiquement
|
structure de dossiers par utilisateur), l'id du compte en pratique
|
||||||
slugify(email_du_compte) — le slug final composé
|
(voir routes/games/games_new.py — opaque, jamais dérivé de l'email) —
|
||||||
|
le slug final composé
|
||||||
"<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué
|
"<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué
|
||||||
projects/<owner_folder>/<project_part>/. Omis (None) : repli sur
|
projects/<owner_folder>/<project_part>/. Omis (None) : repli sur
|
||||||
l'ancien comportement plat (slug à un seul segment, projects/<slug>/)
|
l'ancien comportement plat (slug à un seul segment, projects/<slug>/)
|
||||||
|
|||||||
+17
-19
@@ -6,27 +6,25 @@ from ..db_path import db_path
|
|||||||
from .project_slug import build_slug
|
from .project_slug import build_slug
|
||||||
|
|
||||||
|
|
||||||
def list_games():
|
def list_games(owner_folder):
|
||||||
"""Scanne projects/ : structure par utilisateur (voir
|
"""Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/
|
||||||
db/games/project_slug.py) — projects/<propriétaire>/<projet>/game.db,
|
project_slug.py — owner_folder est l'id du compte, voir
|
||||||
deux niveaux — plus le repli sur l'ancien rangement plat
|
db/games/create_game.py) : jamais les autres comptes — voir
|
||||||
projects/<slug>/game.db pour tout slug pas encore migré (voir
|
core/auth_guard.py, qui vérifie déjà que `owner_folder` correspond au
|
||||||
scripts/migrate_flat_project_slugs.py)."""
|
compte connecté avant tout accès à un slug composé de cette valeur.
|
||||||
|
Un appelant qui passerait le mauvais owner_folder ne verrait de toute
|
||||||
|
façon que SES PROPRES projets, jamais ceux d'un autre compte : cette
|
||||||
|
fonction ne lit plus jamais l'arborescence project/ en entier (avant
|
||||||
|
ce correctif, elle listait TOUS les comptes sans distinction — faille
|
||||||
|
corrigée, voir routes/games/index.py)."""
|
||||||
games = []
|
games = []
|
||||||
if not os.path.isdir(PROJECTS_DIR):
|
entry_path = os.path.join(PROJECTS_DIR, owner_folder)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
return games
|
return games
|
||||||
for entry in sorted(os.listdir(PROJECTS_DIR)):
|
for project_part in sorted(os.listdir(entry_path)):
|
||||||
entry_path = os.path.join(PROJECTS_DIR, entry)
|
slug = build_slug(owner_folder, project_part)
|
||||||
if not os.path.isdir(entry_path):
|
if os.path.isfile(db_path(slug)):
|
||||||
continue
|
_append_game(games, slug)
|
||||||
if os.path.isfile(os.path.join(entry_path, "game.db")):
|
|
||||||
# Repli : un ancien dossier plat porte directement game.db.
|
|
||||||
_append_game(games, entry)
|
|
||||||
continue
|
|
||||||
for project_part in sorted(os.listdir(entry_path)):
|
|
||||||
slug = build_slug(entry, project_part)
|
|
||||||
if os.path.isfile(db_path(slug)):
|
|
||||||
_append_game(games, slug)
|
|
||||||
return games
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -6,17 +6,18 @@ from .project_slug import build_slug, split_slug
|
|||||||
|
|
||||||
|
|
||||||
def move_game(old_slug, new_owner_folder):
|
def move_game(old_slug, new_owner_folder):
|
||||||
"""Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un
|
"""Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est
|
||||||
utilisateur change son adresse email (voir routes/auth/profile.py,
|
l'id du compte (voir db/games/project_slug.py, routes/games/
|
||||||
owner_folder = slugify(email) pour un compte "user", voir
|
games_new.py) : ne change donc plus jamais après coup en usage normal,
|
||||||
db/games/project_slug.py) : le dossier physique doit suivre. Renomme
|
mais reste utile pour une migration ponctuelle (voir
|
||||||
le dossier propriétaire ENTIER en un coup (déplace tous les projets de
|
scripts/migrate_owner_folders_to_user_id.py, qui a besoin de renommer
|
||||||
ce compte ensemble — prêt pour un futur multi-projet, même si un seul
|
les anciens dossiers slugify(email) vers l'id du compte). Renomme le
|
||||||
aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire
|
dossier propriétaire ENTIER en un coup (déplace tous les projets de ce
|
||||||
unique de la même façon que create_game() si, par un hasard extrême,
|
compte ensemble), pas juste un projet. Rend le nouveau dossier
|
||||||
il correspond déjà à un dossier existant. Renvoie le nouveau slug
|
propriétaire unique de la même façon que create_game() si, par un
|
||||||
composé FINAL (même projet, propriétaire renommé), à enregistrer comme
|
hasard extrême, il correspond déjà à un dossier existant. Renvoie le
|
||||||
nouveau project_slug."""
|
nouveau slug composé FINAL (même projet, propriétaire renommé), à
|
||||||
|
enregistrer comme nouveau project_slug."""
|
||||||
old_owner_folder, project_part = split_slug(old_slug)
|
old_owner_folder, project_part = split_slug(old_slug)
|
||||||
if project_part is None:
|
if project_part is None:
|
||||||
# Slug pas encore migré vers la structure par utilisateur (voir
|
# Slug pas encore migré vers la structure par utilisateur (voir
|
||||||
|
|||||||
@@ -43,15 +43,9 @@ def profile_update_email():
|
|||||||
except auth.EmailUpdateError as exc:
|
except auth.EmailUpdateError as exc:
|
||||||
return _render(error=str(exc))
|
return _render(error=str(exc))
|
||||||
|
|
||||||
# Le dossier d'un compte "user" porte le nom de son adresse email (voir
|
# Le dossier d'un projet est nommé d'après l'ID du compte, jamais son
|
||||||
# create_user.py/register_2fa.py, project_slug = slugify(email)) — il
|
# email (voir routes/games/games_new.py) — rien à renommer sur disque
|
||||||
# doit suivre le changement, sans quoi project_slug ne correspondrait
|
# quand l'email change, pour aucun rôle.
|
||||||
# plus à aucun dossier réel. Un "admin" n'a pas de project_slug dédié :
|
|
||||||
# rien à renommer pour ce rôle.
|
|
||||||
if user["role"] != "admin" and user.get("project_slug"):
|
|
||||||
new_slug = db.move_game(user["project_slug"], db.slugify(new_email))
|
|
||||||
auth.set_project_slug(user["id"], new_slug)
|
|
||||||
|
|
||||||
return _render(success="Adresse email mise à jour.")
|
return _render(success="Adresse email mise à jour.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ def games_new():
|
|||||||
# avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en
|
# avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en
|
||||||
# choisissant "document" ou "jeu_2d" pour chacun).
|
# choisissant "document" ou "jeu_2d" pour chacun).
|
||||||
# owner_folder (voir db/games/project_slug.py — structure de dossiers
|
# owner_folder (voir db/games/project_slug.py — structure de dossiers
|
||||||
# par utilisateur) : posé par core/auth_guard.py pour tout compte
|
# par utilisateur) : l'id du compte, JAMAIS son email — opaque, ne
|
||||||
# connecté, y compris l'admin qui utilise cette route.
|
# révèle rien du compte propriétaire dans les slugs/URLs (voir
|
||||||
slug = db.create_game(name, owner_folder=db.slugify(g.current_user["email"]))
|
# scripts/migrate_owner_folders_to_user_id.py pour les projets déjà
|
||||||
|
# existants avec l'ancien schéma).
|
||||||
|
slug = db.create_game(name, owner_folder=str(g.current_user["id"]))
|
||||||
return redirect(url_for("game_dashboard", slug=slug))
|
return redirect(url_for("game_dashboard", slug=slug))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import render_template
|
from flask import g, render_template
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
|
||||||
@@ -7,4 +7,7 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html", games=db.list_games())
|
# Toujours SES PROPRES projets (voir db.list_games, corrigé pour ne
|
||||||
|
# plus jamais scanner les autres comptes) — même pour un admin, qui
|
||||||
|
# n'a donc plus aucune visibilité sur les projets des autres comptes.
|
||||||
|
return render_template("index.html", games=db.list_games(str(g.current_user["id"])))
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ def onboarding_new():
|
|||||||
|
|
||||||
|
|
||||||
def _create_project_for_user(user, onboarding_type, name):
|
def _create_project_for_user(user, onboarding_type, name):
|
||||||
slug = db.create_game(name, owner_folder=db.slugify(user["email"]))
|
# owner_folder = l'id du compte, jamais son email (voir
|
||||||
|
# routes/games/games_new.py, même convention).
|
||||||
|
slug = db.create_game(name, owner_folder=str(user["id"]))
|
||||||
db.set_onboarding_type(slug, onboarding_type)
|
db.set_onboarding_type(slug, onboarding_type)
|
||||||
if user["role"] != "admin":
|
if user["role"] != "admin":
|
||||||
# project_slug = LE seul projet d'un compte "user" (voir
|
# project_slug = LE seul projet d'un compte "user" (voir
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Script à usage unique (PAS exécuté au runtime du moteur) : renomme le
|
||||||
|
dossier PROPRIÉTAIRE de chaque compte (projects/<owner_folder>/, voir
|
||||||
|
db/games/project_slug.py) de l'ancien schéma slugify(email) — visible en
|
||||||
|
clair dans chaque slug/URL de l'app, ex. projects/vandal-william-gmail-com/ —
|
||||||
|
vers l'id numérique du compte (opaque), ex. projects/4/.
|
||||||
|
|
||||||
|
core/auth_guard.py vérifie désormais que le segment "propriétaire" d'un
|
||||||
|
slug correspond à l'id du compte connecté (pour TOUS les rôles, y compris
|
||||||
|
"admin" — faille corrigée) : cette migration doit avoir tourné avant de
|
||||||
|
déployer ce correctif, sinon aucun compte existant ne pourrait plus
|
||||||
|
accéder à ses propres projets (leur dossier reste sous l'ancien nom tant
|
||||||
|
que ce script n'a pas tourné).
|
||||||
|
|
||||||
|
Pour chaque compte : le dossier propriétaire ATTENDU est déduit de
|
||||||
|
`project_slug` (compte "user", déjà à jour si son email a changé entre
|
||||||
|
temps) ou, à défaut (compte "admin", qui n'utilise pas cette colonne — ou
|
||||||
|
tout compte sans projet), de `slugify(email)` (l'ancienne convention,
|
||||||
|
utilisée pour TOUS les rôles jusqu'à ce correctif). S'il existe sur
|
||||||
|
disque, il est renommé en `str(user_id)` via db.move_game (déplace TOUS
|
||||||
|
les projets de ce compte en un coup), puis project_slug est mis à jour
|
||||||
|
pour les comptes "user".
|
||||||
|
|
||||||
|
Mode simulation par défaut (n'écrit rien, affiche ce qui serait fait) —
|
||||||
|
`--apply` requis pour exécuter réellement :
|
||||||
|
python scripts/migrate_owner_folders_to_user_id.py # aperçu
|
||||||
|
python scripts/migrate_owner_folders_to_user_id.py --apply # exécute
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
import auth # noqa: E402
|
||||||
|
from db.constants import PROJECTS_DIR # noqa: E402
|
||||||
|
from db.games.project_slug import build_slug, split_slug # noqa: E402
|
||||||
|
from auth.connection import connect as connect_auth_db # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_old_owner_folder(user):
|
||||||
|
if user["role"] != "admin" and user["project_slug"]:
|
||||||
|
owner_folder, project_part = split_slug(user["project_slug"])
|
||||||
|
if project_part is not None:
|
||||||
|
return owner_folder
|
||||||
|
# Repli : compte "admin" (pas de project_slug dédié) ou compte "user"
|
||||||
|
# sans projet_slug ("user" fraîchement inscrit, pas encore passé par
|
||||||
|
# l'onboarding — n'a alors aucun dossier à migrer de toute façon) —
|
||||||
|
# l'ancienne convention posait TOUJOURS owner_folder = slugify(email),
|
||||||
|
# quel que soit le rôle (voir routes/games/games_new.py avant ce
|
||||||
|
# correctif).
|
||||||
|
return db.slugify(user["email"])
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(apply=False):
|
||||||
|
conn = connect_auth_db()
|
||||||
|
users = [
|
||||||
|
dict(r) for r in
|
||||||
|
conn.execute("SELECT id, email, role, project_slug FROM _users").fetchall()
|
||||||
|
]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
for user in users:
|
||||||
|
new_owner_folder = str(user["id"])
|
||||||
|
old_owner_folder = _expected_old_owner_folder(user)
|
||||||
|
if old_owner_folder == new_owner_folder:
|
||||||
|
continue # déjà migré (ou coïncidence — rien à faire non plus)
|
||||||
|
old_dir = os.path.join(PROJECTS_DIR, old_owner_folder)
|
||||||
|
if not os.path.isdir(old_dir):
|
||||||
|
continue # aucun projet pour ce compte — rien à déplacer
|
||||||
|
|
||||||
|
project_parts = sorted(
|
||||||
|
entry for entry in os.listdir(old_dir)
|
||||||
|
if os.path.isfile(os.path.join(old_dir, entry, "game.db"))
|
||||||
|
)
|
||||||
|
if not project_parts:
|
||||||
|
print(f"{user['email']} ({user['role']}) : dossier {old_owner_folder!r} sans jeu — ignoré.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sample_slug = build_slug(old_owner_folder, project_parts[0])
|
||||||
|
print(
|
||||||
|
f"{user['email']} ({user['role']}) : {old_owner_folder!r} -> {new_owner_folder!r}"
|
||||||
|
f" ({len(project_parts)} projet(s) : {', '.join(project_parts)})"
|
||||||
|
)
|
||||||
|
if not apply:
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_slug = db.move_game(sample_slug, new_owner_folder)
|
||||||
|
if user["role"] != "admin":
|
||||||
|
# Un compte "user" n'a qu'UN SEUL projet — le slug renvoyé
|
||||||
|
# pour celui-ci est donc directement le nouveau project_slug.
|
||||||
|
auth.set_project_slug(user["id"], final_slug)
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
print(f"{migrated} compte(s) migré(s).")
|
||||||
|
else:
|
||||||
|
print("Aperçu seulement — relance avec --apply pour exécuter.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate(apply="--apply" in sys.argv)
|
||||||
+12
-10
@@ -97,17 +97,19 @@ def user_client():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def user_game(client, user_client):
|
def user_game(user_client):
|
||||||
"""Un jeu appartenant au compte `user_client` (project_slug assigné,
|
"""Un jeu appartenant réellement au compte `user_client` — le dossier
|
||||||
voir auth.set_project_slug — même mécanisme que l'inscription réelle,
|
PROPRIÉTAIRE (voir db/games/project_slug.py) doit correspondre à
|
||||||
routes/auth/register_2fa.py). Créé via le client ADMIN (`client`,
|
l'id de CE compte (core/auth_guard.py compare désormais les deux) :
|
||||||
illimité) puis rattaché, pour ne pas dépendre du parcours
|
créé directement avec `owner_folder=str(user_id)`, jamais via le
|
||||||
d'inscription complet dans les tests qui n'en ont pas besoin."""
|
client ADMIN (qui produirait un projet appartenant à l'admin, avec
|
||||||
resp = client.post("/games/new", data={"name": "pytest_user_game"}, follow_redirects=False)
|
juste `project_slug` réassigné dessus côté "user" — cassait
|
||||||
assert resp.status_code == 302
|
l'isolation par propriétaire dès qu'elle a cessé d'être une simple
|
||||||
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
|
comparaison de chaîne)."""
|
||||||
with user_client.session_transaction() as sess:
|
with user_client.session_transaction() as sess:
|
||||||
auth.set_project_slug(sess["user_id"], slug)
|
user_id = sess["user_id"]
|
||||||
|
slug = db.create_game("pytest_user_game", owner_folder=str(user_id))
|
||||||
|
auth.set_project_slug(user_id, slug)
|
||||||
yield slug
|
yield slug
|
||||||
if os.path.isdir(db.game_dir(slug)):
|
if os.path.isdir(db.game_dir(slug)):
|
||||||
db.delete_game(slug)
|
db.delete_game(slug)
|
||||||
|
|||||||
+51
-1
@@ -1,6 +1,7 @@
|
|||||||
"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
|
"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
|
||||||
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
|
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
|
||||||
utilisateur d'un SEUL projet (sauf le rôle "admin", illimité — voir
|
PROPRIÉTAIRE de projet — pour TOUS les rôles, y compris "admin" (qui
|
||||||
|
garde seulement le droit d'avoir plusieurs projets À LUI, voir
|
||||||
core/auth_guard.py). Le tout premier compte jamais créé devient
|
core/auth_guard.py). Le tout premier compte jamais créé devient
|
||||||
automatiquement admin (auth/create_user.py) : la fixture `client` de
|
automatiquement admin (auth/create_user.py) : la fixture `client` de
|
||||||
conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du
|
conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du
|
||||||
@@ -200,6 +201,55 @@ def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
|
|||||||
_cleanup_project("second")
|
_cleanup_project("second")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_isolated_user_with_project(email, name="Jeu de la victime"):
|
||||||
|
"""Compte + projet créés directement (sans passer par le client HTTP
|
||||||
|
de test) — combiner ici deux instances de `test_client()` actives en
|
||||||
|
parallèle (la fixture `client`, admin partagé, ET une seconde pour
|
||||||
|
créer la victime) fait planter Werkzeug ("Popped wrong app context"),
|
||||||
|
une limite connue du client de test Flask quand deux contextes de
|
||||||
|
requête s'entremêlent plutôt que de s'imbriquer proprement."""
|
||||||
|
user_id = auth.create_user(email, "Sup3r$ecret!", "Test", "Victime")
|
||||||
|
auth.confirm_totp(user_id)
|
||||||
|
slug = db.create_game(name, owner_folder=str(user_id))
|
||||||
|
auth.set_project_slug(user_id, slug)
|
||||||
|
return auth.get_user_by_id(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_access_another_users_project(client):
|
||||||
|
"""Faille corrigée : le rôle "admin" contournait entièrement
|
||||||
|
l'isolation par projet (voir core/auth_guard.py) — un admin pouvait
|
||||||
|
ouvrir/modifier/supprimer le jeu de N'IMPORTE QUEL autre compte en
|
||||||
|
connaissant simplement son slug. `client` (conftest.py) est déjà
|
||||||
|
connecté en admin ; désormais bloqué exactement comme un "user"."""
|
||||||
|
victim = _create_isolated_user_with_project("adminvictim@example.com")
|
||||||
|
try:
|
||||||
|
assert client.get(f"/game/{victim['project_slug']}").status_code == 403
|
||||||
|
assert client.get(f"/game/{victim['project_slug']}/screens/1/edit").status_code == 403
|
||||||
|
assert client.post(f"/game/{victim['project_slug']}/delete").status_code == 403
|
||||||
|
# Le projet de la victime doit être resté intact (la tentative de
|
||||||
|
# suppression ci-dessus doit avoir été bloquée AVANT toute écriture).
|
||||||
|
assert db.game_meta(victim["project_slug"]) is not None
|
||||||
|
finally:
|
||||||
|
_cleanup_project("adminvictim@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_home_page_only_lists_their_own_projects(client, game):
|
||||||
|
"""routes/games/index.py (page "Mes jeux") scannait auparavant TOUT
|
||||||
|
projects/ sans filtrage (db.list_games()) — n'importe quel admin
|
||||||
|
voyait donc le slug (et le nom de dossier propriétaire) de chaque
|
||||||
|
compte. Corrigé : ne renvoie plus que les projets du compte connecté,
|
||||||
|
admin compris."""
|
||||||
|
victim = _create_isolated_user_with_project("adminlistvictim@example.com")
|
||||||
|
try:
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert game in html
|
||||||
|
assert victim["project_slug"] not in html
|
||||||
|
finally:
|
||||||
|
_cleanup_project("adminlistvictim@example.com")
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_user_cannot_delete_their_only_project(anon_client):
|
def test_non_admin_user_cannot_delete_their_only_project(anon_client):
|
||||||
"""Sans issue de secours (games_new renvoie toujours vers son
|
"""Sans issue de secours (games_new renvoie toujours vers son
|
||||||
project_slug, existant ou non), le supprimer serait un piège sans
|
project_slug, existant ou non), le supprimer serait un piège sans
|
||||||
|
|||||||
+10
-1
@@ -17,7 +17,16 @@ def test_create_game_and_meta(tmp_game_slug_cleanup):
|
|||||||
tmp_game_slug_cleanup(slug)
|
tmp_game_slug_cleanup(slug)
|
||||||
meta = db.game_meta(slug)
|
meta = db.game_meta(slug)
|
||||||
assert meta["name"] == "Mon Jeu De Test"
|
assert meta["name"] == "Mon Jeu De Test"
|
||||||
assert slug in [g["slug"] for g in db.list_games()]
|
|
||||||
|
|
||||||
|
def test_list_games_only_returns_the_given_owners_projects(tmp_game_slug_cleanup):
|
||||||
|
"""db.list_games(owner_folder) ne scanne plus jamais projects/ en
|
||||||
|
entier (voir routes/games/index.py — faille corrigée : un admin
|
||||||
|
voyait auparavant les projets de tous les comptes)."""
|
||||||
|
slug = db.create_game("Jeu propriétaire", owner_folder="999999")
|
||||||
|
tmp_game_slug_cleanup(slug)
|
||||||
|
assert slug in [g["slug"] for g in db.list_games("999999")]
|
||||||
|
assert db.list_games("000000") == []
|
||||||
|
|
||||||
|
|
||||||
def test_create_game_deduplicates_slug(tmp_game_slug_cleanup):
|
def test_create_game_deduplicates_slug(tmp_game_slug_cleanup):
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ def test_admin_can_create_several_games_via_onboarding(client):
|
|||||||
slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0]
|
slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0]
|
||||||
assert slug2 != slug1
|
assert slug2 != slug1
|
||||||
|
|
||||||
slugs = {g["slug"] for g in db.list_games()}
|
import tests.conftest as conftest_module
|
||||||
|
slugs = {g["slug"] for g in db.list_games(str(conftest_module._TEST_ADMIN_ID))}
|
||||||
assert slug1 in slugs
|
assert slug1 in slugs
|
||||||
assert slug2 in slugs
|
assert slug2 in slugs
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+10
-9
@@ -5,7 +5,6 @@ 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, _complete_onboarding, _cleanup_project, anon_client # noqa: F401
|
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401
|
||||||
|
|
||||||
@@ -215,7 +214,13 @@ def test_update_email_rejects_an_email_already_used(anon_client):
|
|||||||
_cleanup_project("emailtaken2@example.com")
|
_cleanup_project("emailtaken2@example.com")
|
||||||
|
|
||||||
|
|
||||||
def test_update_email_renames_the_user_project_folder(anon_client):
|
def test_update_email_never_touches_the_project_folder(anon_client):
|
||||||
|
"""Le dossier propriétaire d'un projet est nommé d'après l'ID du
|
||||||
|
compte (voir routes/games/games_new.py, opaque et stable — jamais
|
||||||
|
l'email, voir scripts/migrate_owner_folders_to_user_id.py pour la
|
||||||
|
migration qui a retiré l'ancien schéma slugify(email)) : changer
|
||||||
|
d'adresse email ne doit donc plus jamais renommer quoi que ce soit sur
|
||||||
|
disque, ni changer project_slug."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
_register(anon_client, "oldmail@example.com")
|
_register(anon_client, "oldmail@example.com")
|
||||||
@@ -231,13 +236,9 @@ def test_update_email_renames_the_user_project_folder(anon_client):
|
|||||||
assert auth.get_user_by_email("oldmail@example.com") is None
|
assert auth.get_user_by_email("oldmail@example.com") is None
|
||||||
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"]
|
# Ni le slug ni le dossier sur disque n'ont bougé.
|
||||||
# Structure de dossiers par utilisateur (voir db/games/
|
assert new_user["project_slug"] == old_slug
|
||||||
# project_slug.py) : seul le dossier PROPRIÉTAIRE change de nom,
|
assert os.path.isdir(db.game_dir(old_slug))
|
||||||
# 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))
|
|
||||||
|
|
||||||
# La connexion se fait désormais avec la nouvelle adresse.
|
# La connexion se fait désormais avec la nouvelle adresse.
|
||||||
anon_client.post("/logout")
|
anon_client.post("/logout")
|
||||||
|
|||||||
Reference in New Issue
Block a user