Phase 1 (1/3) : état par joueur — couche db/ et chaîne de rendu
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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>
This commit is contained in:
william
2026-08-30 15:31:08 +02:00
co-authored by Claude Sonnet 5
parent bb84b7b377
commit 236d6b4b46
33 changed files with 469 additions and 107 deletions
+4 -2
View File
@@ -53,7 +53,9 @@ from .rows.update_row_field import update_row_field
from .rows.delete_row import delete_row
from .rows.rows_referencing import rows_referencing
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .global_vars.list_global_variables import list_global_variables
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
from .global_vars.get_global_variable import get_global_variable
from .global_vars.create_global_variable import create_global_variable
from .global_vars.update_global_variable_value import update_global_variable_value
@@ -67,7 +69,7 @@ from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES",
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
"slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"list_definitions", "get_definition", "create_definition", "rename_definition",
@@ -75,7 +77,7 @@ __all__ = [
"delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row",
"update_row_field", "delete_row", "rows_referencing",
"list_global_variables", "get_global_variable", "create_global_variable",
"list_global_variables", "list_global_variables_for_player", "get_global_variable", "create_global_variable",
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
+14 -3
View File
@@ -7,18 +7,28 @@ from .get_definition import get_definition
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def create_definition(slug, name, fields):
def create_definition(slug, name, fields, per_player=True):
"""Feature 2 : crée une définition d'objet (comme une table de BDD) et
exécute le vrai CREATE TABLE correspondant, avec les bons types de
colonne, y compris les colonnes de clé étrangère pour les relations
vers d'autres définitions déjà créées dans CE jeu (une définition peut
donc "en contenir" une autre, au sens relationnel)."""
donc "en contenir" une autre, au sens relationnel).
per_player (état par joueur, Phase 1) : True (par défaut) = chaque
joueur a ses propres lignes (une table player_id-scopée qui démarre
VIDE pour chaque nouveau joueur — pas de valeur par défaut à copier,
contrairement à une variable globale, voir
db/global_vars/update_global_variable_value.py) ; False = lignes
partagées par tous les joueurs (ex. un catalogue/une liste de
questions), toujours player_id=PLAYER_SHARED quel que soit le joueur
qui consulte."""
ensure_field_bounds_schema(slug)
conn = connect(slug)
tname = table_name_for(name)
conn.execute(
"INSERT INTO _definitions (name, table_name) VALUES (?, ?)", (name, tname)
"INSERT INTO _definitions (name, table_name, per_player) VALUES (?, ?, ?)",
(name, tname, 1 if per_player else 0),
)
definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
@@ -50,6 +60,7 @@ def create_definition(slug, name, fields):
(definition_id, f["name"], ftype, relation_definition_id, required, pos, min_value, max_value),
)
columns_sql.append("player_id TEXT NOT NULL DEFAULT '__shared__'")
columns_sql.append("created_at TEXT DEFAULT CURRENT_TIMESTAMP")
create_sql = f"CREATE TABLE {tname} ({', '.join(columns_sql)})"
conn.execute(create_sql)
+8 -2
View File
@@ -4,13 +4,19 @@ from ..connection import connect
def ensure_field_bounds_schema(slug):
"""Migration légère (voir screens/screens_repo/ensure_schema.py pour le
même principe) : ajoute les colonnes min_value/max_value à _fields pour
les jeux créés avant le bornage automatique (2.2) — aucune migration
manuelle nécessaire, la prochaine lecture d'une définition suffit."""
les jeux créés avant le bornage automatique (2.2), et per_player à
_definitions pour les jeux créés avant l'état par joueur (Phase 1) —
aucune migration manuelle nécessaire, la prochaine lecture d'une
définition suffit. per_player=1 par défaut (comportement le plus
proche de l'ancien objet-par-défaut : voir create_definition.py)."""
conn = connect(slug)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_fields)").fetchall()}
if "min_value" not in cols:
conn.execute("ALTER TABLE _fields ADD COLUMN min_value REAL")
if "max_value" not in cols:
conn.execute("ALTER TABLE _fields ADD COLUMN max_value REAL")
definition_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_definitions)").fetchall()}
if "per_player" not in definition_cols:
conn.execute("ALTER TABLE _definitions ADD COLUMN per_player INTEGER NOT NULL DEFAULT 1")
conn.commit()
conn.close()
+20 -10
View File
@@ -1,27 +1,37 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .coerce_structured_value import coerce_structured_value
def create_global_variable(slug, name, var_type="texte", default_value=""):
"""Crée une variable globale si son nom n'existe pas déjà (les noms sont
uniques — voir ensure_global_vars_schema.py) ; si elle existe déjà, ne
touche à rien et renvoie simplement son id existant, pour que "créer"
depuis le sélecteur de condition de visibilité (voir screen_edit.html)
reste sans risque même en cas de double clic/soumission."""
def create_global_variable(slug, name, var_type="texte", default_value="", per_player=True):
"""Crée la ligne "modèle" d'une variable globale (toujours
player_id=PLAYER_SHARED, quel que soit per_player — voir
ensure_global_vars_schema.py) si son nom n'existe pas déjà ; si elle
existe déjà, ne touche à rien et renvoie simplement son id existant,
pour que "créer" depuis le sélecteur de condition de visibilité (voir
screen_edit.html) reste sans risque même en cas de double clic/
soumission.
per_player (réglé une fois, à la création, coché par défaut) :
True = chaque joueur aura sa propre valeur (une ligne créée
paresseusement à sa première écriture, voir
update_global_variable_value.py) ; False = valeur unique partagée par
tous les joueurs (ex. un compteur de visiteurs global)."""
name = (name or "").strip()
if not name:
return None
default_value = coerce_structured_value(var_type, default_value)
ensure_global_vars_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone()
existing = conn.execute(
"SELECT id FROM _global_variables WHERE name = ? AND player_id = ?", (name, PLAYER_SHARED)
).fetchone()
if existing:
conn.close()
return existing["id"]
conn.execute(
"INSERT INTO _global_variables (name, type, value) VALUES (?, ?, ?)",
(name, var_type, default_value),
"INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)",
(name, var_type, default_value, PLAYER_SHARED, 1 if per_player else 0),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
+3
View File
@@ -3,6 +3,9 @@ from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable(slug, name):
"""Supprime TOUTES les lignes de ce nom, pas seulement la ligne modèle
PLAYER_SHARED — une variable per_player peut avoir une ligne par joueur
ayant déjà écrit dessus (voir update_global_variable_value.py)."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE name = ?", (name,))
@@ -6,9 +6,15 @@ def delete_global_variable_by_id(slug, var_id):
"""Version par id de delete_global_variable — utilisée par la page de
gestion des variables (game_variables.html), où chaque ligne connaît son
id technique mais pas nécessairement un nom encore garanti unique côté
formulaire d'URL (espaces, accents...)."""
formulaire d'URL (espaces, accents...). Supprime aussi TOUTES les
lignes des autres joueurs pour ce même nom (une variable per_player en
a une par joueur qui a déjà écrit dessus, voir
update_global_variable_value.py) — sans ça, supprimer une variable
depuis le tableau de bord laisserait des lignes orphelines."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE id = ?", (var_id,))
row = conn.execute("SELECT name FROM _global_variables WHERE id = ?", (var_id,)).fetchone()
if row:
conn.execute("DELETE FROM _global_variables WHERE name = ?", (row["name"],))
conn.commit()
conn.close()
+42 -6
View File
@@ -1,23 +1,59 @@
from ..connection import connect
PLAYER_SHARED = "__shared__"
def ensure_global_vars_schema(slug):
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
crée _global_variables si absente — aucune migration manuelle nécessaire
pour les jeux créés avant cette fonctionnalité, la prochaine lecture
suffit. Une variable globale vit pour TOUT le jeu (pas par écran, pas
par ligne) : "nom" est donc UNIQUE, une seule valeur actuelle par nom."""
crée _global_variables si absente. Depuis l'état par joueur (Phase 1),
une variable a une ligne "modèle" créée par le créateur du jeu
(player_id=PLAYER_SHARED, voir PLAYER_SHARED ci-dessus), qui porte son
réglage per_player (1 = chaque joueur a sa propre valeur, une ligne
créée paresseusement à sa première écriture — voir
update_global_variable_value.py ; 0 = valeur unique partagée par tous
les joueurs, toujours la ligne PLAYER_SHARED elle-même) — "nom" n'est
donc plus unique à lui seul, seulement (nom, player_id)."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _global_variables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'texte',
value TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
player_id TEXT NOT NULL DEFAULT '__shared__',
per_player INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, player_id)
);
"""
)
# SQLite ne permet pas de modifier une contrainte UNIQUE existante via
# ALTER TABLE ADD COLUMN — un jeu créé avant la Phase 1 a déjà cette
# table, sous l'ancien schéma (UNIQUE(name) seul, sans player_id/
# per_player) : reconstruction complète si détecté, chaque variable
# existante devenant la ligne "modèle" PLAYER_SHARED, per_player=1 par
# défaut (comportement le plus proche de l'ancien : une seule valeur
# jusqu'ici, qui devient réellement par-joueur dès la Phase 1 en place).
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_global_variables)").fetchall()}
if "player_id" not in cols:
conn.executescript(
"""
ALTER TABLE _global_variables RENAME TO _global_variables_old;
CREATE TABLE _global_variables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'texte',
value TEXT,
player_id TEXT NOT NULL DEFAULT '__shared__',
per_player INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, player_id)
);
INSERT INTO _global_variables (id, name, type, value, created_at)
SELECT id, name, type, value, created_at FROM _global_variables_old;
DROP TABLE _global_variables_old;
"""
)
conn.commit()
conn.close()
+15 -7
View File
@@ -1,16 +1,24 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .resolve_player_key import resolve_player_key
def get_global_variable(slug, name):
"""Par NOM (unique), pas par id — c'est comme ça qu'une variable est
référencée partout ailleurs (condition de visibilité, action "Modifier
une variable"), jamais par un id technique que l'utilisateur ne voit
pas."""
def get_global_variable(slug, name, player_id=PLAYER_SHARED):
"""Par NOM (pas par id — c'est comme ça qu'une variable est référencée
partout ailleurs, condition de visibilité, action "Modifier une
variable") et par joueur (voir resolve_player_key.py) : une variable
per_player renvoie la ligne propre à `player_id`, ou à défaut (ce
joueur n'a encore rien écrit) la ligne modèle PLAYER_SHARED, qui porte
la valeur par défaut définie à la création."""
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _global_variables WHERE name = ?", (name,)).fetchone()
key = resolve_player_key(conn, name, player_id)
row = conn.execute("SELECT * FROM _global_variables WHERE name = ? AND player_id = ?", (name, key)).fetchone()
if not row and key != PLAYER_SHARED:
row = conn.execute(
"SELECT * FROM _global_variables WHERE name = ? AND player_id = ?", (name, PLAYER_SHARED)
).fetchone()
conn.close()
return dict(row) if row else None
+9 -2
View File
@@ -1,10 +1,17 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
def list_global_variables(slug):
"""Toujours les lignes MODÈLES (PLAYER_SHARED) — c'est la liste des
variables telle que le créateur les a définies dans le tableau de
bord, jamais influencée par le nombre de joueurs ayant déjà leur
propre valeur pour une variable per_player (voir
ensure_global_vars_schema.py)."""
ensure_global_vars_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _global_variables ORDER BY name").fetchall()
rows = conn.execute(
"SELECT * FROM _global_variables WHERE player_id = ? ORDER BY name", (PLAYER_SHARED,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
@@ -0,0 +1,18 @@
from .ensure_global_vars_schema import PLAYER_SHARED
from .list_global_variables import list_global_variables
from .get_global_variable import get_global_variable
def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
"""Valeur EFFECTIVE de chaque variable pour ce joueur (voir
get_global_variable.py : la ligne propre à ce joueur si elle existe
déjà, sinon la valeur par défaut de la ligne modèle) — à la différence
de list_global_variables() (toujours les lignes modèles, pour le
tableau de bord), c'est cette version qu'il faut exposer au runtime
jouable (full_game_payload.py), pour qu'une condition évaluée côté
client reflète bien l'état RÉEL de CE joueur."""
result = []
for template in list_global_variables(slug):
value = get_global_variable(slug, template["name"], player_id)
result.append(value if value else template)
return result
+17
View File
@@ -0,0 +1,17 @@
from .ensure_global_vars_schema import PLAYER_SHARED
def resolve_player_key(conn, name, player_id):
"""Détermine le player_id à utiliser RÉELLEMENT pour lire/écrire cette
variable : celui du joueur (variable per_player=1, le cas par défaut),
ou toujours PLAYER_SHARED (variable explicitement partagée entre tous
les joueurs, per_player=0) — décidé par le réglage de la ligne
"modèle" créée par le créateur du jeu (voir create_global_variable.py).
Une variable encore inconnue (aucune ligne modèle) est traitée comme
per_player=1 par défaut."""
row = conn.execute(
"SELECT per_player FROM _global_variables WHERE name = ? AND player_id = ?",
(name, PLAYER_SHARED),
).fetchone()
per_player = row["per_player"] if row else 1
return player_id if per_player else PLAYER_SHARED
+28 -8
View File
@@ -1,18 +1,38 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .get_global_variable import get_global_variable
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
from .resolve_player_key import resolve_player_key
def update_global_variable_value(slug, name, new_value):
def update_global_variable_value(slug, name, new_value, player_id=PLAYER_SHARED):
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant) sur la
variable — utilisé aussi bien par l'action "Modifier une variable" (voir
screens/global_vars/apply_variable_action.py) que par une éventuelle
modification directe (page de gestion des variables)."""
if not get_global_variable(slug, name):
return False
screens/data_actions/apply_variable_action.py) que par une éventuelle
modification directe (page de gestion des variables).
Pour une variable per_player, la PREMIÈRE écriture de ce joueur crée sa
propre ligne (copiée depuis la ligne modèle PLAYER_SHARED — même type/
per_player) ; les écritures suivantes mettent juste à jour cette ligne.
Une variable per_player=0 écrit toujours la ligne PLAYER_SHARED elle-même
(voir resolve_player_key.py)."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET value = ? WHERE name = ?", (str(new_value), name))
key = resolve_player_key(conn, name, player_id)
existing = conn.execute(
"SELECT id FROM _global_variables WHERE name = ? AND player_id = ?", (name, key)
).fetchone()
if existing:
conn.execute("UPDATE _global_variables SET value = ? WHERE id = ?", (str(new_value), existing["id"]))
else:
template = conn.execute(
"SELECT type, per_player FROM _global_variables WHERE name = ? AND player_id = ?", (name, PLAYER_SHARED)
).fetchone()
if not template:
conn.close()
return False
conn.execute(
"INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)",
(name, template["type"], str(new_value), key, template["per_player"]),
)
conn.commit()
conn.close()
return True
+6 -2
View File
@@ -1,8 +1,12 @@
from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def delete_row(slug, definition, row_id):
def delete_row(slug, definition, row_id, player_id=PLAYER_SHARED):
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
conn = connect(slug)
conn.execute(f"DELETE FROM {definition['table_name']} WHERE id = ?", (row_id,))
conn.execute(f"DELETE FROM {definition['table_name']} WHERE id = ? AND player_id = ?", (row_id, key))
conn.commit()
conn.close()
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
def ensure_player_id_column(slug, table_name):
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
ajoute player_id à la table d'un objet créé avant l'état par joueur
(Phase 1) — ses lignes existantes deviennent implicitement
PLAYER_SHARED (comportement le plus proche de l'ancien : une seule
liste jusqu'ici, désormais la liste "partagée" par défaut tant que le
créateur n'a rien de plus à faire)."""
conn = connect(slug)
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table_name})").fetchall()}
if "player_id" not in cols:
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN player_id TEXT NOT NULL DEFAULT '__shared__'")
conn.commit()
conn.close()
+11 -2
View File
@@ -1,10 +1,19 @@
from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def get_row(slug, definition, row_id):
def get_row(slug, definition, row_id, player_id=PLAYER_SHARED):
"""Filtre aussi par player_id (pas seulement id) : même si `row_id` est
déjà un identifiant unique dans toute la table, ce garde-fou empêche un
joueur de lire la ligne d'un AUTRE joueur en devinant/altérant un
row_id (garantie utile dès qu'un objet per_player est exposé sur la
route publique /jouer/<slug>, voir routes/public_play/)."""
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
conn = connect(slug)
row = conn.execute(
f"SELECT * FROM {definition['table_name']} WHERE id = ?", (row_id,)
f"SELECT * FROM {definition['table_name']} WHERE id = ? AND player_id = ?", (row_id, key)
).fetchone()
conn.close()
return dict(row) if row else None
+9 -1
View File
@@ -1,16 +1,24 @@
from ..connection import connect
from ..quote_ident import quote_ident
from .row_columns_and_values import row_columns_and_values
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def insert_row(slug, definition, form_data):
def insert_row(slug, definition, form_data, player_id=PLAYER_SHARED):
"""Feature 3 : enregistre en base les données saisies dans le formulaire
généré à partir de la définition d'objet."""
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
cols, values = row_columns_and_values(definition, form_data)
cols = cols + ["player_id"]
values = values + [key]
conn = connect(slug)
placeholders = ["?"] * len(cols)
quoted_cols = ", ".join(quote_ident(c) for c in cols)
sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})"
conn.execute(sql, values)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
+8 -2
View File
@@ -1,8 +1,14 @@
from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def list_rows(slug, definition):
def list_rows(slug, definition, player_id=PLAYER_SHARED):
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
conn = connect(slug)
rows = conn.execute(f"SELECT * FROM {definition['table_name']} ORDER BY id DESC").fetchall()
rows = conn.execute(
f"SELECT * FROM {definition['table_name']} WHERE player_id = ? ORDER BY id DESC", (key,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
+10 -2
View File
@@ -1,13 +1,21 @@
from ..connection import connect
from ..slugify import slugify
from ..definitions.get_definition import get_definition
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import PLAYER_SHARED
def relation_options(slug, related_definition_id):
"""Lignes existantes d'un objet lié, pour peupler un <select> de relation."""
"""Lignes existantes d'un objet lié, pour peupler un <select> de
relation — toujours la vue "modèle"/créateur (PLAYER_SHARED), cette
fonction n'est utilisée que côté tableau de bord/éditeur, jamais
depuis la route publique /jouer/<slug>."""
related = get_definition(slug, related_definition_id)
ensure_player_id_column(slug, related["table_name"])
conn = connect(slug)
rows = conn.execute(f"SELECT * FROM {related['table_name']} ORDER BY id").fetchall()
rows = conn.execute(
f"SELECT * FROM {related['table_name']} WHERE player_id = ? ORDER BY id", (PLAYER_SHARED,)
).fetchall()
conn.close()
display_field = related["fields"][0]["name"] if related["fields"] else "id"
display_col = slugify(display_field).replace("-", "_")
+14
View File
@@ -0,0 +1,14 @@
PLAYER_SHARED = "__shared__"
def resolve_row_player_key(definition, player_id):
"""Détermine le player_id à utiliser réellement pour lire/écrire les
lignes de cet objet : celui du joueur (objet per_player=1, le cas par
défaut — voir create_definition.py), ou toujours PLAYER_SHARED (objet
explicitement partagé, per_player=0, ex. un catalogue commun à tous
les joueurs). Contrairement à une variable globale (db/global_vars/
resolve_player_key.py), pas de repli implicite sur la ligne "modèle" :
un objet per_player démarre VIDE pour chaque nouveau joueur, il n'y a
pas de valeur par défaut unique à copier pour une LISTE de lignes."""
per_player = definition.get("per_player", 1)
return player_id if per_player else PLAYER_SHARED
+7 -2
View File
@@ -3,6 +3,8 @@ from ..quote_ident import quote_ident
from ..slugify import slugify
from ..definitions.definitions_referencing import definitions_referencing
from ..definitions.get_definition import get_definition
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import PLAYER_SHARED
def rows_referencing(slug, definition_id, row_id):
@@ -10,16 +12,19 @@ def rows_referencing(slug, definition_id, row_id):
qui a une relation vers l'objet de cette ligne, combien de ses lignes
pointent vers CETTE ligne précise — pour avertir avant une suppression
qui laisserait des relations orphelines (aucune suppression en cascade
n'est faite automatiquement, par sécurité)."""
n'est faite automatiquement, par sécurité). Toujours la vue "modèle"/
créateur (PLAYER_SHARED), utilisée uniquement côté tableau de bord."""
refs = []
for d in definitions_referencing(slug, definition_id):
full = get_definition(slug, d["id"])
for f in full["fields"]:
if f["type"] == "relation" and f["relation_definition_id"] == definition_id:
col = slugify(f["name"]).replace("-", "_") + "_id"
ensure_player_id_column(slug, full["table_name"])
conn = connect(slug)
count = conn.execute(
f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {quote_ident(col)} = ?", (row_id,)
f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {quote_ident(col)} = ? AND player_id = ?",
(row_id, PLAYER_SHARED),
).fetchone()["c"]
conn.close()
if count:
+10 -4
View File
@@ -1,17 +1,23 @@
from ..connection import connect
from ..quote_ident import quote_ident
from .row_columns_and_values import row_columns_and_values
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def update_row(slug, definition, row_id, form_data):
def update_row(slug, definition, row_id, form_data, player_id=PLAYER_SHARED):
"""CRUD — Update : ré-enregistre une ligne existante avec les nouvelles
valeurs saisies dans le même formulaire généré que pour la création."""
valeurs saisies dans le même formulaire généré que pour la création.
Filtre aussi par player_id (voir get_row.py — même garde-fou contre un
row_id d'un autre joueur)."""
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
cols, values = row_columns_and_values(definition, form_data)
conn = connect(slug)
set_clause = ", ".join(f"{quote_ident(c)} = ?" for c in cols)
conn.execute(
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ?",
values + [row_id],
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ? AND player_id = ?",
values + [row_id, key],
)
conn.commit()
conn.close()
+11 -3
View File
@@ -1,16 +1,24 @@
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def update_row_field(slug, definition, row_id, field_def, new_value):
def update_row_field(slug, definition, row_id, field_def, new_value, player_id=PLAYER_SHARED):
"""Met à jour UNE SEULE colonne d'une ligne existante — utilisé par les
actions "Modifier une donnée" du mode jouable, qui ne doivent jamais
écraser les autres champs de la ligne (contrairement à update_row, qui
attend un formulaire complet)."""
attend un formulaire complet). Filtre aussi par player_id (voir
get_row.py)."""
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
fname = slugify(field_def["name"]).replace("-", "_")
col = f"{fname}_id" if field_def["type"] == "relation" else fname
conn = connect(slug)
conn.execute(f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ?", (new_value, row_id))
conn.execute(
f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ? AND player_id = ?",
(new_value, row_id, key),
)
conn.commit()
conn.close()
+6 -4
View File
@@ -3,12 +3,14 @@ import db
from .compute_operation import compute_new_value
def apply_data_action(slug, action):
def apply_data_action(slug, action, player_id=db.PLAYER_SHARED):
"""Exécute au moment du clic (mode jouable) une action "modifier_donnee" :
lit l'objet/la ligne/le champ visés dans l'action, calcule la nouvelle
valeur selon l'opération choisie, et l'enregistre — un vrai UPDATE SQL,
limité à cette seule colonne (les autres champs de la ligne ne sont pas
touchés)."""
touchés). `player_id` (état par joueur, Phase 1) : la ligne visée doit
appartenir à CE joueur pour un objet per_player (voir get_row.py/
update_row_field.py — garde-fou contre un row_id d'un autre joueur)."""
definition_id = action.get("target_definition_id")
row_id = action.get("target_row_id")
field_name = action.get("target_field")
@@ -22,7 +24,7 @@ def apply_data_action(slug, action):
field_def = next((f for f in definition["fields"] if f["name"] == field_name), None)
if not field_def:
return False
row = db.get_row(slug, definition, row_id)
row = db.get_row(slug, definition, row_id, player_id)
if not row:
return False
col = db.slugify(field_def["name"]).replace("-", "_")
@@ -36,7 +38,7 @@ def apply_data_action(slug, action):
return False
new_value = _clamp_to_field_bounds(new_value, field_def)
db.update_row_field(slug, definition, row_id, field_def, new_value)
db.update_row_field(slug, definition, row_id, field_def, new_value, player_id)
return True
@@ -3,18 +3,19 @@ import db
from .compute_operation import compute_new_value
def apply_variable_action(slug, action):
def apply_variable_action(slug, action, player_id=db.PLAYER_SHARED):
"""Exécute au moment du clic (mode jouable) une action
"modifier_variable" — même logique/vocabulaire d'opérations que
apply_data_action.py (DATA_OPERATIONS), appliqué à une variable globale
(une seule valeur pour tout le jeu) plutôt qu'au champ d'une ligne
d'objet."""
plutôt qu'au champ d'une ligne d'objet. `player_id` (état par joueur,
Phase 1) : pour une variable per_player, lit/écrit la valeur propre à
CE joueur (voir db/global_vars/resolve_player_key.py)."""
var_name = action.get("target_variable")
operation = action.get("data_operation")
raw_value = action.get("data_value")
if not (var_name and operation):
return False
variable = db.get_global_variable(slug, var_name)
variable = db.get_global_variable(slug, var_name, player_id)
if not variable:
return False
var_type = variable["type"]
@@ -32,4 +33,4 @@ def apply_variable_action(slug, action):
except ValueError:
return False
return db.update_global_variable_value(slug, var_name, new_value)
return db.update_global_variable_value(slug, var_name, new_value, player_id)
+9 -3
View File
@@ -6,7 +6,7 @@ from ..rendering.render_element_html import render_element_html
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def list_elements(slug, screen_id, enforce_visibility=False):
def list_elements(slug, screen_id, enforce_visibility=False, player_id=db.PLAYER_SHARED):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
@@ -28,7 +28,13 @@ def list_elements(slug, screen_id, enforce_visibility=False):
full_game_payload.py) — applique la "condition de visibilité" de chaque
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
qui doit continuer à montrer/sélectionner un élément même si sa
condition serait actuellement fausse."""
condition serait actuellement fausse.
player_id (état par joueur, Phase 1) : posé dans ctx["_forge_player_id"]
pour tout le rendu en aval (Répéteur/Jauge/Donnée liée/condition de
visibilité) — quel joueur consulte cet écran, pour qu'un objet/une
variable per_player affiche SA valeur (voir db/global_vars/,
db/rows/)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
@@ -74,7 +80,7 @@ def list_elements(slug, screen_id, enforce_visibility=False):
for d in result:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
play_ctx = {"_forge_play_mode": True, "_forge_player_id": player_id} if enforce_visibility else None
for d in result:
# Un exemplaire de premier niveau d'un élément de jeu dont le
# modèle N'EST QU'une superposition : son cadre de positionnement
+21 -11
View File
@@ -8,21 +8,28 @@ from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
def full_game_payload(slug):
def full_game_payload(slug, player_id=db.PLAYER_SHARED):
"""Toutes les données nécessaires au runtime JS de la page de jeu jouable
(/game/<slug>/play) : chaque écran avec ses éléments (HTML déjà généré,
y compris les Répéteurs de données, lus en direct), le graphe de logique
(nœuds + fils) de chaque écran, et un instantané des données du jeu
(avec le type de chaque champ, nécessaire pour évaluer une condition
correctement) — un seul aller-retour serveur, ensuite tout se joue côté
client (aucune navigation ne recharge la page, sauf pour appliquer une
action "modifier une donnée", qui doit passer par le serveur)."""
(/game/<slug>/play, ou /jouer/<slug> — voir routes/public_play/) : chaque
écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de
données, lus en direct), le graphe de logique (nœuds + fils) de chaque
écran, et un instantané des données du jeu (avec le type de chaque
champ, nécessaire pour évaluer une condition correctement) — un seul
aller-retour serveur, ensuite tout se joue côté client (aucune
navigation ne recharge la page, sauf pour appliquer une action "modifier
une donnée", qui doit passer par le serveur).
player_id (état par joueur, Phase 1) : PLAYER_SHARED pour l'aperçu
créateur (comportement inchangé — une seule partie partagée pendant la
conception), l'identifiant réel du visiteur pour une partie publique
(voir core/player_identity.py) — chaque objet/variable per_player
renvoie alors la valeur propre à CE joueur."""
screens_ = list_screens(slug)
payload_screens = []
flows = {}
animations = {}
for s in screens_:
elements = list_elements(slug, s["id"], enforce_visibility=True)
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
payload_screens.append({**s, "elements": elements})
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
@@ -50,7 +57,7 @@ def full_game_payload(slug):
fields_meta = {}
for d in definitions:
full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full)
rows = db.list_rows(slug, full, player_id)
cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]}
data[str(d["id"])] = [
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
@@ -73,7 +80,10 @@ def full_game_payload(slug):
# 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)}
variables = {
v["name"]: {"value": v["value"], "type": v["type"]}
for v in db.list_global_variables_for_player(slug, player_id)
}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
+15 -11
View File
@@ -65,7 +65,7 @@ def _field_column(definition, field_name):
return col
def _resolve_filter_value(slug, raw_value):
def _resolve_filter_value(slug, raw_value, player_id=db.PLAYER_SHARED):
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par
convention, un objet utilisé comme "état de partie" — niveau courant,
@@ -73,14 +73,18 @@ def _resolve_filter_value(slug, raw_value):
par des actions "Modifier une donnée" plutôt que d'en créer une
nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
lire la valeur ACTUELLE de cette variable globale (voir
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe)."""
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe).
`player_id` : état par joueur (Phase 1) — quel joueur consulte, pour
qu'un objet/une variable per_player renvoie SA valeur (voir
resolve_player_key.py/resolve_row_player_key.py, appelés en interne par
get_global_variable/list_rows)."""
if not raw_value:
return raw_value
stripped = raw_value.strip()
var_match = _VAR_REF_PATTERN.match(stripped)
if var_match:
variable = db.get_global_variable(slug, var_match.group(1).strip())
variable = db.get_global_variable(slug, var_match.group(1).strip(), player_id)
if not variable:
return None # variable supprimée depuis : ne matche rien plutôt que planter
return _resolve_variable_path(variable.get("value"), var_match.group(2))
@@ -92,7 +96,7 @@ def _resolve_filter_value(slug, raw_value):
for d in db.list_definitions(slug):
if d["name"] == object_name:
full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full)
rows = db.list_rows(slug, full, player_id)
if rows:
return rows[0].get(_field_column(full, field_name))
return None
@@ -156,18 +160,18 @@ def _compare(actual, operator, expected, field_type):
return False
def _clause_matches(slug, definition, row, clause):
def _clause_matches(slug, definition, row, clause, player_id=db.PLAYER_SHARED):
field_name = (clause.get("champ") or "").strip()
if not field_name:
return True
operator = clause.get("operateur") or "egal"
expected = _resolve_filter_value(slug, clause.get("valeur") or "")
expected = _resolve_filter_value(slug, clause.get("valeur") or "", player_id)
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = _field_column(definition, field_name)
return _compare(row.get(col), operator, expected, field_type)
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator):
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id=db.PLAYER_SHARED):
"""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`
@@ -178,11 +182,11 @@ def filter_rows_by_clauses(slug, definition, rows, clauses, combinator):
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)]
return [r for r in rows if any(_clause_matches(slug, definition, r, c, player_id) for c in clauses)]
return [r for r in rows if all(_clause_matches(slug, definition, r, c, player_id) for c in clauses)]
def filter_repeater_rows(slug, definition, rows, attrs):
def filter_repeater_rows(slug, definition, rows, attrs, player_id=db.PLAYER_SHARED):
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
au(x) filtre(s) optionnel(s) réglé(s) dans ses propriétés (_filtre_champ/
_filtre_operateur/_filtre_valeur, ET, si renseigné, _filtre2_champ/
@@ -192,4 +196,4 @@ def filter_repeater_rows(slug, definition, rows, attrs):
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."""
clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id)
+1 -1
View File
@@ -74,7 +74,7 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
markers += "<!--dataBound-->"
return html_str + markers
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw)
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
+5 -4
View File
@@ -22,7 +22,7 @@ def _interpolate_color(low_hex, high_hex, ratio):
return "#{:02x}{:02x}{:02x}".format(*mixed)
def _read_field_value(slug, definition_id, row_id, field_name):
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
@@ -35,7 +35,7 @@ def _read_field_value(slug, definition_id, row_id, field_name):
definition = db.get_definition(slug, int(definition_id))
if not definition:
return None
rows = db.list_rows(slug, definition)
rows = db.list_rows(slug, definition, player_id)
if not rows:
return None
row = None
@@ -95,7 +95,8 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc
couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
afficher_valeur = attrs.get("_afficher_valeur") == "1"
raw_value = _read_field_value(slug, definition_id, row_id, field_name) if field_name else None
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):
@@ -116,7 +117,7 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc
name_html = ""
if field_name_nom:
nom_value = _read_field_value(slug, definition_id, row_id, 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 = (
+3 -2
View File
@@ -30,8 +30,9 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>'
# (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois
# qu'on sait que l'objet est valide.)
rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, attrs_raw)
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
rows = db.list_rows(slug, definition, player_id)
rows = filter_repeater_rows(slug, definition, rows, attrs_raw, player_id)
field_cols = {f["name"]: _field_column(definition, f["name"]) for f in definition["fields"]}
# Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) :
+3 -3
View File
@@ -4,7 +4,7 @@ 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):
def _resolve_bound_row_ctx(slug, attrs, player_id=db.PLAYER_SHARED):
"""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 conditions
@@ -27,9 +27,9 @@ def _resolve_bound_row_ctx(slug, attrs):
return None
if not definition:
return None
rows = db.list_rows(slug, definition)
rows = db.list_rows(slug, definition, player_id)
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)
rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id)
if not rows:
return None
row = rows[0]
+4 -3
View File
@@ -26,17 +26,18 @@ def is_element_visible(slug, attrs, ctx=None):
pas dans ctx et on retombe sur la convention "1 seule ligne = état de
partie" (déjà utilisée par la Jauge/Donnée liée) : la ligne la plus
récente de l'objet choisi."""
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
mode = attrs.get("_visibilite_cond_mode")
if mode not in ("variable", "objet"):
return True
operator = attrs.get("_visibilite_cond_operateur") or "egal"
expected = _resolve_filter_value(slug, attrs.get("_visibilite_cond_valeur") or "")
expected = _resolve_filter_value(slug, attrs.get("_visibilite_cond_valeur") or "", player_id)
if mode == "variable":
var_name = attrs.get("_visibilite_cond_variable")
if not var_name:
return True
variable = db.get_global_variable(slug, var_name)
variable = db.get_global_variable(slug, var_name, player_id)
if not variable:
return True # variable supprimée depuis : ne bloque jamais l'affichage
value = variable.get("value")
@@ -61,7 +62,7 @@ def is_element_visible(slug, attrs, ctx=None):
if ctx and field_name in ctx:
return _compare(ctx.get(field_name), operator, expected, field_type)
rows = db.list_rows(slug, definition)
rows = db.list_rows(slug, definition, player_id)
if not rows:
return True # objet sans donnée pour l'instant : ne bloque pas
row = rows[0] # convention "état de partie" : une seule ligne, voir data_definition_options.py
+108
View File
@@ -0,0 +1,108 @@
"""Tests de la couche db/ pour l'état par joueur (Phase 1 du plan
d'extension du moteur) : une variable globale/un objet marqué per_player=1
(par défaut) donne une valeur/des lignes indépendantes par joueur ; marqué
per_player=0, la valeur/les lignes restent partagées entre tous les
joueurs. L'aperçu créateur (PLAYER_SHARED, le défaut de tous les
paramètres player_id) doit continuer de fonctionner exactement comme
avant ces changements — voir tests/test_confort.py, test_flow.py etc., qui
n'ont pas eu besoin d'être modifiés."""
import db
def test_global_variable_per_player_gives_independent_values(game):
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
db.update_global_variable_value(game, "score", "99", player_id="bob")
assert db.get_global_variable(game, "score", player_id="alice")["value"] == "10"
assert db.get_global_variable(game, "score", player_id="bob")["value"] == "99"
# Le créateur (PLAYER_SHARED, la ligne "modèle") garde la valeur par
# défaut — jamais écrasée par les joueurs.
assert db.get_global_variable(game, "score")["value"] == "0"
def test_global_variable_new_player_falls_back_to_default_value(game):
db.create_global_variable(game, "vies", "nombre_entier", "3", per_player=True)
assert db.get_global_variable(game, "vies", player_id="charlie")["value"] == "3"
def test_global_variable_shared_ignores_player_id(game):
db.create_global_variable(game, "compteur_visites", "nombre_entier", "0", per_player=False)
db.update_global_variable_value(game, "compteur_visites", "1", player_id="alice")
db.update_global_variable_value(game, "compteur_visites", "2", player_id="bob")
# Même valeur pour tout le monde, y compris un 3e joueur qui n'a jamais écrit.
assert db.get_global_variable(game, "compteur_visites", player_id="alice")["value"] == "2"
assert db.get_global_variable(game, "compteur_visites", player_id="bob")["value"] == "2"
assert db.get_global_variable(game, "compteur_visites", player_id="charlie")["value"] == "2"
def test_global_variable_list_only_shows_template_row(game):
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
variables = db.list_global_variables(game)
assert len(variables) == 1
assert variables[0]["value"] == "0"
def test_global_variable_delete_by_id_cascades_all_players(game):
var_id = db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
db.delete_global_variable_by_id(game, var_id)
assert db.get_global_variable(game, "score", player_id="alice") is None
assert db.get_global_variable(game, "score") is None
def test_object_per_player_gives_independent_rows(game):
definition_id = db.create_definition(
game, "Inventaire", [{"name": "objet", "type": "texte", "required": False}], per_player=True
)
definition = db.get_definition(game, definition_id)
db.insert_row(game, definition, {"objet": "épée"}, player_id="alice")
db.insert_row(game, definition, {"objet": "bouclier"}, player_id="bob")
alice_rows = db.list_rows(game, definition, player_id="alice")
bob_rows = db.list_rows(game, definition, player_id="bob")
assert [r["objet"] for r in alice_rows] == ["épée"]
assert [r["objet"] for r in bob_rows] == ["bouclier"]
# Un nouveau joueur qui n'a encore rien démarre VIDE (pas de valeur par
# défaut à copier pour une LISTE, contrairement à une variable scalaire).
assert db.list_rows(game, definition, player_id="charlie") == []
def test_object_shared_gives_same_rows_to_every_player(game):
definition_id = db.create_definition(
game, "Catalogue", [{"name": "nom", "type": "texte", "required": False}], per_player=False
)
definition = db.get_definition(game, definition_id)
db.insert_row(game, definition, {"nom": "Potion"})
alice_rows = db.list_rows(game, definition, player_id="alice")
bob_rows = db.list_rows(game, definition, player_id="bob")
assert [r["nom"] for r in alice_rows] == ["Potion"]
assert [r["nom"] for r in bob_rows] == ["Potion"]
def test_get_row_does_not_leak_across_players(game):
definition_id = db.create_definition(
game, "Inventaire", [{"name": "objet", "type": "texte", "required": False}], per_player=True
)
definition = db.get_definition(game, definition_id)
row_id = db.insert_row(game, definition, {"objet": "épée"}, player_id="alice")
# Bob ne doit jamais pouvoir lire/modifier/supprimer la ligne d'Alice,
# même en devinant son row_id (garde-fou de sécurité pour la route
# publique /jouer/<slug>).
assert db.get_row(game, definition, row_id, player_id="bob") is None
db.update_row_field(game, definition, row_id, definition["fields"][0], "hache", player_id="bob")
assert db.get_row(game, definition, row_id, player_id="alice")["objet"] == "épée"
db.delete_row(game, definition, row_id, player_id="bob")
assert db.get_row(game, definition, row_id, player_id="alice") is not None
def test_creator_preview_default_matches_shared_definition_behavior(game):
"""L'aperçu créateur (aucun player_id passé nulle part, donc
PLAYER_SHARED partout) doit continuer de fonctionner exactement comme
avant l'état par joueur — un seul appelant, un seul jeu de lignes."""
definition_id = db.create_definition(game, "Notes", [{"name": "texte", "type": "texte", "required": False}])
definition = db.get_definition(game, definition_id)
db.insert_row(game, definition, {"texte": "bonjour"})
rows = db.list_rows(game, definition)
assert [r["texte"] for r in rows] == ["bonjour"]