diff --git a/.gitignore b/.gitignore index 78597e9c..3f4e6ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .venv/ venv/ env/ +Bug/ # Variables d'environnement / secrets locaux .env diff --git a/screens/clause_list_codec.py b/screens/clause_list_codec.py new file mode 100644 index 00000000..11eded13 --- /dev/null +++ b/screens/clause_list_codec.py @@ -0,0 +1,45 @@ +import json + +# Partagé par control_value.py (widgets) et filter_repeater_rows.py +# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne +# jamais introduire de dépendance circulaire entre ces deux packages. + + +def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None): + """Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un + attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres + fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et + legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est + absent — pour ne rien casser sur les éléments créés avant l'ajout des + clauses illimitées (aucune migration de données nécessaire, la + conversion se fait à la volée, ici, à la lecture). Renvoie toujours + (clauses, combinateur), combinateur valant "et" par défaut.""" + raw = attrs.get(clauses_attr) + clauses = None + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + clauses = parsed + except (TypeError, ValueError): + clauses = None + if clauses is None and legacy_prefix: + clauses = [] + for suffix in ("", "2"): + champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip() + if champ: + clauses.append({ + "champ": champ, + "operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal", + "valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "", + }) + combinateur = attrs.get(combinator_attr) or "et" + return clauses or [], combinateur + + +def encode_clauses(clauses): + """Sérialise une liste de clauses en JSON, en retirant celles sans nom + de champ (une ligne ajoutée puis jamais remplie) — "" si la liste + nettoyée est vide, pour ne rien écrire de superflu en base.""" + cleaned = [c for c in clauses if (c.get("champ") or "").strip()] + return json.dumps(cleaned) if cleaned else "" diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py index 111093ca..e1e99b1b 100644 --- a/screens/elements/add_element.py +++ b/screens/elements/add_element.py @@ -6,6 +6,7 @@ from ..widgets.registry import WIDGETS from ..widgets.widget_meta import widget_meta from ..widgets.default_style_for_widget import default_style_and_attributes from ..element_types.list_element_types import list_element_types +from ..element_types.is_overlay_only import _is_overlay_only_element_type def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None): @@ -39,6 +40,15 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non # proprement) — hors couleurs, qui restent volontairement transparentes/ # sans bordure tant qu'on ne les choisit pas explicitement. attributes, style = default_style_and_attributes(widget_key) + if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)): + # Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte + # de dialogue" (render_element_html.py décide alors, au rendu, de + # faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) : + # démarre masqué par défaut, exactement comme une superposition + # posée directement (voir default_style_for_widget.py) — sinon un + # dialogue fraîchement déposé couvrirait tout l'écran en jeu dès + # sa pose, avant même qu'une action ne l'affiche. + style["display"] = "none" content = meta.get("default_content", "") width, height = meta.get("w", 30), meta.get("h", 10) diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py index 2b16837b..2abb9587 100644 --- a/screens/elements/save_element_controls.py +++ b/screens/elements/save_element_controls.py @@ -4,6 +4,7 @@ import db from ..widgets.widget_meta import widget_meta from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS +from ..clause_list_codec import encode_clauses from .get_element import get_element @@ -53,6 +54,32 @@ def save_element_controls(slug, element_id, form): toggle_groups[name].add(control["token"]) continue + if ctype == "clause_list": + # Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ + # tableau par colonne ("field_name_champ[]"/"_operateur[]"/ + # "_valeur[]"), reconstituées ici en une liste de clauses par + # position (zip) — même principe que field_name[]/field_type[] + # pour les champs d'un objet (routes/objects/parse_field_rows.py). + champs = form.getlist(field_name + "_champ[]") + operateurs = form.getlist(field_name + "_operateur[]") + valeurs = form.getlist(field_name + "_valeur[]") + clauses = [ + {"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal", + "valeur": valeurs[i] if i < len(valeurs) else ""} + for i, champ in enumerate(champs) + ] + encoded = encode_clauses(clauses) + if encoded: + attributes[control["clauses_attr"]] = encoded + else: + attributes.pop(control["clauses_attr"], None) + combinateur = form.get(field_name + "_combinateur") or "et" + if combinateur == "ou": + attributes[control["combinator_attr"]] = combinateur + else: + attributes.pop(control["combinator_attr"], None) + continue + if ctype == "preset": chosen = form.get(field_name, control.get("default")) opt = next((o for o in control["options"] if o[0] == chosen), None) diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 89050b47..3460ca17 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -62,6 +62,10 @@ FLOW_NODE_FIELDS = { # qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir # comment les combiner. Absents => comportement legacy (une seule clause). "cond_clauses", "cond_combinator", + # Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir + # ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut + # "objet" ou "variable" ; absent => "objet", comportement historique). + "cond_source", "cond_variable", "cond_variable_chemin", "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", diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index a796fe91..155f2430 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -56,5 +56,21 @@ def ensure_flow_schema(slug): # cible une variable globale par NOM plutôt qu'un champ d'objet ; # réutilise data_operation/data_value, déjà là pour "modifier_donnee". conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT") + if "cond_source" not in cols: + # Un nœud Condition peut désormais tester soit un champ d'objet + # (comportement historique, cond_source absent ou "objet"), soit + # une VARIABLE GLOBALE (cond_source="variable" — cond_variable : + # son nom ; cond_variable_chemin : chemin optionnel ".champ"/ + # "[index]" si elle est de type objet/tableau, même syntaxe que + # "Chemin dans la variable" de la Condition de visibilité — voir + # visibility_condition_controls.py). Chaque clause de cond_clauses + # (JSON) porte les mêmes 3 clés en plus des siennes. Évalué côté + # CLIENT (templates/play.html, evaluateConditionClause) contre + # gameData.variables, exposé par full_game_payload.py. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_source TEXT") + if "cond_variable" not in cols: + 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") conn.commit() conn.close() diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 69fd2894..cad48553 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -67,7 +67,16 @@ def full_game_payload(slug): # modèle est utilisé ailleurs. element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)} + # Instantané des variables globales, pour qu'un nœud Condition puisse en + # tester une côté CLIENT (evaluateConditionClause, templates/play.html) — + # {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté + # SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de + # ça. refreshRuntimeData() récupère un payload entier (donc des + # variables à jour) après toute action qui en modifie une. + variables = {v["name"]: {"value": v["value"], "type": v["type"]} for v in db.list_global_variables(slug)} + return { "screens": payload_screens, "flows": flows, "animations": animations, "element_types": element_types, "data": data, "fields_meta": fields_meta, + "variables": variables, } diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index 6aca7715..8b0d025a 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -3,6 +3,8 @@ import re import db +from ..clause_list_codec import decode_clauses + # {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour # comparer le champ filtré à une valeur qui change en cours de partie (ex: # le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même @@ -154,13 +156,30 @@ def _compare(actual, operator, expected, field_type): return False -def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value): +def _clause_matches(slug, definition, row, clause): + field_name = (clause.get("champ") or "").strip() if not field_name: - return rows - expected = _resolve_filter_value(slug, raw_value or "") + return True + operator = clause.get("operateur") or "egal" + expected = _resolve_filter_value(slug, clause.get("valeur") or "") field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte") col = _field_column(definition, field_name) - return [r for r in rows if _compare(r.get(col), operator, expected, field_type)] + return _compare(row.get(col), operator, expected, field_type) + + +def filter_rows_by_clauses(slug, definition, rows, clauses, combinator): + """Ne garde, parmi les lignes, que celles qui satisfont la liste de + clauses données (un nombre quelconque), combinées entre elles par ET + (toutes vraies) ou par OU (au moins une vraie) selon `combinator` + ("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses + reconstruites depuis _filtre_champ/_filtre2_champ, voir + filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/ + Titre (clauses illimitées, voir resolve_bound_row.py).""" + if not clauses: + return rows + if combinator == "ou": + return [r for r in rows if any(_clause_matches(slug, definition, r, c) for c in clauses)] + return [r for r in rows if all(_clause_matches(slug, definition, r, c) for c in clauses)] def filter_repeater_rows(slug, definition, rows, attrs): @@ -172,12 +191,5 @@ def filter_repeater_rows(slug, definition, rows, attrs): sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher, par exemple, que le niveau du parcours en cours (2 conditions : le bon parcours ET le bon numéro de niveau) plutôt que tous les niveaux.""" - rows = _apply_one_filter( - slug, definition, rows, - (attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"), - ) - rows = _apply_one_filter( - slug, definition, rows, - (attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"), - ) - return rows + clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre") + return filter_rows_by_clauses(slug, definition, rows, clauses, combinator) diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index bf8dcd3e..1cf430e9 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -117,19 +117,35 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_ # Un exemplaire d'élément de jeu est posé par défaut avec le widget # générique "conteneur" (add_element.py, "default_widget") — utile # pour la plupart des modèles, mais QUAND le modèle entier n'est - # qu'une "Superposition / boîte de dialogue", cette enveloppe - # (fond, bordure, position normale sur le canevas — voir - # widgets/registry.py "conteneur") resterait visible EN PERMANENCE - # à l'endroit où l'exemplaire a été déposé, alors que la - # superposition à l'intérieur gère déjà entièrement sa propre - # apparence et son propre masquage (position:fixed plein écran, - # démarre masquée) — vécu comme "un conteneur vide apparaît sur la - # scène, pas la boîte de dialogue" (en réalité la boîte de dialogue - # existe bien, juste masquée comme prévu ; c'est le conteneur - # AUTOUR qui n'aurait jamais dû être visible). On court-circuite - # donc entièrement l'enveloppe et on renvoie directement le - # contenu du modèle. - return _mark(child_html) + # qu'une "Superposition / boîte de dialogue", la classe Bulma "box" + # de ce widget (widgets/registry.py "conteneur") lui donnait un + # fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été + # déposé, alors que la superposition à l'intérieur gère déjà + # entièrement sa propre apparence et son propre masquage + # (position:fixed plein écran, démarre masquée) — vécu comme "un + # conteneur vide apparaît sur la scène, pas la boîte de dialogue" + # (en réalité la boîte de dialogue existe bien, juste masquée comme + # prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa + # propre apparence visuelle). + # + # On ne court-circuite PLUS entièrement cette enveloppe (un essai + # précédent le faisait, en renvoyant `child_html` directement) : + # cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE + # précis (id=el["id"], distinct de l'id interne du modèle, + # ex. l'overlay du modèle "dialog" garde son propre id 72 alors que + # CET exemplaire est, par ex., 77) — une action "Modifier un + # élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle + # lui-même) cible cet id d'exemplaire, jamais celui, interne et + # partagé par tous les exemplaires, du modèle. La court-circuiter + # entièrement faisait disparaître cet id du DOM, rendant + # IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la + # logique de la scène qui l'accueille ("j'ai aucun contrôle sur la + # visibilité de la modale depuis la scène"). On garde donc la + # balise (avec son id), mais SANS la classe "box" du widget + # générique — juste une balise transparente, invisible tant + # qu'elle ne porte ni fond ni bordure.""" + attrs.pop("class", None) + return _mark(f"
{child_html}
") if tag in ("ul", "ol"): items = [line.strip() for line in content.split("\n") if line.strip()] @@ -154,12 +170,29 @@ def _render_element_type_children(el, slug, ctx): de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement vide. Le second élément renvoyé indique si le modèle N'EST QUE une superposition (voir l'appelant : dans ce cas précis, l'enveloppe - générique "conteneur" de l'exemplaire doit être court-circuitée).""" + générique "conteneur" de l'exemplaire ne doit pas avoir sa propre + apparence visuelle).""" template_tree = _load_template_tree(slug, el["element_type_id"]) if not template_tree: return None, False + is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"]) + if is_overlay_only: + # Le masquage de CET EXEMPLAIRE est entièrement délégué à + # l'enveloppe (voir l'appelant : add_element.py la fait démarrer + # masquée par défaut, exactement comme la superposition elle-même) + # — la superposition INTERNE au modèle ne doit donc plus appliquer + # SON PROPRE réglage "Visibilité" une fois posée comme exemplaire + # ailleurs (sinon double masquage : rendre l'enveloppe visible + # depuis la scène qui l'accueille resterait sans aucun effet + # visible tant que la superposition interne, elle, reste "Masqué" + # dans le modèle). On retire donc "display" de sa copie de style + # avant rendu (copie éphémère, jamais réenregistrée) — seul le + # display:flex de base (render_overlay.py) s'applique alors. + overlay_root = dict(template_tree["top"][0]) + overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"} + template_tree["top"][0] = overlay_root html = "".join( render_element_html(c, slug, template_tree["children_map"], ctx) for c in template_tree["top"] ) - return html, _is_overlay_only_element_type(slug, el["element_type_id"]) + return html, is_overlay_only diff --git a/screens/rendering/resolve_bound_row.py b/screens/rendering/resolve_bound_row.py index 597cb2e8..79b9e37a 100644 --- a/screens/rendering/resolve_bound_row.py +++ b/screens/rendering/resolve_bound_row.py @@ -1,13 +1,17 @@ import db -from .filter_repeater_rows import filter_repeater_rows, _field_column +from .filter_repeater_rows import filter_rows_by_clauses, _field_column +from ..clause_list_codec import decode_clauses def _resolve_bound_row_ctx(slug, attrs): """Si cet élément est lié à un objet de données (attr:_data_definition_id — réglage "Lier à un objet de données" des widgets Texte/Titre, voir - DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2 - filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le + DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions + réglées (un nombre quelconque, combinées par ET/OU — attr: + _data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien + format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments + créés avant l'ajout des clauses illimitées) — même moteur que le filtre du Répéteur de données, voir filter_repeater_rows.py — et renvoie ses champs sous forme de {nom_du_champ: valeur en texte}, exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule @@ -24,14 +28,8 @@ def _resolve_bound_row_ctx(slug, attrs): if not definition: return None rows = db.list_rows(slug, definition) - rows = filter_repeater_rows(slug, definition, rows, { - "_filtre_champ": attrs.get("_data_filtre_champ"), - "_filtre_operateur": attrs.get("_data_filtre_operateur"), - "_filtre_valeur": attrs.get("_data_filtre_valeur"), - "_filtre2_champ": attrs.get("_data_filtre2_champ"), - "_filtre2_operateur": attrs.get("_data_filtre2_operateur"), - "_filtre2_valeur": attrs.get("_data_filtre2_valeur"), - }) + clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre") + rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator) if not rows: return None row = rows[0] diff --git a/screens/widgets/control_groups/data_binding_controls.py b/screens/widgets/control_groups/data_binding_controls.py index 8d8334e1..adbe674f 100644 --- a/screens/widgets/control_groups/data_binding_controls.py +++ b/screens/widgets/control_groups/data_binding_controls.py @@ -1,26 +1,20 @@ -from ..controls.c_text import c_text from ..controls.c_select import c_select +from ..controls.c_clause_list import c_clause_list from ...flow.constants import CONDITION_OPERATORS # Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un # AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur — # utile pour une valeur unique calculée (ex: l'objectif du niveau en cours) -# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2 -# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du -# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la -# ligne la plus récente de l'objet choisi (convention déjà utilisée par la -# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne). +# plutôt qu'une liste. La ligne affichée est celle qui correspond aux +# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU — +# mêmes clauses, même moteur que le Répéteur de données, voir +# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente +# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet +# "état de partie" qui ne garde qu'une seule ligne). DATA_BINDING_CONTROLS = [ c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)", "attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"), - c_select("data_filtre_champ", "Trouver la ligne où ce champ", - "attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"), - c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"), - c_text("data_filtre_valeur", "...cette valeur", - "attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"), - c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ", - "attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"), - c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"), - c_text("data_filtre2_valeur", "...cette valeur", - "attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"), + c_clause_list("data_filtres", "Trouver la ligne où...", + clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur", + operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"), ] diff --git a/screens/widgets/control_value.py b/screens/widgets/control_value.py index b20aa867..ab838c87 100644 --- a/screens/widgets/control_value.py +++ b/screens/widgets/control_value.py @@ -1,5 +1,7 @@ import re +from ..clause_list_codec import decode_clauses + def _control_value(el, control): """Lit la valeur actuelle d'un réglage à partir de l'élément stocké @@ -7,8 +9,34 @@ def _control_value(el, control): ctype = control["type"] style = el.get("style") or {} + if ctype == "clause_list": + attrs = el.get("attributes") or {} + clauses, combinateur = decode_clauses( + attrs, control["clauses_attr"], control["combinator_attr"], + legacy_prefix=control.get("legacy_prefix"), + ) + return {"clauses": clauses, "combinateur": combinateur} + if ctype == "preset": - for key, _label, props in control["options"]: + # Trié par nombre de propriétés NON VIDES exigées, décroissant : une + # option comme "Visible normalement" (visibility_control.py), dont + # les critères ({"visibility": ""}) ne portent QUE sur "visibility" + # (par choix assumé, pour ne jamais toucher "display" — voir son + # commentaire), matchait sinon TOUJOURS en premier dès que + # "visibility" est absent, MÊME quand "display:none" (posé par + # "Masqué", un critère plus spécifique à 1 propriété non vide contre + # 0 pour "Visible") est également présent -- le panneau affichait + # alors "Visible normalement" sélectionné sur un élément en réalité + # masqué, et comme un + + {% for fname, flabel in c.field_options %} + + {% endfor %} + + + + +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+ + +
+
+ + diff --git a/templates/play.html b/templates/play.html index b49828f0..82bf95f3 100644 --- a/templates/play.html +++ b/templates/play.html @@ -835,13 +835,61 @@ } } + // Lit la valeur ACTUELLE d'une variable globale (gameData.variables, + // exposé par full_game_payload.py, tenu à jour par refreshRuntimeData() + // après toute action qui en modifie une), pour l'évaluation d'une + // condition — équivalent, côté client, de _resolve_filter_value côté + // serveur (screens/rendering/filter_repeater_rows.py), mais SANS la + // syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais + // nécessaire ici : le nom de la variable est choisi dans un menu + // déroulant, voir screen_edit.html). + function readVariableValue(name) { + const v = (gameData.variables || {})[name]; + return v ? v.value : undefined; + } + + // Navigue dans une valeur JSON (variable de type "objet"/"tableau") + // selon un chemin ".champ"/"[index]" chaînable — équivalent JS de + // _resolve_variable_path (même fichier Python que ci-dessus). Chemin + // vide -> valeur brute inchangée (le cas normal pour une variable + // scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond + // à rien -> null, comme côté serveur. + function resolveVariablePath(rawValue, path) { + if (!path) return rawValue; + let current; + try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; } + const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g; + let m; + while ((m = segmentRe.exec(path)) !== null) { + if (current === null || current === undefined) return null; + current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)]; + } + return current === undefined ? null : current; + } + // 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une // liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans // cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule // clause) garde EXACTEMENT son ancien comportement — une seule comparaison. + // + // Chaque clause peut tester soit un champ d'objet (source absente/"objet", + // comportement historique), soit une VARIABLE GLOBALE (source + // "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/ + // cond_variable_chemin). function evaluateConditionClause(clause) { + const source = clause.source ?? clause.cond_source ?? 'objet'; + const operator = clause.operator ?? clause.cond_operator; + const expected = clause.value ?? clause.cond_value; + if (source === 'variable') { + const varName = clause.variable ?? clause.cond_variable; + const path = clause.variable_chemin ?? clause.cond_variable_chemin; + const varInfo = (gameData.variables || {})[varName]; + const actual = resolveVariablePath(readVariableValue(varName), path); + const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : ''; + return compareValues(actual, operator, expected, fieldType); + } const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field); - return compareValues(actual, clause.operator ?? clause.cond_operator, clause.value ?? clause.cond_value, clause.field_type ?? clause.cond_field_type); + return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type); } function evaluateConditionNode(node) { diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 25e9c038..c6333d16 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -219,16 +219,40 @@ + + +

Aucune condition = la ligne la plus récente de cet objet.

+ + {% set clause = {"champ": "", "operateur": "egal", "valeur": ""} %} + {% elif c.type == 'shadow' %}
@@ -1621,8 +1684,9 @@ function initBuilderPanel() { bindPropsAutosave(); bindJaugeDefinitionSelect(); bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']); - bindDefinitionFieldSelects('field-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']); bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']); + bindClauseListDefinitionSelect('field-data_definition_id'); + bindClauseListRows(); initFilterValuePickers(); var visCondModeSel = document.getElementById('field-visibilite_cond_mode'); if (visCondModeSel) toggleVisCondFields(visCondModeSel); @@ -1701,6 +1765,88 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) { }); } +// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/ +// valeur), combinées entre elles par ET/OU (voir c_clause_list.py, +// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante +// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une +// condition" clone un