Simplifie les événements : notification pure, sans paramètre

Retour de l'utilisateur sur le premier jet : "Déclencher un événement"
ne doit JAMAIS faire choisir un élément — c'est une notification pure,
rien de plus. C'est à l'ÉCOUTEUR (déclencheur "Sur un événement
personnalisé" → condition → action) de décider quoi faire ensuite, avec
ses réglages habituels (cible fixe, "Ligne cliquée"...), jamais à
l'événement de transporter un paramètre.

Retire donc tout le mécanisme de transmission ajouté au tour précédent
(has_element_param, target_element_from_event, EVENT_ROW_ID,
window.lastEventParams) :

- db/custom_events/ : _custom_events perd sa colonne has_element_param —
  un événement n'est plus qu'un nom + une description.
- screens/flow/ : retire target_element_from_event (colonne ajoutée par
  ALTER TABLE, laissée inerte sur les bases déjà migrées — sans
  conséquence, plus jamais lue ni écrite) et la constante EVENT_ROW_ID.
- routes/flow/flow_node_run_data.py : retire la résolution EVENT_ROW_ID,
  revient à sa forme d'origine (seul CLICKED_ROW_ID reste géré).
- templates/screen_edit.html : le nœud Action "Déclencher un événement"
  n'a plus qu'un sélecteur d'événement — plus de champs élément/ligne.
  Le nœud Action "Modifier un élément" perd la case "Utiliser l'élément
  transmis par l'événement en cours". L'onglet Événements perd la case
  à cocher "Paramètre" (création et édition).
- templates/play.html : window.dispatchGameEvent(eventId) ne prend plus
  que l'id de l'événement — scan global inchangé, mais ne pose plus
  aucun window.lastEventParams. modifier_element et readFieldValue
  reviennent à leur résolution d'origine (plus de branche event-aware).

208 tests au total (2 tests retirés, devenus sans objet : la
persistance de target_element_from_event et la résolution serveur
d'EVENT_ROW_ID).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-29 20:39:42 +02:00
co-authored by Claude Sonnet 5
parent 666aa892e0
commit 2c59e54556
13 changed files with 76 additions and 273 deletions
+3 -3
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema from .ensure_custom_events_schema import ensure_custom_events_schema
def create_custom_event(slug, name, description="", has_element_param=False): def create_custom_event(slug, name, description=""):
"""Idempotent par nom (même convention que create_global_variable.py) : """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 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 existant plutôt que de lever une erreur — sans risque en cas de
@@ -17,8 +17,8 @@ def create_custom_event(slug, name, description="", has_element_param=False):
conn.close() conn.close()
return existing["id"] return existing["id"]
conn.execute( conn.execute(
"INSERT INTO _custom_events (name, description, has_element_param) VALUES (?, ?, ?)", "INSERT INTO _custom_events (name, description) VALUES (?, ?)",
(name, (description or "").strip(), 1 if has_element_param else 0), (name, (description or "").strip()),
) )
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit() conn.commit()
@@ -8,7 +8,10 @@ def ensure_custom_events_schema(slug):
c'est par ce nom qu'on le désigne dans l'interface, même si les nœuds 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 du graphe le référencent par id technique (voir
screens/flow/ensure_flow_schema.py, trigger_custom_event_id/ screens/flow/ensure_flow_schema.py, trigger_custom_event_id/
target_custom_event_id).""" target_custom_event_id). Un événement est une pure NOTIFICATION,
sans paramètre : "déclencher" ne fait que signaler, jamais choisir
un élément — c'est à l'ÉCOUTEUR (déclencheur → condition → action)
de décider quoi faire, avec ses propres réglages habituels."""
conn = connect(slug) conn = connect(slug)
conn.execute( conn.execute(
""" """
@@ -16,7 +19,6 @@ def ensure_custom_events_schema(slug):
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
has_element_param INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP created_at TEXT DEFAULT CURRENT_TIMESTAMP
); );
""" """
+2 -5
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema from .ensure_custom_events_schema import ensure_custom_events_schema
def update_custom_event(slug, event_id, description="", has_element_param=False): def update_custom_event(slug, event_id, description=""):
"""Le NOM reste volontairement immuable après création — comme une """Le NOM reste volontairement immuable après création — comme une
variable globale (voir update_global_variable.py) : c'est par ce nom 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 qu'on désigne l'événement dans l'interface, mais surtout par son ID
@@ -11,9 +11,6 @@ def update_custom_event(slug, event_id, description="", has_element_param=False)
casserait donc rien niveau logique.""" casserait donc rien niveau logique."""
ensure_custom_events_schema(slug) ensure_custom_events_schema(slug)
conn = connect(slug) conn = connect(slug)
conn.execute( conn.execute("UPDATE _custom_events SET description = ? WHERE id = ?", ((description or "").strip(), event_id))
"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.commit()
conn.close() conn.close()
+1 -2
View File
@@ -8,7 +8,6 @@ from core.flask_app import app
@app.route("/game/<slug>/events/<int:event_id>/edit", methods=["POST"]) @app.route("/game/<slug>/events/<int:event_id>/edit", methods=["POST"])
def custom_event_edit(slug, event_id): def custom_event_edit(slug, event_id):
description = request.form.get("description") or "" 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) screen_id = request.form.get("screen_id", type=int)
db.update_custom_event(slug, event_id, description, has_element_param) db.update_custom_event(slug, event_id, description)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events")) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
+1 -2
View File
@@ -14,8 +14,7 @@ def custom_event_new(slug):
bon écran ensuite, jamais stocké sur l'événement lui-même (game-wide).""" bon écran ensuite, jamais stocké sur l'événement lui-même (game-wide)."""
name = (request.form.get("name") or "").strip() name = (request.form.get("name") or "").strip()
description = request.form.get("description") or "" 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) screen_id = request.form.get("screen_id", type=int)
if name: if name:
db.create_custom_event(slug, name, description, has_element_param) db.create_custom_event(slug, name, description)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events")) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
+3 -8
View File
@@ -13,19 +13,14 @@ def flow_node_run_data(slug, node_id):
un nœud du graphe de logique plutôt que depuis la table _actions. 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 Si le nœud vise CLICKED_ROW_ID (voir "Ligne cliquée (Répéteur)" dans
screen_edit.html) ou EVENT_ROW_ID (la ligne transmise par un événement screen_edit.html), la vraie ligne n'est connue qu'au moment du clic —
personnalisé en cours, voir screens/custom_events/), la vraie ligne le client (play.html) la transmet dans le corps JSON de cette requête,
n'est connue qu'au moment de l'exécution — le client (play.html) la et elle remplace ici target_row_id avant d'exécuter l'action."""
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) node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400 return jsonify({"ok": False}), 400
if node.get("target_row_id") == screens.CLICKED_ROW_ID: if node.get("target_row_id") == screens.CLICKED_ROW_ID:
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("clicked_row_id")} 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) ok = screens.apply_data_action(slug, node)
return jsonify({"ok": ok}) return jsonify({"ok": ok})
+1 -4
View File
@@ -46,10 +46,7 @@ def screen_edit(slug, screen_id):
global_variables = db.list_global_variables(slug) global_variables = db.list_global_variables(slug)
custom_events = db.list_custom_events(slug) custom_events = db.list_custom_events(slug)
custom_event_usages = screens.list_custom_event_usages(slug) custom_event_usages = screens.list_custom_event_usages(slug)
custom_events_map = { custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
e["id"]: {"name": e["name"], "has_element_param": bool(e["has_element_param"])}
for e in custom_events
}
return render_template( return render_template(
"screen_edit.html", "screen_edit.html",
+1 -1
View File
@@ -56,7 +56,7 @@ from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBI
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .flow.constants import ( from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, EVENT_ROW_ID, TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID,
) )
from .rendering.render_element_html import render_element_html from .rendering.render_element_html import render_element_html
+7 -20
View File
@@ -21,16 +21,6 @@
# AUTOINCREMENT SQLite). # AUTOINCREMENT SQLite).
CLICKED_ROW_ID = -1 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 = [ TRIGGER_EVENTS = [
("clic", "Au clic"), ("clic", "Au clic"),
("soumission", "À la soumission"), ("soumission", "À la soumission"),
@@ -89,14 +79,11 @@ FLOW_NODE_FIELDS = {
"target_variable", "target_variable",
# Événements personnalisés (voir screens/custom_events/) : quel # Événements personnalisés (voir screens/custom_events/) : quel
# événement un nœud Déclencheur écoute (trigger_event="evenement") ou # événement un nœud Déclencheur écoute (trigger_event="evenement") ou
# un nœud Action déclenche (action_type="declencher_evenement") — # un nœud Action déclenche (action_type="declencher_evenement") — un
# target_element_id/target_row_id (déjà ci-dessus, EVENT_ROW_ID # événement est une pure NOTIFICATION, sans paramètre : "déclencher"
# compris) portent les paramètres transmis au déclenchement. # ne fait que signaler, jamais choisir un élément. C'est à l'ÉCOUTEUR
# target_element_from_event : indicateur réutilisable par N'IMPORTE # (déclencheur → condition → action) de décider quoi faire ensuite,
# QUEL nœud Action qui accepte déjà target_element_id (v1 : seule # avec ses propres réglages habituels (target_element_id normal, pas
# "modifier_element" le supporte) — contourne la contrainte de clé # de mécanisme dynamique dédié).
# étrangère de target_element_id (impossible d'y stocker un sentinel "trigger_custom_event_id", "target_custom_event_id",
# 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",
} }
-8
View File
@@ -83,13 +83,5 @@ def ensure_flow_schema(slug):
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER")
if "target_custom_event_id" not in cols: if "target_custom_event_id" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER") 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.commit()
conn.close() conn.close()
+19 -64
View File
@@ -788,46 +788,16 @@
bindHoverTexts(); bindHoverTexts();
bindHoverTriggers(); bindHoverTriggers();
return Promise.resolve(); return Promise.resolve();
} else if (node.action_type === 'modifier_element' && (node.target_element_id || node.target_element_from_event)) { } else if (node.action_type === 'modifier_element' && node.target_element_id) {
// target_element_from_event (voir screens/custom_events/) : résout const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
// 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).
//
// data-element-id N'EST PAS unique quand l'élément vit dans un
// Répéteur : chaque ligne rejoue le MÊME modèle, donc le MÊME
// data-element-id se répète à l'identique sur chaque ligne (voir
// render_repeater.py, seul data-row-id — posé sur .repeaterItem,
// l'englobant — distingue réellement une ligne d'une autre). Un
// simple document.querySelector('[data-element-id]') global
// prendrait toujours la PREMIÈRE ligne, jamais la bonne — sans
// rapport avec l'événement reçu. Quand l'événement transmet aussi
// une ligne (row_id), la recherche est donc scopée à L'INTÉRIEUR de
// ce .repeaterItem précis ; sinon (élément fixe, ou événement sans
// paramètre de ligne), recherche globale comme avant.
const resolvedElementId = node.target_element_from_event
? (window.lastEventParams ? window.lastEventParams.element_id : null)
: node.target_element_id;
const resolvedRowId = node.target_element_from_event && window.lastEventParams
? window.lastEventParams.row_id : null;
let targetEl = null;
if (resolvedElementId != null) {
if (resolvedRowId != null) {
const rowEl = document.querySelector('.repeaterItem[data-row-id="' + resolvedRowId + '"]');
targetEl = rowEl ? rowEl.querySelector('[data-element-id="' + resolvedElementId + '"]') : null;
} else {
targetEl = document.querySelector('[data-element-id="' + resolvedElementId + '"]');
}
}
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value); if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve(); return Promise.resolve();
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) { } else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
let dispatchRowId = node.target_row_id; // Notification pure (voir screens/custom_events/) : ne fait que
if (dispatchRowId === -1) dispatchRowId = window.lastClickedRowId; // CLICKED_ROW_ID // signaler l'événement, jamais choisir un élément — c'est à
window.dispatchGameEvent(node.target_custom_event_id, node.target_element_id || null, dispatchRowId != null ? dispatchRowId : null); // l'écouteur (déclencheur → condition → action) de décider quoi
// faire, avec ses propres réglages habituels.
window.dispatchGameEvent(node.target_custom_event_id);
return Promise.resolve(); return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) { } else if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre // 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
@@ -864,16 +834,12 @@
return new Promise(function(resolve) { setTimeout(resolve, ms); }); return new Promise(function(resolve) { setTimeout(resolve, ms); });
} else if (node.action_type === 'modifier_donnee') { } 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); const runDataUrl = "{{ url_for('flow_node_run_data', slug=game.slug, node_id=999999) }}".replace('999999', node.id);
// clicked_row_id/event_row_id : utilisés côté serveur SEULEMENT si ce // clicked_row_id : utilisé côté serveur SEULEMENT si ce nœud vise la
// nœud vise respectivement CLICKED_ROW_ID ou EVENT_ROW_ID (voir // ligne cliquée (CLICKED_ROW_ID, voir flow_node_run_data.py) — sans
// flow_node_run_data.py) — sans effet sinon, donc toujours transmis // effet sinon, donc toujours transmis sans condition ici.
// sans condition ici.
return fetch(runDataUrl, { return fetch(runDataUrl, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ clicked_row_id: window.lastClickedRowId })
clicked_row_id: window.lastClickedRowId,
event_row_id: window.lastEventParams ? window.lastEventParams.row_id : null,
})
}) })
.then(r => r.json()) .then(r => r.json())
.then(() => refreshRuntimeData()); .then(() => refreshRuntimeData());
@@ -896,12 +862,6 @@
// résolue seulement au moment de l'évaluation via le dernier clic // résolue seulement au moment de l'évaluation via le dernier clic
// capturé (voir bindClicks() plus haut). // capturé (voir bindClicks() plus haut).
if (rowId === -1) rowId = window.lastClickedRowId; 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 rows = gameData.data[String(definitionId)] || [];
const row = rows.find(r => r.id === rowId); const row = rows.find(r => r.id === rowId);
return row ? row[fieldName] : undefined; return row ? row[fieldName] : undefined;
@@ -1056,25 +1016,20 @@
} }
// Déclenche un événement personnalisé (voir screens/custom_events/, // Déclenche un événement personnalisé (voir screens/custom_events/,
// action "declencher_evenement") : scan GLOBAL de gameData.flows, même // action "declencher_evenement") : une pure NOTIFICATION, sans aucun
// principe que findTriggerNode()/runScreenShowTriggers() ci-dessus — un // paramètre — scan GLOBAL de gameData.flows, même principe que
// findTriggerNode()/runScreenShowTriggers() ci-dessus, puisqu'un
// écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur // é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é. // 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 // C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
// paramètre) sont posés sur window.lastEventParams AVANT de lancer // suite condition/action, avec ses réglages habituels — cible fixe,
// chaque graphe trouvé, pour que les nœuds en aval (ex. "Modifier un // "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
// élément" avec target_element_from_event, voir applyElementProperty window.dispatchGameEvent = function(eventId) {
// 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) { for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey]; const flow = gameData.flows[screenIdKey];
flow.nodes flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; }) .filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
.forEach(function(n) { .forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
window.lastEventParams = { element_id: elementId, row_id: rowId };
runFlowFrom(n.id, screenIdKey);
});
} }
}; };
+18 -84
View File
@@ -297,10 +297,6 @@
</select> </select>
<div id="nf-action-element-fields" style="display:none;"> <div id="nf-action-element-fields" style="display:none;">
<label id="nf-action-event-param-row" style="display:none; align-items:center; gap:6px; margin-bottom:6px;">
<input type="checkbox" id="nf-action-element-from-event" style="width:auto;" onchange="toggleFlowActionElementFromEvent()">
Utiliser l'élément transmis par l'événement en cours (nœud "Sur un événement personnalisé")
</label>
<select id="nf-action-target-element"> <select id="nf-action-target-element">
{% for e2 in elements %} {% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option> <option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
@@ -391,24 +387,16 @@
{% if not custom_events %} {% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p> <p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% else %} {% else %}
<select id="nf-action-custom-event" onchange="toggleFlowEventDispatchFields()"> <!-- Déclencher un événement = notifier, rien d'autre : aucun
élément/ligne à choisir ici, c'est à l'ÉCOUTEUR (nœud
Déclencheur "Sur un événement personnalisé", plus bas dans
son propre graphe) de décider quoi faire, avec ses propres
réglages habituels — voir screens/custom_events/. -->
<select id="nf-action-custom-event">
{% for ev in custom_events %} {% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option> <option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div id="nf-action-event-param-fields" style="margin-top:6px;">
<label>Élément concerné<select id="nf-action-event-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select></label>
<label style="margin-top:6px;">Ligne concernée (si l'élément vient d'un Répéteur)
<select id="nf-action-event-row">
<option value="">Ne s'applique pas</option>
<option value="-1">🖱️ Ligne cliquée (Répéteur)</option>
</select>
</label>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -526,10 +514,6 @@
<input type="hidden" name="screen_id" value="{{ screen.id }}"> <input type="hidden" name="screen_id" value="{{ screen.id }}">
<input type="text" name="name" placeholder="NOM (ex. NEED_ACTION)" required autocomplete="off"> <input type="text" name="name" placeholder="NOM (ex. NEED_ACTION)" required autocomplete="off">
<input type="text" name="description" placeholder="Description (optionnel)"> <input type="text" name="description" placeholder="Description (optionnel)">
<label style="display:flex; align-items:center; gap:6px; white-space:nowrap; font-size:12.5px;">
<input type="checkbox" name="has_element_param" style="width:auto;">
Paramètre : élément (+ ligne de Répéteur)
</label>
<button type="submit" class="primary">Créer</button> <button type="submit" class="primary">Créer</button>
</form> </form>
@@ -541,18 +525,12 @@
{% endfor %} {% endfor %}
<div class="fieldsTableWrap"> <div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable"> <table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Description</th><th>Paramètre</th><th>Utilisé par</th><th></th></tr></thead> <thead><tr><th>Nom</th><th>Description</th><th>Utilisé par</th><th></th></tr></thead>
<tbody> <tbody>
{% for ev in custom_events %} {% for ev in custom_events %}
<tr> <tr>
<td><code>{{ ev.name }}</code></td> <td><code>{{ ev.name }}</code></td>
<td><input type="text" form="eventEditForm{{ ev.id }}" name="description" value="{{ ev.description or '' }}" placeholder="Description" aria-label="Description de « {{ ev.name }} »"></td> <td><input type="text" form="eventEditForm{{ ev.id }}" name="description" value="{{ ev.description or '' }}" placeholder="Description" aria-label="Description de « {{ ev.name }} »"></td>
<td>
<label style="display:flex; align-items:center; gap:4px; white-space:nowrap;">
<input type="checkbox" form="eventEditForm{{ ev.id }}" name="has_element_param" style="width:auto;" {{ 'checked' if ev.has_element_param else '' }}>
élément
</label>
</td>
<td> <td>
{% set usages = custom_event_usages.get(ev.id, []) %} {% set usages = custom_event_usages.get(ev.id, []) %}
{% if usages %} {% if usages %}
@@ -2241,8 +2219,7 @@ function nodeLabel(node) {
} else if (node.action_type === 'activer_onglet') { } else if (node.action_type === 'activer_onglet') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)'; base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
} else if (node.action_type === 'modifier_element') { } 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 += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
base += ' → ' + elementLabel + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
if (node.element_property === 'visibilite') { if (node.element_property === 'visibilite') {
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value); base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'surbrillance') { } else if (node.element_property === 'surbrillance') {
@@ -2264,11 +2241,11 @@ function nodeLabel(node) {
} else if (node.action_type === 'attendre') { } else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)'; base += ' (' + (node.data_value || '0') + ' s)';
} else if (node.action_type === 'declencher_evenement') { } 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]; var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id];
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »'; base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
if (dispatchedEvent && dispatchedEvent.has_element_param && node.target_element_id) {
base += ' (' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ')';
}
} }
return base; return base;
} }
@@ -2621,51 +2598,16 @@ function toggleFlowActionFields() {
var waitFields = document.getElementById('nf-action-wait-fields'); var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields'); var eventFields = document.getElementById('nf-action-event-fields');
var isOnglet = typeSel.value === 'activer_onglet'; 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'; 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'; dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? '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-onglet-hint').style.display = isOnglet ? 'block' : 'none';
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; 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 if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
// 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_donnee') onFlowDefinitionChange('action'); 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() { function toggleFlowElementPropertyValue() {
@@ -2768,11 +2710,7 @@ function submitNodeForm() {
} else if (actionType === 'activer_onglet') { } else if (actionType === 'activer_onglet') {
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);
} else if (actionType === 'modifier_element') { } else if (actionType === 'modifier_element') {
var fromEvent = document.getElementById('nf-action-element-from-event').checked; payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
payload.target_element_from_event = fromEvent ? 1 : 0;
if (!fromEvent) {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
}
var prop = document.getElementById('nf-action-element-property').value; var prop = document.getElementById('nf-action-element-property').value;
payload.element_property = prop; payload.element_property = prop;
var isColorProp = prop === 'bg_color' || prop === 'border_color'; var isColorProp = prop === 'bg_color' || prop === 'border_color';
@@ -2811,14 +2749,10 @@ function submitNodeForm() {
} else if (actionType === 'attendre') { } else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value; payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} else if (actionType === 'declencher_evenement') { } else if (actionType === 'declencher_evenement') {
var evId = parseInt(document.getElementById('nf-action-custom-event').value, 10); // Notification pure : aucun élément/ligne à transmettre (voir
payload.target_custom_event_id = evId; // screens/custom_events/) — c'est à l'écouteur de décider quoi
var evInfo = CUSTOM_EVENTS_MAP[evId]; // faire, avec ses propres réglages habituels.
if (evInfo && evInfo.has_element_param) { payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10);
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);
}
} }
} }
+16 -70
View File
@@ -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 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 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, 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 json
import re 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)) 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): def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post( resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add", 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): def test_create_event_is_idempotent_by_name(client, game):
screen_id = _create_screen(client, game) screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/events/new", data={ 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 assert resp.status_code == 302
events = db.list_custom_events(game) events = db.list_custom_events(game)
assert len(events) == 1 assert len(events) == 1
assert events[0]["name"] == "NEED_ACTION" 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. # 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}) client.post(f"/game/{game}/events/new", data={"name": "NEED_ACTION", "screen_id": screen_id})
assert len(db.list_custom_events(game)) == 1 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) 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={ 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 assert resp.status_code == 302
event = db.get_custom_event(game, event_id) event = db.get_custom_event(game, event_id)
assert event["description"] == "new" assert event["description"] == "new"
assert event["has_element_param"] == 1
def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game): 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") 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) _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") dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
element_id = _add_element(client, game, dispatcher_screen)
_add_flow_node( _add_flow_node(
client, game, dispatcher_screen, client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
target_custom_event_id=event_id, target_element_id=element_id,
) )
usages = screens.list_custom_event_usages(game) 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): 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_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node( 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), intacts pour chaque écran, avec le bon type (entier, pas une chaîne),
faute de quoi une comparaison stricte "===" côté JS échouerait faute de quoi une comparaison stricte "===" côté JS échouerait
silencieusement.""" 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_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node( 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") dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
element_id = _add_element(client, game, dispatcher_screen)
dispatcher_node_id = _add_flow_node( dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen, client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
target_custom_event_id=event_id, target_element_id=element_id,
) )
payload = screens.full_game_payload(game) 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) 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["action_type"] == "declencher_evenement"
assert dispatcher_node["target_custom_event_id"] == event_id 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