Fil directeur du plan : chaque variable globale et chaque objet de données gagne un player_id (sentinelle PLAYER_SHARED='__shared__' par défaut partout — l'aperçu créateur et tous les tests existants continuent de fonctionner À L'IDENTIQUE, aucune signature appelée sans argument explicite ne change de comportement), plus un réglage per_player choisi une fois à la création : - per_player=1 (par défaut) : chaque joueur a sa propre valeur/ses propres lignes. - per_player=0 : valeur/lignes partagées par tous les joueurs (ex. un compteur de visiteurs global, un catalogue commun). db/global_vars/ : _global_variables passe de UNIQUE(name) à UNIQUE(name, player_id) — SQLite ne permet pas de modifier une contrainte UNIQUE via ALTER TABLE, reconstruction de la table détectée et faite une seule fois (ensure_global_vars_schema.py) pour les jeux créés avant cette phase. La ligne "modèle" (player_id=PLAYER_SHARED, créée par le créateur) porte le réglage per_player et sert de valeur PAR DÉFAUT : la première écriture d'un joueur sur une variable per_player crée paresseusement SA propre ligne (copiée depuis le modèle) ; une lecture sans ligne encore écrite retombe sur le modèle (nouveau resolve_player_key.py). list_global_variables() (tableau de bord) ne montre toujours que les lignes modèles ; nouveau list_global_variables_for_player() expose la valeur EFFECTIVE d'un joueur au runtime (full_game_payload.py). db/definitions/ + db/rows/ : chaque table d'objet généré (create_definition.py) gagne une colonne player_id (ADD COLUMN simple, pas de contrainte UNIQUE en jeu ici) ; _definitions gagne per_player. Contrairement aux variables, PAS de repli sur une ligne "modèle" pour les lignes d'un objet per_player — une LISTE n'a pas de valeur par défaut unique à copier comme un scalaire, un nouvel objet per_player démarre VIDE pour chaque joueur (nouveau resolve_row_player_key.py). get_row/update_row/update_row_field/delete_row filtrent aussi par player_id (pas seulement id) : garde-fou contre un row_id d'un AUTRE joueur, nécessaire dès qu'un objet per_player sera exposé sur la future route publique /jouer/<slug>. Migration : nouveau ensure_player_id_column(slug, table_name), appelé avant toute requête sur une table d'objet créée avant cette phase. Chaîne de rendu (screens/elements/list_elements.py -> screens/rendering/render_element_html.py -> render_repeater.py/ render_jauge.py/resolve_bound_row.py/visibility_condition.py/ filter_repeater_rows.py) : player_id transite dans le ctx déjà utilisé partout pour "champ en cours" (ctx["_forge_player_id"], même patron que ctx["_forge_play_mode"], posé une seule fois par list_elements quand enforce_visibility=True) — pas de nouveau paramètre positionnel à threader dans chaque fonction, juste une clé de plus dans un mécanisme déjà en place. Nouveau tests/test_player_state.py : verrouille à la fois le nouveau comportement (deux joueurs => valeurs/lignes indépendantes ; per_player=0 => partagé ; nouveau joueur => valeur par défaut pour une variable, liste VIDE pour un objet ; get_row ne fuite jamais vers un autre joueur) et la non-régression de l'aperçu créateur (comportement historique inchangé). Vérifié : 232 tests passent (9 nouveaux). Reste à faire (prochains commits) : route publique /jouer/<slug>, identité visiteur (cookie), bascule "Publier en ligne" dans le tableau de bord. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
13 KiB
Python
218 lines
13 KiB
Python
import html as html_lib
|
|
|
|
from ..widgets.widget_meta import widget_meta
|
|
from .visible_attrs import _visible_attrs
|
|
from .style_string import _style_string
|
|
from .attr_string import _attr_string
|
|
from .apply_ctx import _apply_ctx
|
|
from .render_children import _render_children
|
|
from .render_table import _render_table
|
|
from .render_select import _render_select
|
|
from .render_fieldset import _render_fieldset
|
|
from .render_checkbox_or_radio import _render_checkbox_or_radio
|
|
from .render_repeater import _render_repeater
|
|
from .render_jauge import _render_jauge
|
|
from .render_overlay import _render_overlay
|
|
from .render_onglets import _render_onglets
|
|
from .render_icone import _render_icone
|
|
from .resolve_bound_row import _resolve_bound_row_ctx
|
|
from .visibility_condition import is_element_visible
|
|
from ..element_types.load_template_tree import _load_template_tree
|
|
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
|
|
|
|
|
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
|
"""Construit le HTML réel (balise + attributs + style + contenu, y
|
|
compris les éléments physiquement posés à l'intérieur pour un
|
|
conteneur/répéteur/groupe de champs) — utilisé par l'éditeur (canevas)
|
|
et le mode jouable. N'écrit jamais rien sur disque : uniquement du HTML
|
|
généré à la volée à partir de ce qui est stocké en base. `slug` est
|
|
nécessaire pour le Répéteur de données (il doit lire les données du jeu
|
|
au moment de l'affichage). `ctx` porte les valeurs {{champ}} de la ligne
|
|
en cours quand cet élément est descendant d'un répéteur. `parent_flex_direction`
|
|
est la disposition interne (voir _parent_flex_direction) du conteneur qui
|
|
pose physiquement cet élément, s'il y en a un — elle influence la largeur
|
|
par défaut de cet élément (voir _style_string)."""
|
|
meta = widget_meta(el.get("widget"))
|
|
attrs_raw = el.get("attributes") or {}
|
|
# La condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) n'est
|
|
# appliquée qu'en mode JOUABLE (ctx["_forge_play_mode"], posé par
|
|
# full_game_payload.py via list_elements(..., enforce_visibility=True))
|
|
# — jamais dans l'éditeur, où l'élément doit rester visible/sélectionnable
|
|
# même si la condition serait actuellement fausse, sans quoi il
|
|
# deviendrait impossible de le retrouver pour le régler. Un commentaire
|
|
# marqueur ("visibilityGated") reste posé sur le HTML même quand la
|
|
# condition est vraie, pour que refreshRuntimeData() (play.html) sache
|
|
# qu'il faut réévaluer cet élément après toute action "Modifier une
|
|
# donnée"/"Modifier une variable" — pas seulement Jauge/Répéteur, qui ont
|
|
# déjà chacun leur propre marqueur ("jaugeBar"/"repeaterItem") couvrant
|
|
# ce cas d'office.
|
|
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
|
|
play_mode = bool((ctx or {}).get("_forge_play_mode"))
|
|
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
|
|
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
|
|
|
|
# "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu
|
|
# ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du
|
|
# rendu — potentiellement via une "Variable globale" en valeur de
|
|
# comparaison (voir _resolve_filter_value). Comme "visibilityGated"
|
|
# juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML pour
|
|
# que refreshRuntimeData() (play.html) sache qu'il faut réévaluer cet
|
|
# élément après toute action "Modifier une donnée"/"Modifier une
|
|
# variable" — sans lui, changer la variable choisie changeait bien QUELLE
|
|
# ligne aurait dû s'afficher (recalculé correctement côté serveur à
|
|
# chaque rendu), mais le client ne redemandait jamais le nouveau HTML de
|
|
# CET élément précis : le texte affiché restait figé sur son ancienne
|
|
# ligne jusqu'au prochain rechargement complet de la page.
|
|
has_data_binding = bool(attrs_raw.get("_data_definition_id"))
|
|
|
|
def _mark(html_str):
|
|
markers = ""
|
|
if has_visibility_condition:
|
|
markers += "<!--visibilityGated-->"
|
|
if has_data_binding:
|
|
markers += "<!--dataBound-->"
|
|
return html_str + markers
|
|
|
|
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw, (ctx or {}).get("_forge_player_id", "__shared__"))
|
|
if bound_ctx:
|
|
ctx = {**(ctx or {}), **bound_ctx}
|
|
# Chaque branche "special_render" passe elle aussi par _mark() : sans
|
|
# ça, un widget de ce genre (Icône, Tableau, Superposition...) actuellement
|
|
# VISIBLE sous condition ne portait aucun marqueur, et refreshRuntimeData()
|
|
# (play.html) ne savait donc jamais qu'il fallait le réévaluer après une
|
|
# action "Modifier une donnée/variable" — il restait figé dans son état
|
|
# de l'affichage précédent (ex: une icône "enveloppe fermée" restée
|
|
# visible après l'ouverture du mail, en même temps que "enveloppe
|
|
# ouverte" qui apparaît, elle, correctement puisqu'un élément CACHÉ
|
|
# porte toujours son marqueur via le placeholder ci-dessus).
|
|
special = meta.get("special_render")
|
|
if special == "table":
|
|
return _mark(_render_table(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "select_options":
|
|
return _mark(_render_select(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "fieldset_legend":
|
|
return _mark(_render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "checkbox_label":
|
|
return _mark(_render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "repeater":
|
|
return _mark(_render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "jauge":
|
|
return _mark(_render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "overlay":
|
|
return _mark(_render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "onglets":
|
|
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "icone":
|
|
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
|
|
tag = meta.get("tag")
|
|
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
|
niveau = (el.get("attributes") or {}).get("_niveau", "2")
|
|
tag = f"h{niveau}"
|
|
|
|
attrs = _visible_attrs(el, meta, ctx)
|
|
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
|
|
|
# 3.1 (Confort) — interactions au survol : un texte de remplacement
|
|
# optionnel, affiché à la place du contenu normal quand le joueur
|
|
# survole cet élément à la souris (voir bindHoverTexts() dans
|
|
# templates/play.html). Absent par défaut => aucun changement, donc
|
|
# aucune régression sur les éléments déjà créés.
|
|
hover_text = (el.get("attributes") or {}).get("_survol_texte")
|
|
if hover_text:
|
|
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
|
|
|
|
if meta.get("self_closing"):
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
|
|
|
|
content = _apply_ctx(el.get("content") or "", ctx)
|
|
child_html, is_overlay_template = (
|
|
_render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False)
|
|
)
|
|
if child_html is None:
|
|
child_html = _render_children(el, slug, children_map, ctx)
|
|
elif is_overlay_template:
|
|
# 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", 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"<div {_attr_string(attrs, style)}>{child_html}</div>")
|
|
|
|
if tag in ("ul", "ol"):
|
|
items = [line.strip() for line in content.split("\n") if line.strip()]
|
|
inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
|
|
|
|
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
|
|
|
|
|
|
def _render_element_type_children(el, slug, ctx):
|
|
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
|
|
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
|
|
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
|
|
copiés en base — exactement le même principe qu'un modèle de ligne de
|
|
Répéteur (voir _render_repeater/_load_template_tree). Modifier
|
|
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
|
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
|
retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a
|
|
plus de modèle valide, pour que l'appelant retombe sur le rendu
|
|
générique (d'éventuels enfants en base issus d'une version antérieure
|
|
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 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
|