From 5c2d7c49cd279d779e277c3e5a13edf89b925b8b Mon Sep 17 00:00:00 2001 From: william Date: Sat, 29 Aug 2026 06:18:00 +0200 Subject: [PATCH 01/16] =?UTF-8?q?Corrige=20l'affichage=20des=20codes=20de?= =?UTF-8?q?=20r=C3=A9cup=C3=A9ration=202FA=20(invisibles=20en=20pratique)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le modal était placé en dehors de #pageChrome ET de
— or pjax.js (swapDocument()) ne remplace jamais que ces deux conteneurs à chaque navigation, jamais le body entier. La redirection qui suit la confirmation de la 2FA passe par une navigation pjax (fetch), pas un vrai rechargement de page : le HTML du modal était bien renvoyé par le serveur, mais jamais copié dans le DOM réellement affiché, donc jamais visible pour un utilisateur qui vient de s'inscrire. Déplacé à l'intérieur de
: fait maintenant partie de innerHTML remplacé par pjax.js à chaque navigation, comme le reste du contenu de page. Co-Authored-By: Claude Sonnet 5 --- templates/base.html | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/templates/base.html b/templates/base.html index 63130863..83dd3209 100644 --- a/templates/base.html +++ b/templates/base.html @@ -22,24 +22,6 @@ ne rechargeait pas la page en entier (Ctrl+F5), et inversement laissée en place en revenant sur l'accueil (liste des jeux) où elle n'a pourtant rien à faire. --> -{% set recovery_codes = pop_recovery_codes() %} -{% if recovery_codes %} - -{% endif %}
diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 00000000..a7a01801 --- /dev/null +++ b/tests/test_publish.py @@ -0,0 +1,91 @@ +"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir +routes/publish/publish_game.py et publish/build_package.py). Le vrai +téléchargement du Python portable + Flask (publish/vendor_runtime.py) +n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un +dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip +(fichiers présents, structure), jamais un vrai lancement du serveur +embarqué (nécessite un environnement Windows réel, voir le plan).""" +import os +import zipfile + +import pytest + +import publish.build_package as build_package + +from tests.test_auth import anon_client # noqa: F401 + + +@pytest.fixture +def fake_vendor(tmp_path, monkeypatch): + """Un python-embed/pylibs factice, juste assez pour que l'assemblage + du zip (copie de fichiers) fonctionne sans jamais toucher au réseau.""" + python_dir = tmp_path / "python-embed-amd64" + python_dir.mkdir() + (python_dir / "python.exe").write_bytes(b"fake-exe") + pylibs_dir = tmp_path / "pylibs" + pylibs_dir.mkdir() + (pylibs_dir / "flask").mkdir() + (pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8") + + def _fake_ensure_vendor_ready(): + return str(python_dir), str(pylibs_dir) + + monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready) + return python_dir, pylibs_dir + + +def test_build_game_zip_contains_the_expected_files(fake_vendor, game): + zip_path, game_name = build_package.build_game_zip(game) + try: + assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py) + with zipfile.ZipFile(zip_path) as zf: + names = set(zf.namelist()) + for expected in [ + "run.bat", "player_app.py", + f"projects/{game}/game.db", + "screens/__init__.py", "db/__init__.py", "filters/__init__.py", + "core/__init__.py", "core/flask_app.py", "core/jinja_filters.py", + "templates/play.html", + "static/style.css", "static/csrf_fetch.js", + "python-embed/python.exe", "python-embed/pylibs/flask/__init__.py", + ]: + assert expected in names, f"{expected} manquant du zip" + finally: + if os.path.isfile(zip_path): + os.remove(zip_path) + + +def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game): + zip_path, _ = build_package.build_game_zip(game) + try: + with zipfile.ZipFile(zip_path) as zf: + player_app_src = zf.read("player_app.py").decode("utf-8") + assert f'SLUG = "{game}"' in player_app_src + assert "_SLUG_PLACEHOLDER" not in player_app_src + finally: + if os.path.isfile(zip_path): + os.remove(zip_path) + + +def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game): + resp = client.post(f"/game/{game}/publish") + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "application/zip" + assert "attachment" in resp.headers["Content-Disposition"] + assert resp.data[:2] == b"PK" # signature d'un fichier zip + + +def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client): + """Réutilise la garde d'accès existante (core/auth_guard.py) sans + code supplémentaire — un compte non-admin ne doit pas pouvoir publier + le projet d'un autre (même vérification que /game/ lui-même, + voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project).""" + from tests.test_auth import _register, _confirm_2fa, _cleanup_project + + _register(anon_client, "publishisolation@example.com") + _confirm_2fa(anon_client) + try: + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish") + assert resp.status_code == 403 + finally: + _cleanup_project("publishisolation@example.com") From dcbec168187d535b6372166a0a46e9b2056201b4 Mon Sep 17 00:00:00 2001 From: william Date: Sat, 29 Aug 2026 19:14:19 +0200 Subject: [PATCH 12/16] =?UTF-8?q?Ajoute=20les=20=C3=A9v=C3=A9nements=20per?= =?UTF-8?q?sonnalis=C3=A9s=20(backend)=20:=20d=C3=A9clencher/=C3=A9couter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première moitié de la fonctionnalité "événements" (NEED_ACTION et autres) : une entité game-wide (nom, description, "a un paramètre élément" oui/non), déclenchable comme nouvelle action du graphe de logique depuis n'importe quelle scène/modèle, et écoutable comme nouveau type de déclencheur depuis n'importe quel autre. L'UI (nouvel onglet "Événements" dans screen_edit.html, formulaires de nœud, exécution côté client dans play.html) suit dans un commit séparé. - db/custom_events/ (calqué sur db/global_vars/) : CRUD de la table _custom_events (nom unique, description, has_element_param). create_custom_event est idempotent par nom (même convention que create_global_variable) — sans risque en cas de double soumission. - screens/flow/ : 3 nouvelles colonnes sur _flow_nodes (trigger_custom_event_id/target_custom_event_id : quel événement un nœud écoute/déclenche ; target_element_from_event : indicateur réutilisable par n'importe quel nœud Action utilisant déjà target_element_id, pour résoudre "l'élément transmis par l'événement en cours" au lieu d'une cible fixe — contourne la contrainte de clé étrangère de target_element_id, qui empêche d'y stocker un sentinel comme EVENT_ROW_ID directement). Nouveau trigger_event "evenement" et action_type "declencher_evenement". - screens/custom_events/ (PAS dans db/, même séparation que screens/elements/delete_element.py) : delete_custom_event, la SEULE suppression d'entité game-wide du moteur à vraiment cascader (demande explicite) — supprime tous les nœuds/arêtes qui référencent l'événement, sur TOUTES les scènes ET tous les modèles à la fois (aucun filtre screen_id nécessaire : un modèle est un écran caché, même table _flow_nodes). list_custom_event_usages : où un événement est écouté/déclenché, pour l'onglet Événements à venir. - routes/custom_events/ : CRUD monté sous /game//events/..., redirige vers l'éditeur de scène/modèle d'origine (screen_id transmis par le formulaire) avec l'onglet "events" à ouvrir. tests/test_custom_events.py (nouveau) : idempotence à la création, usages détectés sur deux écrans différents, suppression qui retire bien les DEUX nœuds (un sur une vraie scène, un sur un modèle/écran caché) en une seule opération, sans toucher aux écrans eux-mêmes. 207 tests au total. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 6 + db/custom_events/__init__.py | 0 db/custom_events/create_custom_event.py | 26 ++++ .../ensure_custom_events_schema.py | 25 ++++ db/custom_events/get_custom_event.py | 12 ++ db/custom_events/list_custom_events.py | 10 ++ db/custom_events/update_custom_event.py | 19 +++ routes/__init__.py | 2 +- routes/custom_events/__init__.py | 1 + routes/custom_events/custom_event_delete.py | 18 +++ routes/custom_events/custom_event_edit.py | 14 +++ routes/custom_events/custom_event_new.py | 21 ++++ screens/__init__.py | 7 +- screens/custom_events/__init__.py | 9 ++ screens/custom_events/delete_custom_event.py | 30 +++++ .../custom_events/list_custom_event_usages.py | 37 ++++++ screens/flow/constants.py | 30 +++++ screens/flow/ensure_flow_schema.py | 19 +++ screens/labels/action_type_labels.py | 1 + screens/labels/trigger_labels.py | 1 + tests/test_custom_events.py | 112 ++++++++++++++++++ 21 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 db/custom_events/__init__.py create mode 100644 db/custom_events/create_custom_event.py create mode 100644 db/custom_events/ensure_custom_events_schema.py create mode 100644 db/custom_events/get_custom_event.py create mode 100644 db/custom_events/list_custom_events.py create mode 100644 db/custom_events/update_custom_event.py create mode 100644 routes/custom_events/__init__.py create mode 100644 routes/custom_events/custom_event_delete.py create mode 100644 routes/custom_events/custom_event_edit.py create mode 100644 routes/custom_events/custom_event_new.py create mode 100644 screens/custom_events/__init__.py create mode 100644 screens/custom_events/delete_custom_event.py create mode 100644 screens/custom_events/list_custom_event_usages.py create mode 100644 tests/test_custom_events.py diff --git a/db/__init__.py b/db/__init__.py index 0232189c..6a67b117 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -61,6 +61,11 @@ from .global_vars.update_global_variable import update_global_variable from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id +from .custom_events.list_custom_events import list_custom_events +from .custom_events.get_custom_event import get_custom_event +from .custom_events.create_custom_event import create_custom_event +from .custom_events.update_custom_event import update_custom_event + __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "slugify", "table_name_for", "game_dir", "db_path", "connect", @@ -73,4 +78,5 @@ __all__ = [ "list_global_variables", "get_global_variable", "create_global_variable", "update_global_variable_value", "update_global_variable", "delete_global_variable", "delete_global_variable_by_id", + "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", ] diff --git a/db/custom_events/__init__.py b/db/custom_events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/db/custom_events/create_custom_event.py b/db/custom_events/create_custom_event.py new file mode 100644 index 00000000..e071bded --- /dev/null +++ b/db/custom_events/create_custom_event.py @@ -0,0 +1,26 @@ +from ..connection import connect +from .ensure_custom_events_schema import ensure_custom_events_schema + + +def create_custom_event(slug, name, description="", has_element_param=False): + """Idempotent par nom (même convention que create_global_variable.py) : + si le nom existe déjà, ne touche à rien et renvoie simplement son id + existant plutôt que de lever une erreur — sans risque en cas de + double clic/soumission depuis l'onglet Événements (screen_edit.html).""" + name = (name or "").strip() + if not name: + return None + ensure_custom_events_schema(slug) + conn = connect(slug) + existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone() + if existing: + conn.close() + return existing["id"] + conn.execute( + "INSERT INTO _custom_events (name, description, has_element_param) VALUES (?, ?, ?)", + (name, (description or "").strip(), 1 if has_element_param else 0), + ) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return new_id diff --git a/db/custom_events/ensure_custom_events_schema.py b/db/custom_events/ensure_custom_events_schema.py new file mode 100644 index 00000000..8da80e61 --- /dev/null +++ b/db/custom_events/ensure_custom_events_schema.py @@ -0,0 +1,25 @@ +from ..connection import connect + + +def ensure_custom_events_schema(slug): + """Migration légère (même principe que ensure_global_vars_schema.py) : + crée _custom_events si absente. Un événement personnalisé vit pour + TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE — + c'est par ce nom qu'on le désigne dans l'interface, même si les nœuds + du graphe le référencent par id technique (voir + screens/flow/ensure_flow_schema.py, trigger_custom_event_id/ + target_custom_event_id).""" + conn = connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _custom_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + has_element_param INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/db/custom_events/get_custom_event.py b/db/custom_events/get_custom_event.py new file mode 100644 index 00000000..453deb07 --- /dev/null +++ b/db/custom_events/get_custom_event.py @@ -0,0 +1,12 @@ +from ..connection import connect +from .ensure_custom_events_schema import ensure_custom_events_schema + + +def get_custom_event(slug, event_id): + if not event_id: + return None + ensure_custom_events_schema(slug) + conn = connect(slug) + row = conn.execute("SELECT * FROM _custom_events WHERE id = ?", (event_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/db/custom_events/list_custom_events.py b/db/custom_events/list_custom_events.py new file mode 100644 index 00000000..624fb80c --- /dev/null +++ b/db/custom_events/list_custom_events.py @@ -0,0 +1,10 @@ +from ..connection import connect +from .ensure_custom_events_schema import ensure_custom_events_schema + + +def list_custom_events(slug): + ensure_custom_events_schema(slug) + conn = connect(slug) + rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/db/custom_events/update_custom_event.py b/db/custom_events/update_custom_event.py new file mode 100644 index 00000000..839d6606 --- /dev/null +++ b/db/custom_events/update_custom_event.py @@ -0,0 +1,19 @@ +from ..connection import connect +from .ensure_custom_events_schema import ensure_custom_events_schema + + +def update_custom_event(slug, event_id, description="", has_element_param=False): + """Le NOM reste volontairement immuable après création — comme une + variable globale (voir update_global_variable.py) : c'est par ce nom + qu'on désigne l'événement dans l'interface, mais surtout par son ID + TECHNIQUE que les nœuds du graphe le référencent + (trigger_custom_event_id/target_custom_event_id) — le renommer ne + casserait donc rien niveau logique.""" + ensure_custom_events_schema(slug) + conn = connect(slug) + conn.execute( + "UPDATE _custom_events SET description = ?, has_element_param = ? WHERE id = ?", + ((description or "").strip(), 1 if has_element_param else 0, event_id), + ) + conn.commit() + conn.close() diff --git a/routes/__init__.py b/routes/__init__.py index 722a00c0..66bd2092 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events diff --git a/routes/custom_events/__init__.py b/routes/custom_events/__init__.py new file mode 100644 index 00000000..bdf004b1 --- /dev/null +++ b/routes/custom_events/__init__.py @@ -0,0 +1 @@ +from . import custom_event_new, custom_event_edit, custom_event_delete diff --git a/routes/custom_events/custom_event_delete.py b/routes/custom_events/custom_event_delete.py new file mode 100644 index 00000000..9e41bfda --- /dev/null +++ b/routes/custom_events/custom_event_delete.py @@ -0,0 +1,18 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//events//delete", methods=["POST"]) +def custom_event_delete(slug, event_id): + """Supprime aussi TOUS les nœuds Déclencheur/Action qui référencent cet + événement, sur toutes les scènes et tous les modèles (voir + screens/custom_events/delete_custom_event.py) — seule suppression + d'entité game-wide du moteur à vraiment cascader, sur demande + explicite (une variable globale ou une définition d'objet supprimée, + elles, laissent des références pendantes tolérées ailleurs).""" + screen_id = request.form.get("screen_id", type=int) + screens.delete_custom_event(slug, event_id) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events")) diff --git a/routes/custom_events/custom_event_edit.py b/routes/custom_events/custom_event_edit.py new file mode 100644 index 00000000..ea8ec496 --- /dev/null +++ b/routes/custom_events/custom_event_edit.py @@ -0,0 +1,14 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//events//edit", methods=["POST"]) +def custom_event_edit(slug, event_id): + description = request.form.get("description") or "" + has_element_param = request.form.get("has_element_param") == "on" + screen_id = request.form.get("screen_id", type=int) + db.update_custom_event(slug, event_id, description, has_element_param) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events")) diff --git a/routes/custom_events/custom_event_new.py b/routes/custom_events/custom_event_new.py new file mode 100644 index 00000000..d2468475 --- /dev/null +++ b/routes/custom_events/custom_event_new.py @@ -0,0 +1,21 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//events/new", methods=["POST"]) +def custom_event_new(slug): + """Depuis l'onglet Événements de l'éditeur de scène/modèle + (screen_edit.html) — idempotent par nom (voir db.create_custom_event), + sans risque en cas de double soumission. screen_id (l'écran/modèle + depuis lequel on a créé l'événement) sert uniquement à revenir sur le + bon écran ensuite, jamais stocké sur l'événement lui-même (game-wide).""" + name = (request.form.get("name") or "").strip() + description = request.form.get("description") or "" + has_element_param = request.form.get("has_element_param") == "on" + screen_id = request.form.get("screen_id", type=int) + if name: + db.create_custom_event(slug, name, description, has_element_param) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events")) diff --git a/screens/__init__.py b/screens/__init__.py index 8d8f6990..36706271 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -55,7 +55,9 @@ from .labels.element_action_properties import ( from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS -from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID +from .flow.constants import ( + TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, EVENT_ROW_ID, +) from .rendering.render_element_html import render_element_html from .rendering.trigger_for import _trigger_for @@ -117,4 +119,7 @@ from .animations.update_animation_clip import update_animation_clip from .animations.delete_animation_clip import delete_animation_clip from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS +from .custom_events.list_custom_event_usages import list_custom_event_usages +from .custom_events.delete_custom_event import delete_custom_event + from .payload.full_game_payload import full_game_payload diff --git a/screens/custom_events/__init__.py b/screens/custom_events/__init__.py new file mode 100644 index 00000000..f486ccdb --- /dev/null +++ b/screens/custom_events/__init__.py @@ -0,0 +1,9 @@ +"""Tout ce qui touche à la fois _custom_events (db/custom_events/) ET +_flow_nodes/_flow_edges (screens/flow/) — jamais dans db/custom_events/, +pour la même raison que screens/elements/delete_element.py n'est pas +dans db/ : ce sont deux domaines différents (une entité game-wide, des +nœuds de graphe), le nettoyage croisé vit dans screens/, pas db/.""" +from .list_custom_event_usages import list_custom_event_usages +from .delete_custom_event import delete_custom_event + +__all__ = ["list_custom_event_usages", "delete_custom_event"] diff --git a/screens/custom_events/delete_custom_event.py b/screens/custom_events/delete_custom_event.py new file mode 100644 index 00000000..1f2b9826 --- /dev/null +++ b/screens/custom_events/delete_custom_event.py @@ -0,0 +1,30 @@ +import db +from ..flow.ensure_flow_schema import ensure_flow_schema + + +def delete_custom_event(slug, event_id): + """Seule suppression d'entité game-wide du moteur qui cascade + RÉELLEMENT (demande explicite) : une variable globale ou une + définition d'objet supprimée laisse des références pendantes + tolérées ailleurs (voir db/global_vars/, db/definitions/) — un + événement, lui, doit emporter avec lui TOUS ses écouteurs (nœuds + Déclencheur, trigger_custom_event_id) et déclencheurs (nœuds Action, + target_custom_event_id), sur TOUS les écrans ET modèles à la fois + (aucun filtre screen_id, comme screens/elements/delete_element.py — + un modèle est un écran caché, même table _flow_nodes).""" + ensure_flow_schema(slug) + conn = db.connect(slug) + conn.execute( + """DELETE FROM _flow_edges WHERE from_node_id IN + (SELECT id FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?) + OR to_node_id IN + (SELECT id FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?)""", + (event_id, event_id, event_id, event_id), + ) + conn.execute( + "DELETE FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?", + (event_id, event_id), + ) + conn.execute("DELETE FROM _custom_events WHERE id = ?", (event_id,)) + conn.commit() + conn.close() diff --git a/screens/custom_events/list_custom_event_usages.py b/screens/custom_events/list_custom_event_usages.py new file mode 100644 index 00000000..aa965630 --- /dev/null +++ b/screens/custom_events/list_custom_event_usages.py @@ -0,0 +1,37 @@ +import db +from ..flow.ensure_flow_schema import ensure_flow_schema + + +def list_custom_event_usages(slug): + """Pour CHAQUE événement, où est-il écouté ("ecoute", nœud Déclencheur) + et déclenché ("declenche", nœud Action) — une seule requête plutôt + qu'une par événement, pour l'onglet Événements (screen_edit.html) qui + les affiche tous à la fois. Aucun filtre screen_id : couvre aussi bien + les écrans que les modèles (un modèle est un écran caché, voir + screens/element_types/create_element_type.py), exactement comme la + suppression en cascade (delete_custom_event.py). + + Renvoie {event_id: [{"screen_id", "screen_name", "is_template", "role"}, ...]}.""" + ensure_flow_schema(slug) + conn = db.connect(slug) + rows = conn.execute( + """ + SELECT DISTINCT n.id AS node_id, s.id AS screen_id, s.name AS screen_name, s.is_template AS is_template, + CASE WHEN n.trigger_custom_event_id IS NOT NULL THEN n.trigger_custom_event_id + ELSE n.target_custom_event_id END AS event_id, + CASE WHEN n.trigger_custom_event_id IS NOT NULL THEN 'ecoute' + ELSE 'declenche' END AS role + FROM _flow_nodes n + JOIN _screens s ON s.id = n.screen_id + WHERE n.trigger_custom_event_id IS NOT NULL OR n.target_custom_event_id IS NOT NULL + ORDER BY s.name + """ + ).fetchall() + conn.close() + usages = {} + for row in rows: + usages.setdefault(row["event_id"], []).append({ + "screen_id": row["screen_id"], "screen_name": row["screen_name"], + "is_template": bool(row["is_template"]), "role": row["role"], + }) + return usages diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 3460ca17..23a087ab 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -21,6 +21,16 @@ # AUTOINCREMENT SQLite). CLICKED_ROW_ID = -1 +# Même principe que CLICKED_ROW_ID, pour un second contexte dynamique : +# "la ligne de Répéteur transmise par l'événement personnalisé en cours +# d'exécution" (voir screens/custom_events/, nœud Déclencheur +# trigger_event="evenement") — stockable directement dans target_row_id +# (déjà sans contrainte de clé étrangère, voir ensure_flow_schema.py) au +# même titre que CLICKED_ROW_ID, résolu côté client (templates/play.html) +# via window.lastEventParams.row_id au lieu de window.lastClickedRowId. +# Valeur distincte de CLICKED_ROW_ID pour ne jamais les confondre. +EVENT_ROW_ID = -2 + TRIGGER_EVENTS = [ ("clic", "Au clic"), ("soumission", "À la soumission"), @@ -42,6 +52,14 @@ TRIGGER_EVENTS = [ # cacher/montrer un élément selon l'état de la partie SANS qu'un clic # explicite soit nécessaire pour le réévaluer. ("affichage", "À l'affichage de l'écran"), + # Écoute un événement personnalisé (voir screens/custom_events/), + # déclenché depuis N'IMPORTE QUEL autre graphe (une autre scène, un + # autre modèle) via l'action "declencher_evenement" — ni élément ni + # écran précis : trigger_custom_event_id (quel événement) est le seul + # réglage propre à ce nœud, retrouvé par un scan global de + # gameData.flows côté client (voir dispatchGameEvent() dans + # templates/play.html), au même titre que findTriggerNode(). + ("evenement", "Sur un événement personnalisé"), ] CONDITION_OPERATORS = [ @@ -69,4 +87,16 @@ FLOW_NODE_FIELDS = { "action_type", "target_screen_id", "target_element_id", "element_property", "element_value", "target_definition_id", "target_row_id", "target_field", "data_operation", "data_value", "target_variable", + # Événements personnalisés (voir screens/custom_events/) : quel + # événement un nœud Déclencheur écoute (trigger_event="evenement") ou + # un nœud Action déclenche (action_type="declencher_evenement") — + # target_element_id/target_row_id (déjà ci-dessus, EVENT_ROW_ID + # compris) portent les paramètres transmis au déclenchement. + # target_element_from_event : indicateur réutilisable par N'IMPORTE + # QUEL nœud Action qui accepte déjà target_element_id (v1 : seule + # "modifier_element" le supporte) — contourne la contrainte de clé + # étrangère de target_element_id (impossible d'y stocker un sentinel + # comme EVENT_ROW_ID sur target_row_id) en résolvant "l'élément + # transmis par l'événement en cours" côté client à la place. + "trigger_custom_event_id", "target_custom_event_id", "target_element_from_event", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index 155f2430..aeae3bdf 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -72,5 +72,24 @@ def ensure_flow_schema(slug): conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT") if "cond_variable_chemin" not in cols: conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT") + if "trigger_custom_event_id" not in cols: + # Événements personnalisés (voir screens/custom_events/) : quel + # événement un nœud Déclencheur écoute (trigger_event="evenement") + # ou un nœud Action déclenche (action_type="declencher_evenement"). + # Pas de FK vers _custom_events : une suppression d'événement + # nettoie déjà ces colonnes elle-même (voir + # screens/custom_events/delete_custom_event.py), une contrainte + # ferait double emploi sans rien apporter de plus. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER") + if "target_custom_event_id" not in cols: + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER") + if "target_element_from_event" not in cols: + # Indicateur réutilisable par n'importe quel nœud Action qui + # accepte déjà target_element_id (v1 : seule "modifier_element") — + # contourne la contrainte de clé étrangère de target_element_id + # (impossible d'y stocker un sentinel comme EVENT_ROW_ID sur + # target_row_id) en résolvant "l'élément transmis par l'événement + # en cours" côté client (templates/play.html) à la place. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_element_from_event INTEGER NOT NULL DEFAULT 0") conn.commit() conn.close() diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index cd0f65ef..df15c443 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -8,5 +8,6 @@ ACTION_TYPE_LABELS = { "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", "activer_onglet": "Afficher cet élément, masquer tous ses frères", "attendre": "Attendre quelques secondes avant de continuer", + "declencher_evenement": "Déclencher un événement", "rien": "Ne rien faire", } diff --git a/screens/labels/trigger_labels.py b/screens/labels/trigger_labels.py index 49bc3531..594fd32a 100644 --- a/screens/labels/trigger_labels.py +++ b/screens/labels/trigger_labels.py @@ -4,4 +4,5 @@ TRIGGER_LABELS = { "affichage": "À l'affichage de l'écran", "survol": "Au survol", "fin_survol": "Fin du survol", + "evenement": "Sur un événement personnalisé", } diff --git a/tests/test_custom_events.py b/tests/test_custom_events.py new file mode 100644 index 00000000..76bf3801 --- /dev/null +++ b/tests/test_custom_events.py @@ -0,0 +1,112 @@ +"""Événements personnalisés (db/custom_events/, screens/custom_events/) : +un événement game-wide (ex. NEED_ACTION) écouté par un nœud Déclencheur +("trigger_event"="evenement") sur UNE scène/UN modèle, et déclenché par +un nœud Action ("action_type"="declencher_evenement") sur UNE AUTRE +scène/UN AUTRE modèle — la seule entité game-wide du moteur dont la +suppression cascade RÉELLEMENT vers les nœuds qui la référencent, +partout à la fois (voir screens/custom_events/delete_custom_event.py).""" +import json +import re + +import db +import screens + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def _add_element(client, slug, screen_id, widget="bouton"): + resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False) + return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + +def _add_flow_node(client, slug, screen_id, **fields): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps(fields), + content_type="application/json", + ) + assert resp.status_code == 200 + return resp.get_json()["id"] + + +def test_create_event_is_idempotent_by_name(client, game): + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/events/new", data={ + "name": "NEED_ACTION", "description": "Besoin d'action", "has_element_param": "on", "screen_id": screen_id, + }) + assert resp.status_code == 302 + events = db.list_custom_events(game) + assert len(events) == 1 + assert events[0]["name"] == "NEED_ACTION" + assert events[0]["has_element_param"] == 1 + + # Double soumission : ne crée pas un deuxième événement du même nom. + client.post(f"/game/{game}/events/new", data={"name": "NEED_ACTION", "screen_id": screen_id}) + assert len(db.list_custom_events(game)) == 1 + + +def test_edit_event_updates_description_and_param_flag(client, game): + screen_id = _create_screen(client, game) + event_id = db.create_custom_event(game, "NEED_ACTION", "old", False) + resp = client.post(f"/game/{game}/events/{event_id}/edit", data={ + "description": "new", "has_element_param": "on", "screen_id": screen_id, + }) + assert resp.status_code == 302 + event = db.get_custom_event(game, event_id) + assert event["description"] == "new" + assert event["has_element_param"] == 1 + + +def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game): + event_id = db.create_custom_event(game, "NEED_ACTION", "", True) + + listener_screen = _create_screen(client, game, "Écran écouteur") + _add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id) + + dispatcher_screen = _create_screen(client, game, "Écran déclencheur") + element_id = _add_element(client, game, dispatcher_screen) + _add_flow_node( + client, game, dispatcher_screen, + node_type="action", action_type="declencher_evenement", + target_custom_event_id=event_id, target_element_id=element_id, + ) + + usages = screens.list_custom_event_usages(game) + roles = {u["role"] for u in usages[event_id]} + screen_ids = {u["screen_id"] for u in usages[event_id]} + assert roles == {"ecoute", "declenche"} + assert screen_ids == {listener_screen, dispatcher_screen} + + +def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game): + event_id = db.create_custom_event(game, "NEED_ACTION", "", True) + + listener_screen = _create_screen(client, game, "Écran écouteur") + listener_node_id = _add_flow_node( + client, game, listener_screen, + node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id, + ) + + # Le modèle (élément de jeu réutilisable) est un écran caché — voir + # screens.create_element_type — le déclencheur y est posé exactement + # comme sur une vraie scène. + screens.create_element_type(game, "Carte email", "📧") + template = next(t for t in screens.list_element_types(game) if t["name"] == "Carte email") + template_screen_id = template["template_screen_id"] + dispatcher_node_id = _add_flow_node( + client, game, template_screen_id, + node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id, + ) + + resp = client.post(f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False) + assert resp.status_code == 302 + + assert db.get_custom_event(game, event_id) is None + assert screens.get_flow_node(game, listener_node_id) is None + assert screens.get_flow_node(game, dispatcher_node_id) is None + # Les écrans/modèles eux-mêmes ne sont pas touchés, seuls les nœuds. + assert screens.get_screen(game, listener_screen) is not None + assert screens.get_screen(game, template_screen_id) is not None From f436190f909c9e37afa2ed1d727a00dcaaa4b3d9 Mon Sep 17 00:00:00 2001 From: william Date: Sat, 29 Aug 2026 19:34:24 +0200 Subject: [PATCH 13/16] =?UTF-8?q?Ajoute=20les=20=C3=A9v=C3=A9nements=20per?= =?UTF-8?q?sonnalis=C3=A9s=20(=C3=A9diteur=20+=20ex=C3=A9cution)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deuxième moitié de la fonctionnalité "événements" (voir le commit précédent pour le backend) : un nouvel onglet "📣 Événements" dans screen_edit.html (scène ET modèle, même route) pour créer/modifier/ supprimer un événement (nom, description, "a un paramètre élément" oui/ non) et voir où il est déjà écouté/déclenché ; deux nouveaux types de nœud dans le graphe de logique ; exécution réelle côté client (play.html). - templates/screen_edit.html : - Onglet Événements : barre de création + tableau (patron form/name="..." + attribut `form="eventEditFormN"` déjà utilisé par l'onglet Variables du tableau de bord, pour éditer plusieurs champs d'une ligne sans emboîter un dans un ). Colonne "Utilisé par" avec liens directs vers les écrans/modèles concernés (list_custom_event_usages, déjà en place côté backend). - Nœud Déclencheur "Sur un événement personnalisé" : un simple sélecteur d'événement (trigger_custom_event_id) — ni élément ni écran, retrouvé par un scan global côté client (comme "À l'affichage de l'écran"). - Nœud Action "Déclencher un événement" : sélecteur d'événement (target_custom_event_id), avec élément/ligne concernés affichés seulement si l'événement a has_element_param (réutilise le sélecteur d'élément existant + "Ligne cliquée" pour la ligne, pas de définition d'objet ici donc pas de liste de lignes fixes possible). - Nœud Action "Modifier un élément" : nouvelle case "Utiliser l'élément transmis par l'événement en cours" (target_element_from_event) — v1, seule cette action l'expose (extensible plus tard sans nouveau changement de schéma). - nodeLabel()/submitNodeForm()/toggleFlowTriggerFields()/ toggleFlowActionFields() étendus en conséquence, CUSTOM_EVENTS_MAP (id -> nom/has_element_param) exposé côté JS pour le rendu des libellés et l'affichage conditionnel des champs paramètre. - templates/play.html : - window.dispatchGameEvent(eventId, elementId, rowId) : scan de gameData.flows (toutes scènes ET modèles à la fois, même principe que findTriggerNode()/runScreenShowTriggers()) pour trouver chaque écouteur, pose window.lastEventParams puis exécute son graphe (runFlowFrom) — au même titre que window.lastClickedRowId pour "Ligne cliquée". - runActionNode : nouvelle branche "declencher_evenement" (résout CLICKED_ROW_ID pour la ligne transmise, comme "Modifier une donnée") ; branche "modifier_element" étendue pour résoudre dynamiquement l'élément depuis window.lastEventParams quand target_element_from_event est actif. - readFieldValue (évaluation de condition) et l'appel serveur de "Modifier une donnée" (routes/flow/flow_node_run_data.py) résolvent désormais aussi EVENT_ROW_ID (-2), au même endroit que CLICKED_ROW_ID (-1) déjà en place. - routes/screens/screen_edit.py : passe custom_events/ custom_event_usages/custom_events_map_json au template (même patron que global_variables déjà threadé pour le sélecteur de variable dans le formulaire de Condition). Nouveaux tests dans tests/test_custom_events.py : forme exacte du payload runtime exposé au JS (types entiers, pas des chaînes — une comparaison stricte "===" échouerait silencieusement sinon), target_element_from_event bien persisté, résolution serveur d' EVENT_ROW_ID depuis le corps de la requête. 210 tests au total. Co-Authored-By: Claude Sonnet 5 --- routes/flow/flow_node_run_data.py | 11 +- routes/screens/screen_edit.py | 8 ++ templates/play.html | 60 ++++++++- templates/screen_edit.html | 214 ++++++++++++++++++++++++++++-- tests/test_custom_events.py | 85 ++++++++++++ 5 files changed, 361 insertions(+), 17 deletions(-) diff --git a/routes/flow/flow_node_run_data.py b/routes/flow/flow_node_run_data.py index 0d362df8..ef233cd9 100644 --- a/routes/flow/flow_node_run_data.py +++ b/routes/flow/flow_node_run_data.py @@ -13,14 +13,19 @@ def flow_node_run_data(slug, node_id): un nœud du graphe de logique plutôt que depuis la table _actions. Si le nœud vise CLICKED_ROW_ID (voir "Ligne cliquée (Répéteur)" dans - screen_edit.html), la vraie ligne n'est connue qu'au moment du clic — - le client (play.html) la transmet dans le corps JSON de cette requête, - et elle remplace ici target_row_id avant d'exécuter l'action.""" + screen_edit.html) ou EVENT_ROW_ID (la ligne transmise par un événement + personnalisé en cours, voir screens/custom_events/), la vraie ligne + n'est connue qu'au moment de l'exécution — le client (play.html) la + transmet dans le corps JSON de cette requête, et elle remplace ici + target_row_id avant d'exécuter l'action.""" node = screens.get_flow_node(slug, node_id) if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": return jsonify({"ok": False}), 400 if node.get("target_row_id") == screens.CLICKED_ROW_ID: body = request.get_json(silent=True) or {} node = {**node, "target_row_id": body.get("clicked_row_id")} + elif node.get("target_row_id") == screens.EVENT_ROW_ID: + body = request.get_json(silent=True) or {} + node = {**node, "target_row_id": body.get("event_row_id")} ok = screens.apply_data_action(slug, node) return jsonify({"ok": ok}) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index f0c17600..d134fb25 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -44,6 +44,12 @@ def screen_edit(slug, screen_id): screens_map = {s["id"]: s["name"] for s in all_screens} animation_clips = screens.list_animation_clips(slug, screen_id) global_variables = db.list_global_variables(slug) + custom_events = db.list_custom_events(slug) + custom_event_usages = screens.list_custom_event_usages(slug) + custom_events_map = { + e["id"]: {"name": e["name"], "has_element_param": bool(e["has_element_param"])} + for e in custom_events + } return render_template( "screen_edit.html", @@ -80,4 +86,6 @@ def screen_edit(slug, screen_id): animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), fontawesome_icons=screens.FONTAWESOME_ICONS, global_variables=global_variables, + custom_events=custom_events, custom_event_usages=custom_event_usages, + custom_events_map_json=json.dumps(custom_events_map), ) diff --git a/templates/play.html b/templates/play.html index 43d0ff16..5d14cb78 100644 --- a/templates/play.html +++ b/templates/play.html @@ -788,10 +788,25 @@ bindHoverTexts(); bindHoverTriggers(); return Promise.resolve(); - } else if (node.action_type === 'modifier_element' && node.target_element_id) { - const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]'); + } else if (node.action_type === 'modifier_element' && (node.target_element_id || node.target_element_from_event)) { + // target_element_from_event (voir screens/custom_events/) : résout + // dynamiquement "l'élément transmis par l'événement en cours" + // (window.lastEventParams, posé par dispatchGameEvent() juste avant + // d'exécuter ce graphe) plutôt qu'une cible fixe choisie dans + // l'éditeur — contourne la contrainte de clé étrangère de + // target_element_id, qui empêche d'y stocker un sentinel comme + // EVENT_ROW_ID directement (voir screens/flow/ensure_flow_schema.py). + const resolvedElementId = node.target_element_from_event + ? (window.lastEventParams ? window.lastEventParams.element_id : null) + : node.target_element_id; + const targetEl = resolvedElementId ? document.querySelector('[data-element-id="' + resolvedElementId + '"]') : null; if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value); return Promise.resolve(); + } else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) { + let dispatchRowId = node.target_row_id; + if (dispatchRowId === -1) dispatchRowId = window.lastClickedRowId; // CLICKED_ROW_ID + window.dispatchGameEvent(node.target_custom_event_id, node.target_element_id || null, dispatchRowId != null ? dispatchRowId : null); + return Promise.resolve(); } else if (node.action_type === 'activer_onglet' && node.target_element_id) { // 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre // l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id, @@ -827,12 +842,16 @@ return new Promise(function(resolve) { setTimeout(resolve, ms); }); } else if (node.action_type === 'modifier_donnee') { const runDataUrl = "{{ url_for('flow_node_run_data', slug=game.slug, node_id=999999) }}".replace('999999', node.id); - // clicked_row_id : utilisé côté serveur SEULEMENT si ce nœud vise la - // ligne cliquée (CLICKED_ROW_ID, voir flow_node_run_data.py) — sans - // effet sinon, donc toujours transmis sans condition ici. + // clicked_row_id/event_row_id : utilisés côté serveur SEULEMENT si ce + // nœud vise respectivement CLICKED_ROW_ID ou EVENT_ROW_ID (voir + // flow_node_run_data.py) — sans effet sinon, donc toujours transmis + // sans condition ici. return fetch(runDataUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clicked_row_id: window.lastClickedRowId }) + body: JSON.stringify({ + clicked_row_id: window.lastClickedRowId, + event_row_id: window.lastEventParams ? window.lastEventParams.row_id : null, + }) }) .then(r => r.json()) .then(() => refreshRuntimeData()); @@ -855,6 +874,12 @@ // résolue seulement au moment de l'évaluation via le dernier clic // capturé (voir bindClicks() plus haut). if (rowId === -1) rowId = window.lastClickedRowId; + // EVENT_ROW_ID (-2, voir screens/flow/constants.py) : "la ligne de + // Répéteur transmise par l'événement personnalisé en cours" — même + // principe que CLICKED_ROW_ID ci-dessus, résolue via + // window.lastEventParams (posé par dispatchGameEvent()) plutôt que le + // dernier clic capturé. + if (rowId === -2) rowId = window.lastEventParams ? window.lastEventParams.row_id : null; const rows = gameData.data[String(definitionId)] || []; const row = rows.find(r => r.id === rowId); return row ? row[fieldName] : undefined; @@ -1008,6 +1033,29 @@ step(nodeId); } + // Déclenche un événement personnalisé (voir screens/custom_events/, + // action "declencher_evenement") : scan GLOBAL de gameData.flows, même + // principe que findTriggerNode()/runScreenShowTriggers() ci-dessus — un + // écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur + // N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché. + // elementId/rowId (potentiellement null si l'événement n'a pas de + // paramètre) sont posés sur window.lastEventParams AVANT de lancer + // chaque graphe trouvé, pour que les nœuds en aval (ex. "Modifier un + // élément" avec target_element_from_event, voir applyElementProperty + // plus haut/runActionNode plus bas) puissent le résoudre — au même + // titre que window.lastClickedRowId pour "Ligne cliquée (Répéteur)". + window.dispatchGameEvent = function(eventId, elementId, rowId) { + for (const screenIdKey in gameData.flows) { + const flow = gameData.flows[screenIdKey]; + flow.nodes + .filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; }) + .forEach(function(n) { + window.lastEventParams = { element_id: elementId, row_id: rowId }; + runFlowFrom(n.id, screenIdKey); + }); + } + }; + // L'écran affiché au démarrage doit être choisi AVANT le premier // bindClicks() : la recherche du nœud Déclencheur associé à un élément se // fait sur l'écran courant (window.currentScreenId), qui doit donc déjà diff --git a/templates/screen_edit.html b/templates/screen_edit.html index e634c70e..a3139b57 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -172,6 +172,7 @@ +
@@ -216,6 +217,16 @@ +
@@ -470,6 +510,76 @@
+ + +
+
+

Événements personnalisés

+ +
+ + + + + + + + + {% if custom_events %} + {% for ev in custom_events %} +
+ +
+ {% endfor %} +
+ + + + {% for ev in custom_events %} + + + + + + + + {% endfor %} + +
NomDescriptionParamètreUtilisé par
{{ ev.name }} + + + {% set usages = custom_event_usages.get(ev.id, []) %} + {% if usages %} + {% for u in usages %} + {{ '🔔' if u.role == 'ecoute' else '📣' }} {{ u.screen_name }}{{ ' (modèle)' if u.is_template else '' }}{{ ', ' if not loop.last else '' }} + {% endfor %} + {% else %} + Inutilisé + {% endif %} + + +
+ + +
+
+
+ {% else %} +

Aucun événement pour l'instant — crée le premier ci-dessus, puis pose un nœud "Sur un événement personnalisé" (Déclencheur) ou "Déclencher un événement" (Action) dans la Logique de la scène.

+ {% endif %} +
+ -
- - -
{% endif %} @@ -526,10 +514,6 @@ - @@ -541,18 +525,12 @@ {% endfor %}
- + {% for ev in custom_events %} -
NomDescriptionParamètreUtilisé par
NomDescriptionUtilisé par
{{ ev.name }} - - {% set usages = custom_event_usages.get(ev.id, []) %} {% if usages %} @@ -2241,8 +2219,7 @@ function nodeLabel(node) { } else if (node.action_type === 'activer_onglet') { base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)'; } else if (node.action_type === 'modifier_element') { - var elementLabel = node.target_element_from_event ? "l'élément transmis par l'événement" : (ELEMENTS_LABELS[node.target_element_id] || '?'); - base += ' → ' + elementLabel + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property); + base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property); if (node.element_property === 'visibilite') { base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value); } else if (node.element_property === 'surbrillance') { @@ -2264,11 +2241,11 @@ function nodeLabel(node) { } else if (node.action_type === 'attendre') { base += ' (' + (node.data_value || '0') + ' s)'; } else if (node.action_type === 'declencher_evenement') { + // Notification pure : rien d'autre à afficher que le nom de + // l'événement (voir screens/custom_events/) — aucun élément/ligne + // n'est jamais transmis au déclenchement. var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id]; base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »'; - if (dispatchedEvent && dispatchedEvent.has_element_param && node.target_element_id) { - base += ' (' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ')'; - } } return base; } @@ -2621,51 +2598,16 @@ function toggleFlowActionFields() { var waitFields = document.getElementById('nf-action-wait-fields'); var eventFields = document.getElementById('nf-action-event-fields'); var isOnglet = typeSel.value === 'activer_onglet'; - var isModifierElement = typeSel.value === 'modifier_element'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; - elementFields.style.display = (isModifierElement || isOnglet) ? 'block' : 'none'; + elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none'; document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none'; document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; - // v1 : seule "Modifier un élément" sait résoudre "l'élément transmis par - // l'événement en cours" (target_element_from_event) — pas encore - // "Activer cet onglet" (voir le plan, extensible plus tard sans nouveau - // changement de schéma). - var eventParamRow = document.getElementById('nf-action-event-param-row'); - if (eventParamRow) eventParamRow.style.display = isModifierElement ? 'flex' : 'none'; - if (!isModifierElement) document.getElementById('nf-action-element-from-event').checked = false; - if (isModifierElement) { toggleFlowElementPropertyValue(); toggleFlowActionElementFromEvent(); } + if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue(); if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action'); - if (typeSel.value === 'declencher_evenement') toggleFlowEventDispatchFields(); -} - -// "Utiliser l'élément transmis par l'événement en cours" (voir -// nf-action-event-param-row ci-dessus) : quand coché, le sélecteur -// d'élément fixe n'a plus de sens (target_element_from_event résout -// dynamiquement l'élément côté client, voir play.html) — masqué plutôt -// que laissé visible mais ignoré, pour ne pas laisser croire qu'il compte. -function toggleFlowActionElementFromEvent() { - var checked = document.getElementById('nf-action-element-from-event').checked; - document.getElementById('nf-action-target-element').style.display = checked ? 'none' : 'block'; -} - -// L'élément/la ligne concernés ne sont utiles que si l'événement choisi a -// réellement un paramètre (voir has_element_param, CUSTOM_EVENTS_MAP) — -// sans ça, rien à transmettre au déclenchement (voir target_element_id/ -// target_row_id du nœud Action "declencher_evenement"). Pas de "définition -// d'objet" ici (contrairement à "Modifier une donnée") : la ligne -// concernée ne peut donc être que "Ligne cliquée" (CLICKED_ROW_ID, si ce -// nœud est lui-même exécuté suite à un clic sur une ligne de Répéteur) ou -// "ne s'applique pas" — jamais une ligne fixe choisie dans une liste. -function toggleFlowEventDispatchFields() { - var sel = document.getElementById('nf-action-custom-event'); - var paramFields = document.getElementById('nf-action-event-param-fields'); - if (!sel || !paramFields) return; - var eventInfo = CUSTOM_EVENTS_MAP[sel.value]; - paramFields.style.display = (eventInfo && eventInfo.has_element_param) ? 'block' : 'none'; } function toggleFlowElementPropertyValue() { @@ -2768,11 +2710,7 @@ function submitNodeForm() { } else if (actionType === 'activer_onglet') { payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10); } else if (actionType === 'modifier_element') { - var fromEvent = document.getElementById('nf-action-element-from-event').checked; - payload.target_element_from_event = fromEvent ? 1 : 0; - if (!fromEvent) { - payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10); - } + payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10); var prop = document.getElementById('nf-action-element-property').value; payload.element_property = prop; var isColorProp = prop === 'bg_color' || prop === 'border_color'; @@ -2811,14 +2749,10 @@ function submitNodeForm() { } else if (actionType === 'attendre') { payload.data_value = document.getElementById('nf-action-wait-seconds').value; } else if (actionType === 'declencher_evenement') { - var evId = parseInt(document.getElementById('nf-action-custom-event').value, 10); - payload.target_custom_event_id = evId; - var evInfo = CUSTOM_EVENTS_MAP[evId]; - if (evInfo && evInfo.has_element_param) { - payload.target_element_id = parseInt(document.getElementById('nf-action-event-element').value, 10); - var evRow = document.getElementById('nf-action-event-row').value; - if (evRow) payload.target_row_id = parseInt(evRow, 10); - } + // Notification pure : aucun élément/ligne à transmettre (voir + // screens/custom_events/) — c'est à l'écouteur de décider quoi + // faire, avec ses propres réglages habituels. + payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10); } } diff --git a/tests/test_custom_events.py b/tests/test_custom_events.py index 1966ec2a..b8c53ce5 100644 --- a/tests/test_custom_events.py +++ b/tests/test_custom_events.py @@ -4,7 +4,13 @@ un événement game-wide (ex. NEED_ACTION) écouté par un nœud Déclencheur un nœud Action ("action_type"="declencher_evenement") sur UNE AUTRE scène/UN AUTRE modèle — la seule entité game-wide du moteur dont la suppression cascade RÉELLEMENT vers les nœuds qui la référencent, -partout à la fois (voir screens/custom_events/delete_custom_event.py).""" +partout à la fois (voir screens/custom_events/delete_custom_event.py). + +Un événement est une pure NOTIFICATION, sans aucun paramètre : le +déclencher ne fait que signaler, jamais choisir un élément — c'est à +l'ÉCOUTEUR (déclencheur → condition → action) de décider quoi faire +ensuite, avec ses propres réglages habituels (cible fixe, "Ligne +cliquée"...).""" import json import re @@ -17,11 +23,6 @@ def _create_screen(client, slug, name="Accueil"): return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) -def _add_element(client, slug, screen_id, widget="bouton"): - resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False) - return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) - - def _add_flow_node(client, slug, screen_id, **fields): resp = client.post( f"/game/{slug}/screens/{screen_id}/flow/nodes/add", @@ -35,43 +36,39 @@ def _add_flow_node(client, slug, screen_id, **fields): def test_create_event_is_idempotent_by_name(client, game): screen_id = _create_screen(client, game) resp = client.post(f"/game/{game}/events/new", data={ - "name": "NEED_ACTION", "description": "Besoin d'action", "has_element_param": "on", "screen_id": screen_id, + "name": "NEED_ACTION", "description": "Besoin d'action", "screen_id": screen_id, }) assert resp.status_code == 302 events = db.list_custom_events(game) assert len(events) == 1 assert events[0]["name"] == "NEED_ACTION" - assert events[0]["has_element_param"] == 1 # Double soumission : ne crée pas un deuxième événement du même nom. client.post(f"/game/{game}/events/new", data={"name": "NEED_ACTION", "screen_id": screen_id}) assert len(db.list_custom_events(game)) == 1 -def test_edit_event_updates_description_and_param_flag(client, game): +def test_edit_event_updates_description(client, game): screen_id = _create_screen(client, game) - event_id = db.create_custom_event(game, "NEED_ACTION", "old", False) + event_id = db.create_custom_event(game, "NEED_ACTION", "old") resp = client.post(f"/game/{game}/events/{event_id}/edit", data={ - "description": "new", "has_element_param": "on", "screen_id": screen_id, + "description": "new", "screen_id": screen_id, }) assert resp.status_code == 302 event = db.get_custom_event(game, event_id) assert event["description"] == "new" - assert event["has_element_param"] == 1 def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game): - event_id = db.create_custom_event(game, "NEED_ACTION", "", True) + event_id = db.create_custom_event(game, "NEED_ACTION") listener_screen = _create_screen(client, game, "Écran écouteur") _add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id) dispatcher_screen = _create_screen(client, game, "Écran déclencheur") - element_id = _add_element(client, game, dispatcher_screen) _add_flow_node( client, game, dispatcher_screen, - node_type="action", action_type="declencher_evenement", - target_custom_event_id=event_id, target_element_id=element_id, + node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id, ) usages = screens.list_custom_event_usages(game) @@ -82,7 +79,7 @@ def test_usages_reports_listener_and_dispatcher_across_different_screens(client, def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game): - event_id = db.create_custom_event(game, "NEED_ACTION", "", True) + event_id = db.create_custom_event(game, "NEED_ACTION") listener_screen = _create_screen(client, game, "Écran écouteur") listener_node_id = _add_flow_node( @@ -120,7 +117,7 @@ def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game): intacts pour chaque écran, avec le bon type (entier, pas une chaîne), faute de quoi une comparaison stricte "===" côté JS échouerait silencieusement.""" - event_id = db.create_custom_event(game, "NEED_ACTION", "", True) + event_id = db.create_custom_event(game, "NEED_ACTION") listener_screen = _create_screen(client, game, "Écran écouteur") listener_node_id = _add_flow_node( @@ -129,11 +126,9 @@ def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game): ) dispatcher_screen = _create_screen(client, game, "Écran déclencheur") - element_id = _add_element(client, game, dispatcher_screen) dispatcher_node_id = _add_flow_node( client, game, dispatcher_screen, - node_type="action", action_type="declencher_evenement", - target_custom_event_id=event_id, target_element_id=element_id, + node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id, ) payload = screens.full_game_payload(game) @@ -146,52 +141,3 @@ def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game): dispatcher_node = next(n for n in flows[str(dispatcher_screen)]["nodes"] if n["id"] == dispatcher_node_id) assert dispatcher_node["action_type"] == "declencher_evenement" assert dispatcher_node["target_custom_event_id"] == event_id - assert dispatcher_node["target_element_id"] == element_id - - -def test_modifier_element_from_event_flag_persists(client, game): - screen_id = _create_screen(client, game) - node_id = _add_flow_node( - client, game, screen_id, - node_type="action", action_type="modifier_element", - target_element_from_event=1, element_property="surbrillance", element_value="pulse", - ) - node = screens.get_flow_node(game, node_id) - assert node["target_element_from_event"] == 1 - assert node["target_element_id"] is None - - -def test_flow_node_run_data_resolves_event_row_id_from_request_body(client, game): - """Miroir serveur du sentinel EVENT_ROW_ID (voir play.html, - readFieldValue) — flow_node_run_data.py doit résoudre target_row_id - depuis le corps JSON ("event_row_id") exactement comme il le fait déjà - pour CLICKED_ROW_ID ("clicked_row_id").""" - resp = client.post(f"/game/{game}/objects/new", data={ - "object_name": "Jauge", "field_name[]": ["valeur"], - "field_type[]": ["nombre_entier"], "field_relation[]": [""], "field_required[]": ["0"], - "field_min[]": [""], "field_max[]": [""], - }, follow_redirects=False) - def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) - client.post(f"/game/{game}/objects/{def_id}/data/new", data={"valeur": "1"}) - client.post(f"/game/{game}/objects/{def_id}/data/new", data={"valeur": "2"}) - rows = db.list_rows(game, db.get_definition(game, def_id)) - row_id = next(r["id"] for r in rows if r["valeur"] == 2) - - screen_id = _create_screen(client, game) - node_id = _add_flow_node( - client, game, screen_id, - node_type="action", action_type="modifier_donnee", - target_definition_id=def_id, target_row_id=screens.EVENT_ROW_ID, - target_field="valeur", data_operation="definir_nombre", data_value="99", - ) - - resp = client.post( - f"/game/{game}/flow/nodes/{node_id}/run-data", - data=json.dumps({"clicked_row_id": None, "event_row_id": row_id}), - content_type="application/json", - ) - assert resp.status_code == 200 - assert resp.get_json()["ok"] is True - - row = db.get_row(game, db.get_definition(game, def_id), row_id) - assert row["valeur"] == 99 From a7a315cce7a11e23670aaa2b6c874b7bb240cee9 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 10:26:37 +0200 Subject: [PATCH 16/16] =?UTF-8?q?Corrige=20un=20bug=20moteur=20:=20plusieu?= =?UTF-8?q?rs=20d=C3=A9clencheurs=20"Au=20clic"=20(ou=20survol)=20sur=20le?= =?UTF-8?q?=20m=C3=AAme=20=C3=A9l=C3=A9ment=20n'ex=C3=A9cutaient=20que=20l?= =?UTF-8?q?e=20premier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic effectué directement sur projects/test/game.db de l'utilisateur (suite à son signalement "je ne parviens pas à mettre fin à la surbrillance") : deux nœuds Déclencheur distincts ("Au clic", id=4 et id=42) référençaient le même trigger_element_id=65. La fonction findTriggerNode() utilisait Array.find(), qui ne retourne que la PREMIÈRE correspondance — le second nœud (celui qui devait couper la surbrillance) n'était donc jamais exécuté, silencieusement, quel que soit le graphe construit dans l'éditeur. Ce n'est pas un bug lié aux événements personnalisés ni aux conditions par variable (deux pistes explorées avant ce diagnostic) : c'est une limitation générale du moteur, qui n'a jamais géré plus d'un déclencheur "Au clic"/"Au survol"/"À la fin du survol" par élément. Renomme findTriggerNode() en findTriggerNodes() (pluriel) : retourne désormais TOUS les nœuds correspondants (élément + type d'événement), et bindClicks()/bindHoverTriggers() exécutent chaque flow trouvé au lieu de s'arrêter au premier. Vérifié : 208 tests passent, syntaxe JS validée (script de play.html rendu via le client de test puis node --check). Comme pour le reste du graphe de logique côté client, ce changement n'est pas couvert par les tests automatisés (pas de harnais navigateur/DOM) — vérification manuelle recommandée sur le scénario réel de l'utilisateur. Co-Authored-By: Claude Sonnet 5 --- templates/play.html | 78 +++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/templates/play.html b/templates/play.html index a0509424..4a97faf8 100644 --- a/templates/play.html +++ b/templates/play.html @@ -265,28 +265,39 @@ } // Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"), - // le nœud Déclencheur qui lui est rattaché — c'est le point d'entrée du - // graphe à exécuter. On cherche dans TOUS les écrans (pas seulement - // l'écran courant) : bindClicks() attache les gestionnaires une seule - // fois, sur tous les éléments de tous les écrans (déjà tous présents dans - // le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant - // d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible. - // Cette recherche globale couvre aussi les écrans-MODÈLES (un élément de - // jeu réutilisable, ex. "mail content", posé sur une scène) : un - // déclencheur "Au clic"/"Au survol" posé dans l'éditeur du modèle, sur un - // de SES PROPRES enfants, est donc retrouvé même quand ce modèle est - // utilisé ailleurs — d'où le besoin de renvoyer aussi l'écran d'origine du - // nœud (screenIdKey), pas seulement le nœud : runFlowFrom() doit exécuter - // le graphe dans CET écran-là (celui du modèle), jamais dans celui - // réellement affiché, sans quoi le nœud trouvé n'existerait pas dans le - // graphe utilisé et l'action ne se déclencherait jamais. - function findTriggerNode(elementId, event) { + // TOUS les nœuds Déclencheur qui lui sont rattachés — ce sont les points + // d'entrée des graphes à exécuter. Renvoie un TABLEAU (jamais un seul + // nœud) : rien n'empêche de poser plusieurs déclencheurs indépendants + // "Au clic" sur le MÊME élément (ex. l'un ouvre une ligne de Répéteur, + // l'autre éteint une surbrillance) — un seul clic doit alors exécuter + // CHACUN de ces graphes, pas seulement le premier trouvé (régression + // vécue : un second déclencheur posé sur un élément qui en avait déjà un + // restait silencieusement inerte, aucune erreur, juste jamais exécuté). + // + // On cherche dans TOUS les écrans (pas seulement l'écran courant) : + // bindClicks() attache les gestionnaires une seule fois, sur tous les + // éléments de tous les écrans (déjà tous présents dans le DOM, seuls les + // écrans non actifs sont masqués) — chaque identifiant d'élément est + // unique dans tout le jeu, donc pas d'ambiguïté possible. Cette recherche + // globale couvre aussi les écrans-MODÈLES (un élément de jeu réutilisable, + // ex. "mail content", posé sur une scène) : un déclencheur "Au clic"/"Au + // survol" posé dans l'éditeur du modèle, sur un de SES PROPRES enfants, + // est donc retrouvé même quand ce modèle est utilisé ailleurs — d'où le + // besoin de renvoyer aussi l'écran d'origine de CHAQUE nœud (screenIdKey), + // pas seulement le nœud : runFlowFrom() doit exécuter le graphe dans CET + // écran-là (celui du modèle), jamais dans celui réellement affiché, sans + // quoi le nœud trouvé n'existerait pas dans le graphe utilisé et l'action + // ne se déclencherait jamais. + function findTriggerNodess(elementId, event) { + const found = []; for (const screenIdKey in gameData.flows) { - const flow = gameData.flows[screenIdKey]; - const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event); - if (found) return { node: found, screenId: screenIdKey }; + gameData.flows[screenIdKey].nodes.forEach(function(n) { + if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) { + found.push({ node: n, screenId: screenIdKey }); + } + }); } - return null; + return found; } // Ré-attache les gestionnaires de clic sur chaque élément — appelé au @@ -313,8 +324,8 @@ function bindClicks() { document.querySelectorAll('[data-element-id]').forEach(elDiv => { const elementId = parseInt(elDiv.dataset.elementId, 10); - const trigger = findTriggerNode(elementId, 'clic'); - if (trigger) { + const triggers = findTriggerNodess(elementId, 'clic'); + if (triggers.length) { // Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks() // après chaque changement de donnée, mais un élément qui n'est ni un // Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement @@ -337,7 +348,9 @@ const rowEl = ev.target.closest('.repeaterItem'); window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null; window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null; - runFlowFrom(trigger.node.id, trigger.screenId); + // Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas + // seulement le premier (voir le commentaire de findTriggerNodess). + triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId)); }); } }); @@ -354,12 +367,15 @@ document.querySelectorAll('[data-element-id]').forEach(elDiv => { if (elDiv.dataset.hoverTriggerBound) return; const elementId = parseInt(elDiv.dataset.elementId, 10); - const enterTrigger = findTriggerNode(elementId, 'survol'); - const leaveTrigger = findTriggerNode(elementId, 'fin_survol'); - if (!enterTrigger && !leaveTrigger) return; + const enterTriggers = findTriggerNodess(elementId, 'survol'); + const leaveTriggers = findTriggerNodess(elementId, 'fin_survol'); + if (!enterTriggers.length && !leaveTriggers.length) return; elDiv.dataset.hoverTriggerBound = '1'; - if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId)); - if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId)); + // Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir + // le commentaire de findTriggerNodess (plusieurs déclencheurs + // indépendants sur le même élément/événement doivent tous s'exécuter). + if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId))); + if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId))); }); } @@ -982,10 +998,10 @@ // continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue // une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence. // flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut - // l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode) + // l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNodes) // quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un // élément de jeu réutilisable, sur un de ses propres enfants. Sans ce - // paramètre, un tel déclencheur était bien retrouvé (findTriggerNode + // paramètre, un tel déclencheur était bien retrouvé (findTriggerNodes // cherche dans tous les écrans) mais son graphe n'existait jamais dans // l'écran réellement affiché : rien ne se déclenchait, silencieusement. function runFlowFrom(nodeId, flowScreenId) { @@ -1018,7 +1034,7 @@ // Déclenche un événement personnalisé (voir screens/custom_events/, // action "declencher_evenement") : une pure NOTIFICATION, sans aucun // paramètre — scan GLOBAL de gameData.flows, même principe que - // findTriggerNode()/runScreenShowTriggers() ci-dessus, puisqu'un + // findTriggerNodes()/runScreenShowTriggers() ci-dessus, puisqu'un // écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur // N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché. // C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre