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>
165 lines
8.2 KiB
Python
165 lines
8.2 KiB
Python
import html as html_lib
|
|
|
|
import db
|
|
|
|
from .style_string import _style_string
|
|
from .auto_id import _auto_id
|
|
|
|
|
|
def _hex_to_rgb(hex_color):
|
|
hex_color = (hex_color or "#5b8cff").lstrip("#")
|
|
if len(hex_color) != 6:
|
|
hex_color = "5b8cff"
|
|
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
|
|
|
|
|
def _interpolate_color(low_hex, high_hex, ratio):
|
|
"""Dégradé linéaire simple entre deux couleurs (rouge en bas, vert en
|
|
haut par défaut, comme demandé dans le document d'interface de Boîte
|
|
Mail) — ratio est déjà borné entre 0 et 1 par l'appelant."""
|
|
lo, hi = _hex_to_rgb(low_hex), _hex_to_rgb(high_hex)
|
|
mixed = tuple(round(lo[i] + (hi[i] - lo[i]) * ratio) for i in range(3))
|
|
return "#{:02x}{:02x}{:02x}".format(*mixed)
|
|
|
|
|
|
def _read_field_value(slug, definition_id, row_id, field_name, player_id=db.PLAYER_SHARED):
|
|
"""Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de
|
|
l'objet suivi — quand un objet contient plusieurs enregistrements (ex:
|
|
Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est
|
|
ce qui distingue lequel suivre. Repli sur la ligne la PLUS RÉCENTE si
|
|
row_id est absent ou introuvable (jauges créées avant ce réglage, ou
|
|
ligne supprimée depuis) — comportement historique, jamais de régression
|
|
pour un objet à une seule ligne."""
|
|
if not definition_id or not field_name or not slug:
|
|
return None
|
|
definition = db.get_definition(slug, int(definition_id))
|
|
if not definition:
|
|
return None
|
|
rows = db.list_rows(slug, definition, player_id)
|
|
if not rows:
|
|
return None
|
|
row = None
|
|
if row_id:
|
|
row = next((r for r in rows if str(r["id"]) == str(row_id)), None)
|
|
if row is None:
|
|
row = rows[0]
|
|
col = db.slugify(field_name).replace("-", "_")
|
|
return row.get(col)
|
|
|
|
|
|
def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
|
"""Jauge/barre dont le remplissage (largeur en %) et la couleur (dégradé
|
|
bas -> haut) se recalculent automatiquement à partir d'un champ
|
|
numérique d'un objet de données — voir 2.1 dans la doc de cadrage Forge
|
|
Engine ("binding live d'une donnée vers un style"). Recalculée à chaque
|
|
régénération du HTML du jeu (chargement de /play, et après toute action
|
|
"Modifier une donnée"), donc toujours à jour sans action supplémentaire
|
|
à poser dans le graphe de logique."""
|
|
attrs = el.get("attributes") or {}
|
|
nom_position = attrs.get("_nom_position") or "dessus"
|
|
nom_alignement = attrs.get("_nom_alignement") or "centre"
|
|
nom_police = attrs.get("_nom_police") or "inherit"
|
|
nom_taille = attrs.get("_nom_taille") or "12"
|
|
nom_gras = attrs.get("_nom_gras") == "1"
|
|
nom_italique = attrs.get("_nom_italique") == "1"
|
|
# display:flex — le nom (optionnel) s'empile AU-DESSUS de la barre (mode
|
|
# "dessus", flex-direction:column) ou se place à sa gauche (mode "cote",
|
|
# flex-direction:row) plutôt que de se superposer dedans — voir name_html
|
|
# plus bas. "justify-content:center" recentre le bloc nom+barre quand la
|
|
# jauge se retrouve avec une hauteur supérieure à son contenu (ex. héritée
|
|
# d'un conteneur parent qui l'étire) — sans ça le bloc restait collé en
|
|
# haut de sa propre boîte, donnant une impression de centrage faussé dès
|
|
# qu'un nom (donc plus de contenu) était ajouté. Le fond/bordure/overflow
|
|
# vivent sur le wrapper de la barre (bar_wrap_style), pas ici, pour que le
|
|
# nom reste en dehors de la zone rognée par "overflow:hidden".
|
|
flex_dir = "row" if nom_position == "cote" else "column"
|
|
align_items = "center" if nom_position == "cote" else "stretch"
|
|
style = _style_string(
|
|
el,
|
|
f" display:flex; flex-direction:{flex_dir}; align-items:{align_items}; justify-content:center; gap:4px;",
|
|
parent_flex_direction,
|
|
)
|
|
definition_id = attrs.get("_definition_id")
|
|
row_id = attrs.get("_row_id")
|
|
field_name = (attrs.get("_champ") or "").strip()
|
|
field_name_nom = (attrs.get("_champ_nom") or "").strip()
|
|
try:
|
|
min_v = float(attrs.get("_min", "0") or 0)
|
|
except (TypeError, ValueError):
|
|
min_v = 0.0
|
|
try:
|
|
max_v = float(attrs.get("_max", "100") or 100)
|
|
except (TypeError, ValueError):
|
|
max_v = 100.0
|
|
couleur_basse = attrs.get("_couleur_basse") or "#e05252"
|
|
couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
|
|
afficher_valeur = attrs.get("_afficher_valeur") == "1"
|
|
|
|
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
|
|
raw_value = _read_field_value(slug, definition_id, row_id, field_name, player_id) if field_name else None
|
|
try:
|
|
value = float(raw_value)
|
|
except (TypeError, ValueError):
|
|
value = min_v
|
|
|
|
span = (max_v - min_v) or 1
|
|
ratio = max(0.0, min(1.0, (value - min_v) / span))
|
|
percent = round(ratio * 100, 1)
|
|
fill_color = _interpolate_color(couleur_basse, couleur_haute, ratio)
|
|
|
|
label_html = ""
|
|
if afficher_valeur:
|
|
label_html = (
|
|
f'<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
|
f'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
|
f'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">{html_lib.escape(str(raw_value if raw_value is not None else "—"))}</div>'
|
|
)
|
|
|
|
name_html = ""
|
|
if field_name_nom:
|
|
nom_value = _read_field_value(slug, definition_id, row_id, field_name_nom, player_id)
|
|
if nom_value not in (None, ""):
|
|
text_align = "left" if (nom_position == "dessus" and nom_alignement == "gauche") else "center"
|
|
name_html = (
|
|
f'<div style="flex:0 0 auto; font-family:{html_lib.escape(nom_police)}; '
|
|
f'font-size:{html_lib.escape(str(nom_taille))}px; '
|
|
f'font-weight:{"700" if nom_gras else "400"}; '
|
|
f'font-style:{"italic" if nom_italique else "normal"}; '
|
|
f'color:#e8eaf0; text-align:{text_align};">{html_lib.escape(str(nom_value))}</div>'
|
|
)
|
|
|
|
# Vraie balise <progress> Bulma ("class=progress") plutôt qu'une barre
|
|
# maison faite de <div> positionnés — la couleur dégradée bas/haut (voir
|
|
# _interpolate_color ci-dessus) reste possible nativement : Bulma pilote
|
|
# justement le remplissage via la variable CSS --bulma-progress-value-
|
|
# background-color, qu'on peut fixer en ligne à la couleur calculée sans
|
|
# sortir du système de classes de Bulma. La classe "jaugeBar" est
|
|
# repérée côté client (play.html, refreshRuntimeData) pour savoir que
|
|
# cette balise doit être régénérée après toute action "Modifier une
|
|
# donnée" — sinon la jauge resterait figée sur sa valeur de départ après
|
|
# un changement, exactement comme il fallait déjà repérer "repeaterItem"
|
|
# pour les Répéteurs (voir 1.1/1.2).
|
|
extra_classes = " ".join(v for k, v in attrs.items() if k.startswith("_class_") and v)
|
|
bar_class = html_lib.escape((f"progress jaugeBar {extra_classes}").strip())
|
|
bar = (
|
|
f'<progress class="{bar_class}" value="{percent}" max="100" '
|
|
f'style="--bulma-progress-value-background-color:{fill_color}; --bulma-progress-bar-background-color:#1c2130; '
|
|
f'margin:0; border-radius:6px; transition:background-color .3s ease;">{percent}%</progress>'
|
|
)
|
|
# min-height (pas 0) : un élément posé DANS un conteneur a une hauteur
|
|
# "auto" par défaut (voir _style_string) — "flex:1 1 auto" seul n'a
|
|
# alors rien à répartir (le conteneur flex lui-même n'a pas de hauteur
|
|
# définie tant que rien ne la contraint) et la barre s'effondrait à 0px,
|
|
# ne laissant plus que le nom visible. Cette valeur plancher garde la
|
|
# barre visible dans ce cas, tout en la laissant grandir avec flex:1 si
|
|
# l'élément a une vraie hauteur (posé directement sur un écran, ou
|
|
# hauteur fixée via "Position & taille").
|
|
bar_wrap = (
|
|
f'<div style="position:relative; display:flex; align-items:center; '
|
|
f'flex:1 1 auto; min-height:14px;">{bar}{label_html}</div>'
|
|
)
|
|
return (
|
|
f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">'
|
|
f'{name_html}{bar_wrap}</div>'
|
|
)
|