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
+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()