From 236d6b4b4643f4f93c744a0389b7010b599881ae Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 15:31:08 +0200 Subject: [PATCH 1/6] =?UTF-8?q?Phase=201=20(1/3)=20:=20=C3=A9tat=20par=20j?= =?UTF-8?q?oueur=20=E2=80=94=20couche=20db/=20et=20cha=C3=AEne=20de=20rend?= =?UTF-8?q?u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. 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/, identité visiteur (cookie), bascule "Publier en ligne" dans le tableau de bord. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 6 +- db/definitions/create_definition.py | 17 ++- db/definitions/ensure_field_bounds_schema.py | 10 +- db/global_vars/create_global_variable.py | 30 +++-- db/global_vars/delete_global_variable.py | 3 + .../delete_global_variable_by_id.py | 10 +- db/global_vars/ensure_global_vars_schema.py | 48 +++++++- db/global_vars/get_global_variable.py | 22 ++-- db/global_vars/list_global_variables.py | 11 +- .../list_global_variables_for_player.py | 18 +++ db/global_vars/resolve_player_key.py | 17 +++ .../update_global_variable_value.py | 36 ++++-- db/rows/delete_row.py | 8 +- db/rows/ensure_player_id_column.py | 16 +++ db/rows/get_row.py | 13 ++- db/rows/insert_row.py | 10 +- db/rows/list_rows.py | 10 +- db/rows/relation_options.py | 12 +- db/rows/resolve_row_player_key.py | 14 +++ db/rows/rows_referencing.py | 9 +- db/rows/update_row.py | 14 ++- db/rows/update_row_field.py | 14 ++- screens/data_actions/apply_data_action.py | 10 +- screens/data_actions/apply_variable_action.py | 11 +- screens/elements/list_elements.py | 12 +- screens/payload/full_game_payload.py | 32 ++++-- screens/rendering/filter_repeater_rows.py | 26 +++-- screens/rendering/render_element_html.py | 2 +- screens/rendering/render_jauge.py | 9 +- screens/rendering/render_repeater.py | 5 +- screens/rendering/resolve_bound_row.py | 6 +- screens/rendering/visibility_condition.py | 7 +- tests/test_player_state.py | 108 ++++++++++++++++++ 33 files changed, 469 insertions(+), 107 deletions(-) create mode 100644 db/global_vars/list_global_variables_for_player.py create mode 100644 db/global_vars/resolve_player_key.py create mode 100644 db/rows/ensure_player_id_column.py create mode 100644 db/rows/resolve_row_player_key.py create mode 100644 tests/test_player_state.py diff --git a/db/__init__.py b/db/__init__.py index 6a67b117..a1d375c5 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -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", diff --git a/db/definitions/create_definition.py b/db/definitions/create_definition.py index 5ef56112..82b8461e 100644 --- a/db/definitions/create_definition.py +++ b/db/definitions/create_definition.py @@ -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) diff --git a/db/definitions/ensure_field_bounds_schema.py b/db/definitions/ensure_field_bounds_schema.py index b5a015f7..204ea533 100644 --- a/db/definitions/ensure_field_bounds_schema.py +++ b/db/definitions/ensure_field_bounds_schema.py @@ -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() diff --git a/db/global_vars/create_global_variable.py b/db/global_vars/create_global_variable.py index e2a40c0c..cbce72ca 100644 --- a/db/global_vars/create_global_variable.py +++ b/db/global_vars/create_global_variable.py @@ -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() diff --git a/db/global_vars/delete_global_variable.py b/db/global_vars/delete_global_variable.py index 2525bee3..5bd95b45 100644 --- a/db/global_vars/delete_global_variable.py +++ b/db/global_vars/delete_global_variable.py @@ -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,)) diff --git a/db/global_vars/delete_global_variable_by_id.py b/db/global_vars/delete_global_variable_by_id.py index 8117cbeb..fb9bd827 100644 --- a/db/global_vars/delete_global_variable_by_id.py +++ b/db/global_vars/delete_global_variable_by_id.py @@ -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() diff --git a/db/global_vars/ensure_global_vars_schema.py b/db/global_vars/ensure_global_vars_schema.py index c9a01899..43533774 100644 --- a/db/global_vars/ensure_global_vars_schema.py +++ b/db/global_vars/ensure_global_vars_schema.py @@ -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() diff --git a/db/global_vars/get_global_variable.py b/db/global_vars/get_global_variable.py index e0102a70..f2db00a7 100644 --- a/db/global_vars/get_global_variable.py +++ b/db/global_vars/get_global_variable.py @@ -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 diff --git a/db/global_vars/list_global_variables.py b/db/global_vars/list_global_variables.py index 0bffbe63..7aa7e635 100644 --- a/db/global_vars/list_global_variables.py +++ b/db/global_vars/list_global_variables.py @@ -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] diff --git a/db/global_vars/list_global_variables_for_player.py b/db/global_vars/list_global_variables_for_player.py new file mode 100644 index 00000000..53508b4b --- /dev/null +++ b/db/global_vars/list_global_variables_for_player.py @@ -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 diff --git a/db/global_vars/resolve_player_key.py b/db/global_vars/resolve_player_key.py new file mode 100644 index 00000000..db381bb1 --- /dev/null +++ b/db/global_vars/resolve_player_key.py @@ -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 diff --git a/db/global_vars/update_global_variable_value.py b/db/global_vars/update_global_variable_value.py index a6d85ff3..4360c08a 100644 --- a/db/global_vars/update_global_variable_value.py +++ b/db/global_vars/update_global_variable_value.py @@ -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 diff --git a/db/rows/delete_row.py b/db/rows/delete_row.py index 8f444a74..ca785901 100644 --- a/db/rows/delete_row.py +++ b/db/rows/delete_row.py @@ -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() diff --git a/db/rows/ensure_player_id_column.py b/db/rows/ensure_player_id_column.py new file mode 100644 index 00000000..86798abd --- /dev/null +++ b/db/rows/ensure_player_id_column.py @@ -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() diff --git a/db/rows/get_row.py b/db/rows/get_row.py index 1758bb3a..1405e298 100644 --- a/db/rows/get_row.py +++ b/db/rows/get_row.py @@ -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/, 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 diff --git a/db/rows/insert_row.py b/db/rows/insert_row.py index e6b40e8a..4024c567 100644 --- a/db/rows/insert_row.py +++ b/db/rows/insert_row.py @@ -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 diff --git a/db/rows/list_rows.py b/db/rows/list_rows.py index 78e2fb26..5e7acea0 100644 --- a/db/rows/list_rows.py +++ b/db/rows/list_rows.py @@ -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] diff --git a/db/rows/relation_options.py b/db/rows/relation_options.py index 5a3a9c53..5fa8fadf 100644 --- a/db/rows/relation_options.py +++ b/db/rows/relation_options.py @@ -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 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/.""" 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("-", "_") diff --git a/db/rows/resolve_row_player_key.py b/db/rows/resolve_row_player_key.py new file mode 100644 index 00000000..d8ba2d0f --- /dev/null +++ b/db/rows/resolve_row_player_key.py @@ -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 diff --git a/db/rows/rows_referencing.py b/db/rows/rows_referencing.py index fb7adbce..bb15f9c5 100644 --- a/db/rows/rows_referencing.py +++ b/db/rows/rows_referencing.py @@ -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: diff --git a/db/rows/update_row.py b/db/rows/update_row.py index a1d71bdd..55f7da63 100644 --- a/db/rows/update_row.py +++ b/db/rows/update_row.py @@ -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() diff --git a/db/rows/update_row_field.py b/db/rows/update_row_field.py index 477bef11..762449a3 100644 --- a/db/rows/update_row_field.py +++ b/db/rows/update_row_field.py @@ -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() diff --git a/screens/data_actions/apply_data_action.py b/screens/data_actions/apply_data_action.py index 0d37ecfb..61483173 100644 --- a/screens/data_actions/apply_data_action.py +++ b/screens/data_actions/apply_data_action.py @@ -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 diff --git a/screens/data_actions/apply_variable_action.py b/screens/data_actions/apply_variable_action.py index 0342ddbd..2d7d808b 100644 --- a/screens/data_actions/apply_variable_action.py +++ b/screens/data_actions/apply_variable_action.py @@ -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) diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py index f047648c..38befc29 100644 --- a/screens/elements/list_elements.py +++ b/screens/elements/list_elements.py @@ -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 diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index cad48553..913b757d 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -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//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//play, ou /jouer/ — 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, diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index 8b0d025a..ddd25f84 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -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) diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index 33780232..65a00e74 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -74,7 +74,7 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_ markers += "" 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 diff --git a/screens/rendering/render_jauge.py b/screens/rendering/render_jauge.py index 4fd09b85..9cc27cb8 100644 --- a/screens/rendering/render_jauge.py +++ b/screens/rendering/render_jauge.py @@ -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 = ( diff --git a/screens/rendering/render_repeater.py b/screens/rendering/render_repeater.py index 631cbb7e..2f180267 100644 --- a/screens/rendering/render_repeater.py +++ b/screens/rendering/render_repeater.py @@ -30,8 +30,9 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi return f'
Objet introuvable — a-t-il été supprimé ?
' # (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) : diff --git a/screens/rendering/resolve_bound_row.py b/screens/rendering/resolve_bound_row.py index 79b9e37a..2f3105f7 100644 --- a/screens/rendering/resolve_bound_row.py +++ b/screens/rendering/resolve_bound_row.py @@ -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] diff --git a/screens/rendering/visibility_condition.py b/screens/rendering/visibility_condition.py index f5921a82..fec59bce 100644 --- a/screens/rendering/visibility_condition.py +++ b/screens/rendering/visibility_condition.py @@ -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 diff --git a/tests/test_player_state.py b/tests/test_player_state.py new file mode 100644 index 00000000..9ba385b4 --- /dev/null +++ b/tests/test_player_state.py @@ -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/). + 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"] -- 2.54.0 From 3c39f1a24937bc69a4478560dede3687952f87c2 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 15:46:00 +0200 Subject: [PATCH 2/6] =?UTF-8?q?Phase=201=20(2/3)=20:=20route=20publique=20?= =?UTF-8?q?/jouer/=20+=20identit=C3=A9=20visiteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la vraie route hébergée multi-joueurs qui manquait totalement (voir le constat d'exploration : /game//play est réservé au créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) — un visiteur anonyme peut maintenant jouer un jeu explicitement publié en ligne, avec sa propre partie (variables/objets per_player posés dans le commit précédent). core/player_identity.py : identité visiteur via un cookie NON SIGNÉ (forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de partition, jamais un jeton d'autorisation. routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu créateur mais threadées avec le vrai player_id du cookie au lieu du sentinel PLAYER_SHARED : - GET /jouer/ (game_play_public.py) - GET /jouer//runtime-payload - POST /jouer//flow/nodes//run-data - POST /jouer//flow/nodes//run-variable Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu n'est exposé publiquement que si le créateur l'a explicitement basculé "Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la table générique _meta, comme game_meta.py pour 'name'). core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS — la garde générique de connexion les laisse passer sans session, mais chaque vue vérifie quand même is_public_played elle-même (défense en profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py) n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask, qui existe pour n'importe quel visiteur (connecté ou non). templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload', ...)) mais reçoit des URLs déjà résolues par la route elle-même (runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url) — nécessaire puisque ce même template sert maintenant DEUX familles de routes (aperçu créateur ET partie publique), chacune avec ses propres noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ comportement) et game_play_public.py passent chacun ses propres URLs. templates/base.html : bascule "🌐 Publier en ligne" dans la barre de navigation du jeu, à côté de "📦 Publier" (export .zip) — deux fonctionnalités distinctes. db/games/game_meta.py expose maintenant is_public_played, disponible partout où `game` est dans le contexte. Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux cookies forge_player_id différents — qui obtiennent des valeurs de variable indépendantes, et un qui verrouille que l'aperçu créateur reste inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu (aperçu créateur et partie publique). Co-Authored-By: Claude Sonnet 5 --- core/auth_guard.py | 9 ++ core/player_identity.py | 35 ++++++ db/__init__.py | 3 + db/games/game_meta.py | 10 +- db/games/is_public_played.py | 14 +++ db/games/set_public_played.py | 14 +++ routes/__init__.py | 2 +- routes/games/__init__.py | 2 +- routes/games/game_toggle_public_played.py | 16 +++ routes/play/game_play.py | 9 +- routes/public_play/__init__.py | 1 + .../public_play/flow_node_run_data_public.py | 29 +++++ .../flow_node_run_variable_public.py | 25 ++++ routes/public_play/game_play_public.py | 36 ++++++ routes/public_play/runtime_payload_public.py | 20 +++ templates/base.html | 16 ++- templates/play.html | 14 ++- tests/test_public_play.py | 116 ++++++++++++++++++ 18 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 core/player_identity.py create mode 100644 db/games/is_public_played.py create mode 100644 db/games/set_public_played.py create mode 100644 routes/games/game_toggle_public_played.py create mode 100644 routes/public_play/__init__.py create mode 100644 routes/public_play/flow_node_run_data_public.py create mode 100644 routes/public_play/flow_node_run_variable_public.py create mode 100644 routes/public_play/game_play_public.py create mode 100644 routes/public_play/runtime_payload_public.py create mode 100644 tests/test_public_play.py diff --git a/core/auth_guard.py b/core/auth_guard.py index 949629c2..126f1b11 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -19,9 +19,18 @@ from .flask_app import app # Endpoints accessibles SANS être connecté — tout le reste exige une # session valide. "static" (CSS/JS/images) doit rester public : la page # de connexion elle-même en a besoin pour s'afficher. +# +# game_play_public/runtime_payload_public/flow_node_run_data_public/ +# flow_node_run_variable_public (routes/public_play/) : la route PUBLIQUE +# /jouer/, pour un vrai visiteur anonyme (état par joueur, Phase 1) +# — chaque vue vérifie ELLE-MÊME db.is_public_played(slug) (404 sinon), +# cette garde générique ne fait qu'autoriser l'accès sans connexion, elle +# ne dispense d'aucune autre vérification. _PUBLIC_ENDPOINTS = { "static", "login", "login_2fa", "register", "register_2fa", "logout", "forgot_password", "reset_password", + "game_play_public", "runtime_payload_public", + "flow_node_run_data_public", "flow_node_run_variable_public", } diff --git a/core/player_identity.py b/core/player_identity.py new file mode 100644 index 00000000..d1639c71 --- /dev/null +++ b/core/player_identity.py @@ -0,0 +1,35 @@ +"""Identité d'un visiteur anonyme de la route publique /jouer/ (voir +routes/public_play/) — état par joueur, Phase 1. Un cookie NON SIGNÉ (pas +besoin de itsdangerous/app.secret_key ici) : c'est une simple clé de +PARTITION des données (quelles lignes/valeurs appartiennent à ce visiteur), +jamais un jeton d'autorisation — la contrefaçon la plus grave possible +serait de se faire passer pour un AUTRE joueur anonyme du même jeu, pas de +franchir une frontière de sécurité (l'aperçu créateur, lui, reste protégé +par core/auth_guard.py, complètement indépendant de ce mécanisme).""" +import secrets + +from flask import request + +PLAYER_ID_COOKIE = "forge_player_id" +_PLAYER_ID_MAX_AGE = 60 * 60 * 24 * 365 # 1 an + + +def get_or_create_player_id(): + """Renvoie (player_id, is_new) — is_new indique s'il faut poser le + cookie sur la réponse avant de la renvoyer (voir + set_player_id_cookie ci-dessous) : à utiliser aussi bien depuis une + route qui rend une page (GET /jouer/) qu'une route API JSON + (runtime-payload, run-data, run-variable), dont la réponse n'est + construite qu'APRÈS avoir déjà besoin du player_id pour agir.""" + existing = request.cookies.get(PLAYER_ID_COOKIE) + if existing: + return existing, False + return secrets.token_urlsafe(16), True + + +def set_player_id_cookie(response, player_id): + response.set_cookie( + PLAYER_ID_COOKIE, player_id, + max_age=_PLAYER_ID_MAX_AGE, httponly=True, samesite="Lax", + ) + return response diff --git a/db/__init__.py b/db/__init__.py index a1d375c5..41be883d 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -29,6 +29,8 @@ from .connection import connect from .games.list_games import list_games from .games.game_meta import game_meta +from .games.is_public_played import is_public_played +from .games.set_public_played import set_public_played from .games.create_game import create_game from .games.update_game_name import update_game_name from .games.delete_game import delete_game @@ -72,6 +74,7 @@ __all__ = [ "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", + "is_public_played", "set_public_played", "list_definitions", "get_definition", "create_definition", "rename_definition", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "delete_definition", diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 3d806029..ab6de535 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -1,8 +1,16 @@ from ..connection import connect +from .is_public_played import is_public_played def game_meta(slug): conn = connect(slug) row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() conn.close() - return {"slug": slug, "name": row["value"] if row else slug} + return { + "slug": slug, "name": row["value"] if row else slug, + # État par joueur (Phase 1) : disponible partout où `game` est + # dans le contexte du template (base.html, la bascule "Publier en + # ligne" en a besoin pour afficher son état actuel), sans avoir à + # appeler is_public_played(slug) séparément dans chaque route. + "is_public_played": is_public_played(slug), + } diff --git a/db/games/is_public_played.py b/db/games/is_public_played.py new file mode 100644 index 00000000..32064302 --- /dev/null +++ b/db/games/is_public_played.py @@ -0,0 +1,14 @@ +from ..connection import connect + + +def is_public_played(slug): + """Un jeu n'est jouable sur la route publique /jouer/ (voir + routes/public_play/) que si le créateur l'a explicitement basculé + "Publier en ligne" dans son tableau de bord — jamais par défaut, pour + ne pas exposer un jeu en cours de conception. Réutilise la table + générique _meta (voir game_meta.py, déjà utilisée pour 'name'), + aucune nouvelle colonne/table nécessaire.""" + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'is_public_played'").fetchone() + conn.close() + return bool(row and row["value"] == "1") diff --git a/db/games/set_public_played.py b/db/games/set_public_played.py new file mode 100644 index 00000000..2842e47f --- /dev/null +++ b/db/games/set_public_played.py @@ -0,0 +1,14 @@ +from ..connection import connect + + +def set_public_played(slug, value): + """Bascule "Publier en ligne" (tableau de bord) — voir + is_public_played.py. `value` : bool.""" + conn = connect(slug) + conn.execute( + "INSERT INTO _meta (key, value) VALUES ('is_public_played', ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ("1" if value else "0",), + ) + conn.commit() + conn.close() diff --git a/routes/__init__.py b/routes/__init__.py index 2e9d945f..eab6058d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play diff --git a/routes/games/__init__.py b/routes/games/__init__.py index 471a48c9..bfc0d253 100644 --- a/routes/games/__init__.py +++ b/routes/games/__init__.py @@ -1 +1 @@ -from . import index, games_new, game_dashboard, game_rename, game_delete +from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played diff --git a/routes/games/game_toggle_public_played.py b/routes/games/game_toggle_public_played.py new file mode 100644 index 00000000..a50e7d5b --- /dev/null +++ b/routes/games/game_toggle_public_played.py @@ -0,0 +1,16 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//toggle-public-played", methods=["POST"]) +def game_toggle_public_played(slug): + """Bascule "Publier en ligne" (voir db/games/is_public_played.py) — + active/désactive la route publique /jouer/ (routes/public_play/), + jouable par n'importe quel visiteur anonyme avec sa propre partie + (état par joueur, Phase 1). Distinct de "Publier" (export .zip + exécutable mono-joueur, voir routes/publish/publish_game.py).""" + db.set_public_played(slug, request.form.get("value") == "1") + return redirect(request.referrer or url_for("game_dashboard", slug=slug)) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index 98c73c30..e677703e 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -1,6 +1,6 @@ import json -from flask import render_template +from flask import render_template, url_for import db import screens @@ -10,8 +10,15 @@ from core.flask_app import app @app.route("/game//play") def game_play(slug): + """Aperçu créateur (connecté, voir core/auth_guard.py) — une seule + partie partagée (PLAYER_SHARED, le défaut de full_game_payload), comme + avant l'état par joueur (Phase 1). Distincte de /jouer/ + (routes/public_play/), la vraie route publique par joueur.""" game = db.game_meta(slug) payload = screens.full_game_payload(slug) return render_template( "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + runtime_payload_url=url_for("runtime_payload", slug=slug), + flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999), + flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py new file mode 100644 index 00000000..8d21da09 --- /dev/null +++ b/routes/public_play/__init__.py @@ -0,0 +1 @@ +from . import game_play_public, runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public diff --git a/routes/public_play/flow_node_run_data_public.py b/routes/public_play/flow_node_run_data_public.py new file mode 100644 index 00000000..08576fd9 --- /dev/null +++ b/routes/public_play/flow_node_run_data_public.py @@ -0,0 +1,29 @@ +from flask import jsonify, request, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-data", methods=["POST"]) +def flow_node_run_data_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_data.py (aperçu créateur) + — voir routes/public_play/game_play_public.py pour le principe + général. `player_id` (le cookie du visiteur) est passé à + apply_data_action pour qu'un objet per_player agisse sur SA ligne.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": + return jsonify({"ok": False}), 400 + if node.get("target_row_id") == screens.CLICKED_ROW_ID: + body = request.get_json(silent=True) or {} + node = {**node, "target_row_id": body.get("clicked_row_id")} + ok = screens.apply_data_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": ok})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/flow_node_run_variable_public.py b/routes/public_play/flow_node_run_variable_public.py new file mode 100644 index 00000000..cb0ad632 --- /dev/null +++ b/routes/public_play/flow_node_run_variable_public.py @@ -0,0 +1,25 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-variable", methods=["POST"]) +def flow_node_run_variable_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_variable.py (aperçu + créateur) — voir routes/public_play/game_play_public.py pour le + principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable": + return jsonify({"ok": False}), 400 + ok = screens.apply_variable_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": ok})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py new file mode 100644 index 00000000..6381a5e1 --- /dev/null +++ b/routes/public_play/game_play_public.py @@ -0,0 +1,36 @@ +import json + +from flask import render_template, url_for, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer/") +def game_play_public(slug): + """Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) — + distincte de /game//play (aperçu créateur, connecté, INCHANGÉE) : + un vrai visiteur anonyme, identifié par un cookie (voir + core/player_identity.py), avec sa propre partie (variables/objets + per_player, voir db/global_vars/, db/rows/). 404 si le créateur n'a + pas explicitement basculé "Publier en ligne" dans son tableau de bord + — jamais exposé par défaut.""" + if not db.is_public_played(slug): + abort(404) + game = db.game_meta(slug) + player_id, is_new = get_or_create_player_id() + payload = screens.full_game_payload(slug, player_id) + resp = app.make_response( + render_template( + "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + runtime_payload_url=url_for("runtime_payload_public", slug=slug), + flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999), + flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999), + ) + ) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/runtime_payload_public.py b/routes/public_play/runtime_payload_public.py new file mode 100644 index 00000000..867706ea --- /dev/null +++ b/routes/public_play/runtime_payload_public.py @@ -0,0 +1,20 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//runtime-payload") +def runtime_payload_public(slug): + """Miroir public de routes/flow/runtime_payload.py (aperçu créateur) — + voir routes/public_play/game_play_public.py pour le principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + resp = app.make_response(jsonify(screens.full_game_payload(slug, player_id))) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/templates/base.html b/templates/base.html index 53f8de58..2beab4f6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -49,8 +49,22 @@ jouable dans un nouvel onglet), pas une page de gestion de plus. --> {% endif %} diff --git a/templates/play.html b/templates/play.html index f1c2427a..15c2161c 100644 --- a/templates/play.html +++ b/templates/play.html @@ -79,13 +79,19 @@ // le JS), qui ne peuvent pas contenir de code Jinja puisqu'ils ne // passent jamais par le moteur de templates. FORGE_PLAY_URLS porte donc // les URLs de routes dont ces fichiers ont besoin (actions.js, - // bindings.js), construites ici une seule fois. + // bindings.js), construites ici une seule fois — passées par la route + // elle-même (runtime_payload_url/flow_node_run_data_url/ + // flow_node_run_variable_url) plutôt que construites ici avec des noms + // de endpoint fixes : ce même template sert aussi bien l'aperçu + // créateur (/game//play, routes/play/game_play.py) que la partie + // publique par joueur (/jouer/, routes/public_play/), deux + // familles de routes distinctes (voir état par joueur, Phase 1). let gameData = {{ payload_json|safe }}; let screensData = gameData.screens; var FORGE_PLAY_URLS = { - runtimePayload: {{ url_for('runtime_payload', slug=game.slug)|tojson }}, - flowNodeRunData: {{ url_for('flow_node_run_data', slug=game.slug, node_id=999999)|tojson }}, - flowNodeRunVariable: {{ url_for('flow_node_run_variable', slug=game.slug, node_id=999999)|tojson }}, + runtimePayload: {{ runtime_payload_url|tojson }}, + flowNodeRunData: {{ flow_node_run_data_url|tojson }}, + flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }}, }; diff --git a/tests/test_public_play.py b/tests/test_public_play.py new file mode 100644 index 00000000..2ebcec8b --- /dev/null +++ b/tests/test_public_play.py @@ -0,0 +1,116 @@ +"""Tests de la route publique /jouer/ (routes/public_play/) — état +par joueur, Phase 1. Contrairement aux autres tests, ceux-ci utilisent +DIRECTEMENT flask_app.test_client() (pas la fixture `client`) pour de +vrais visiteurs anonymes, jamais connectés — le but est justement de +vérifier que core/auth_guard.py laisse passer ces routes sans session.""" +import json +import re + +import db + +from conftest import flask_app + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def _add_flow_node(client, slug, screen_id, **fields): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps(fields), + content_type="application/json", + ) + assert resp.status_code == 200 + return resp.get_json()["id"] + + +def test_public_route_404_until_explicitly_published(client, game): + anon = flask_app.test_client() + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 404 + + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 200 + + client.post(f"/game/{game}/toggle-public-played", data={"value": "0"}) + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 404 + + +def test_public_route_works_without_any_login_session(client, game): + """La garde globale (core/auth_guard.py) ne doit PAS exiger de session + pour cette route — c'est tout le sens de "publique".""" + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + anon = flask_app.test_client() + with anon.session_transaction() as sess: + assert "user_id" not in sess + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 200 + assert b"CSRF_TOKEN" in resp.data or b"gameData" in resp.data + + +def test_public_route_sets_player_id_cookie(client, game): + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + anon = flask_app.test_client() + resp = anon.get(f"/jouer/{game}") + assert "forge_player_id" in resp.headers.get("Set-Cookie", "") + + +def test_two_anonymous_players_get_independent_variable_state(client, game): + """Bout en bout, via HTTP : deux visiteurs anonymes (deux clients de + test SÉPARÉS, donc deux cookies forge_player_id différents) sur le + même jeu publié -> chacun sa propre valeur pour une variable + per_player, modifiée via l'action "Modifier une variable" (le vrai + chemin d'exécution du mode jouable, routes/public_play/ + flow_node_run_variable_public.py).""" + db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True) + screen_id = _create_screen(client, game) + node_id = _add_flow_node( + client, game, screen_id, + node_type="action", action_type="modifier_variable", + target_variable="score", data_operation="incrementer", data_value="10", + ) + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + + alice = flask_app.test_client() + alice.get(f"/jouer/{game}") # pose le cookie + resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + assert resp.get_json()["ok"] is True + + bob = flask_app.test_client() + bob.get(f"/jouer/{game}") + resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + assert resp.get_json()["ok"] is True + + assert db.get_global_variable(game, "score", player_id="__shared__")["value"] == "0" + + alice_payload = alice.get(f"/jouer/{game}/runtime-payload").get_json() + bob_payload = bob.get(f"/jouer/{game}/runtime-payload").get_json() + assert alice_payload["variables"]["score"]["value"] == "10" + assert bob_payload["variables"]["score"]["value"] == "20" + + +def test_creator_preview_unaffected_by_public_players(client, game): + """L'aperçu créateur (/game//play, connecté) doit continuer de + voir/modifier PLAYER_SHARED, jamais l'état d'un joueur public.""" + db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True) + screen_id = _create_screen(client, game) + node_id = _add_flow_node( + client, game, screen_id, + node_type="action", action_type="modifier_variable", + target_variable="score", data_operation="incrementer", data_value="10", + ) + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + + alice = flask_app.test_client() + alice.get(f"/jouer/{game}") + alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + + resp = client.get(f"/game/{game}/play") + assert resp.status_code == 200 + creator_payload = client.get(f"/game/{game}/runtime-payload").get_json() + assert creator_payload["variables"]["score"]["value"] == "0" -- 2.54.0 From 4585a72588a8dbde39030765948d294187f93259 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 15:54:37 +0200 Subject: [PATCH 3/6] Phase 1 (3/3) : bascule "par joueur" dans le tableau de bord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dernier morceau du plan d'état par joueur : le créateur choisit, à la création d'une variable globale ou d'un objet, si chaque joueur aura sa propre valeur/ses propres lignes (coché par défaut) ou si elle est explicitement PARTAGÉE par tous les joueurs (ex. un compteur de visiteurs global, un catalogue commun) — voir db/global_vars/create_global_variable.py et db/definitions/create_definition.py (Phase 1, 1er commit). routes/global_vars/create_global_var.py, routes/objects/object_new.py : lisent la case à cocher "per_player" du formulaire (absente => reste per_player=1, comportement par défaut). templates/game_dashboard.html : case à cocher sur les deux panneaux de création + colonne "Par joueur" dans les deux tableaux existants, pour que ce réglage (immuable après création, comme le nom d'une variable) reste visible. db/definitions/list_definitions.py appelait _definitions directement sans jamais migrer son schéma — un tableau de bord ouvert avant la toute première création/modification d'objet aurait affiché "Non — partagé" pour un objet en réalité per_player=1 (colonne absente => Undefined, donc faux en Jinja) : corrigé en appelant ensure_field_bounds_schema() ici aussi, comme le fait déjà create_definition.py/get_definition.py. Vérifié : 239 tests passent (2 nouveaux, dont un qui aurait détecté le bug ci-dessus). Phase 1 (état par joueur) est maintenant complète : couche db/, route publique /jouer/, et ce réglage créateur. Co-Authored-By: Claude Sonnet 5 --- db/definitions/list_definitions.py | 2 ++ routes/global_vars/create_global_var.py | 7 ++++++- routes/objects/object_new.py | 7 ++++++- templates/game_dashboard.html | 25 +++++++++++++++++++++++-- tests/test_player_state.py | 23 +++++++++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/db/definitions/list_definitions.py b/db/definitions/list_definitions.py index ed28d9fd..526d501e 100644 --- a/db/definitions/list_definitions.py +++ b/db/definitions/list_definitions.py @@ -1,7 +1,9 @@ from ..connection import connect +from .ensure_field_bounds_schema import ensure_field_bounds_schema def list_definitions(slug): + ensure_field_bounds_schema(slug) conn = connect(slug) rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall() conn.close() diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py index 4f50a41f..37158678 100644 --- a/routes/global_vars/create_global_var.py +++ b/routes/global_vars/create_global_var.py @@ -13,6 +13,11 @@ def create_global_var(slug): name = (request.form.get("name") or "").strip() var_type = request.form.get("type") or "texte" default_value = request.form.get("default_value") or "" + # État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE + # explicitement rend la variable partagée par tous les joueurs, voir + # db/global_vars/create_global_variable.py) — absent du formulaire + # (jeux/pages qui n'ont pas encore ce champ) => reste per_player. + per_player = request.form.get("per_player", "1") == "1" if name: - db.create_global_variable(slug, name, var_type, default_value) + db.create_global_variable(slug, name, var_type, default_value, per_player=per_player) return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/objects/object_new.py b/routes/objects/object_new.py index 98c0bbc0..2a76a90c 100644 --- a/routes/objects/object_new.py +++ b/routes/objects/object_new.py @@ -23,8 +23,13 @@ def object_new(slug): # message d'erreur, juste un retour au tableau de bord) dès qu'on # tapait le nom sans avoir encore cliqué "+ Ajouter un champ". fields = _parse_field_rows(request.form) + # État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE + # explicitement rend l'objet partagé par tous les joueurs — ex. un + # catalogue/une liste de questions — voir db/definitions/ + # create_definition.py). + per_player = request.form.get("per_player", "1") == "1" if name: - definition_id = db.create_definition(slug, name, fields) + definition_id = db.create_definition(slug, name, fields, per_player=per_player) return redirect(url_for("object_view", slug=slug, definition_id=definition_id)) return redirect(url_for("game_dashboard", slug=slug)) diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index 545abb84..b1f0ed36 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -76,12 +76,13 @@ {% if definitions %}
- + {% for d in definitions %} + @@ -158,6 +159,15 @@ + + {% if variables %} @@ -166,7 +176,7 @@ {% endfor %}
NomTable SQL
NomTable SQLPar joueur
{{ d.table_name }}{{ 'Oui' if d.per_player else 'Non — partagé' }}
- + {% for v in variables %} @@ -188,6 +198,7 @@ {% endif %} +
NomTypeValeur
NomTypeValeurPar joueur
{{ 'Oui' if v.per_player else 'Non — partagée' }}
+ + +

Champs

diff --git a/tests/test_player_state.py b/tests/test_player_state.py index 9ba385b4..0034de7a 100644 --- a/tests/test_player_state.py +++ b/tests/test_player_state.py @@ -97,6 +97,29 @@ def test_get_row_does_not_leak_across_players(game): assert db.get_row(game, definition, row_id, player_id="alice") is not None +def test_create_global_var_route_respects_per_player_checkbox(client, game): + client.post(f"/game/{game}/variables/new", data={ + "name": "score", "type": "nombre_entier", "default_value": "0", + }) + assert db.list_global_variables(game)[0]["per_player"] == 1 + + client.post(f"/game/{game}/variables/new", data={ + "name": "compteur_visites", "type": "nombre_entier", "default_value": "0", "per_player": "0", + }) + variables = {v["name"]: v for v in db.list_global_variables(game)} + assert variables["compteur_visites"]["per_player"] == 0 + + +def test_object_new_route_respects_per_player_checkbox(client, game): + client.post(f"/game/{game}/objects/new", data={"object_name": "Inventaire"}) + definitions = {d["name"]: d for d in db.list_definitions(game)} + assert definitions["Inventaire"]["per_player"] == 1 + + client.post(f"/game/{game}/objects/new", data={"object_name": "Catalogue", "per_player": "0"}) + definitions = {d["name"]: d for d in db.list_definitions(game)} + assert definitions["Catalogue"]["per_player"] == 0 + + 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 -- 2.54.0 From 8e8a159e884cf03474d541e572b00236c40595b5 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 16:04:10 +0200 Subject: [PATCH 4/6] =?UTF-8?q?Corrige=20la=20CI=20:=20les=20jobs=20de=20t?= =?UTF-8?q?est=20tournent=20dans=20"container"=20au=20lieu=20d'un=20docker?= =?UTF-8?q?=20run=20imbriqu=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job "test" échouait ("Could not open requirements file: requirements-dev.txt") : docker run -v "$PWD":/app lancé DEPUIS un runner qui exécute déjà le job dans son propre conteneur (Docker- outside-of-Docker) ne peut pas monter "$PWD" — ce chemin vit dans le conteneur du job, pas sur l'hôte où tourne le vrai démon Docker sollicité par ce docker run imbriqué ; /app se retrouvait donc vide dans le conteneur imbriqué. Corrigé en utilisant la clé "container" (standard Gitea/GitHub Actions) : le job tourne DIRECTEMENT dans l'image voulue, le checkout dépose les fichiers dans son propre système de fichiers, aucun montage de volume à faire. Un seul job "test" ne peut avoir qu'UNE image : scindé en test-python (python:3.13-slim) et test-js (node:20-slim, pour les tests node:test de static/js/play/__tests__/), build-and-push dépend des deux. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/deploy.yml | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8eeb6b7b..07e848c1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -26,31 +26,37 @@ on: # github.com / du miroir gitea.com depuis le runner self-hosted. jobs: - test: + # Le job tourne DIRECTEMENT dans le conteneur (clé "container", standard + # Gitea/GitHub Actions) plutôt que de lancer un `docker run` imbriqué + # depuis le runner — un essai précédent (docker run -v "$PWD":/app ...) + # échouait avec "Could not open requirements file" : sur un runner qui + # exécute déjà le job dans un conteneur (Docker-outside-of-Docker), "$PWD" + # ne correspond à aucun chemin que le démon Docker de l'HÔTE peut monter, + # /app se retrouvait donc vide dans le conteneur imbriqué. Avec "container", + # le checkout dépose directement les fichiers dans le système de fichiers + # du conteneur du job, aucun montage de volume à faire soi-même. + test-python: runs-on: ubuntu-latest + container: + image: python:3.13-slim steps: - uses: actions/checkout@v4 - - # Conteneurs officiels plutôt que actions/setup-python|setup-node du - # marketplace — même logique que le reste de ce fichier (éviter une - # dépendance à la disponibilité de github.com/du miroir gitea.com - # depuis le runner self-hosted), et le runner n'a pas besoin - # d'installer quoi que ce soit d'autre que Docker, déjà requis pour - # build-and-push. + - name: Installer les dépendances Python (dev) + run: pip install --quiet -r requirements-dev.txt - name: Tests Python (pytest) - run: | - docker run --rm -v "$PWD":/app -w /app python:3.13-slim sh -c " - pip install --quiet -r requirements-dev.txt && - python -m pytest tests/ -q - " + run: python -m pytest tests/ -q + test-js: + runs-on: ubuntu-latest + container: + image: node:20-slim + steps: + - uses: actions/checkout@v4 - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) - run: | - docker run --rm -v "$PWD":/app -w /app node:20-slim \ - node --test static/js/play/__tests__/*.test.js + run: node --test static/js/play/__tests__/*.test.js build-and-push: - needs: test + needs: [test-python, test-js] if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: -- 2.54.0 From d474303a55aba72e005e3b5c2edd2499114f2144 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 16:08:59 +0200 Subject: [PATCH 5/6] =?UTF-8?q?Corrige=20la=20CI=20(suite)=20:=20ex=C3=A9c?= =?UTF-8?q?ute=20les=20tests=20pendant=20un=20docker=20build,=20pas=20dans?= =?UTF-8?q?=20un=20container=20de=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "container: image: python:3.13-slim" (tentative précédente) casse actions/checkout@v4 : c'est une action Node.js, qui a besoin de Node dans l'environnement d'exécution des steps — "container:" remplace CET environnement en entier par l'image donnée, qui n'a pas Node ("command not found", nektos/act#107), pas seulement l'environnement des commandes qu'on y lance soi-même. Nouvelle approche : le job tourne sur le runner par défaut (checkout fonctionne normalement, Node y est déjà disponible), et les tests s'exécutent PENDANT un `docker build` (Dockerfile jetable passé par stdin, jamais commité, un par langage) plutôt que dans un conteneur lancé après coup — le transfert du contexte de build vers le démon Docker passe par le protocole API (tar), jamais par un chemin hôte à monter, donc insensible au problème Docker-outside-of-Docker qui avait fait échouer le tout premier essai (docker run -v "$PWD":/app). Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/deploy.yml | 51 ++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 07e848c1..367d88e7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -26,34 +26,49 @@ on: # github.com / du miroir gitea.com depuis le runner self-hosted. jobs: - # Le job tourne DIRECTEMENT dans le conteneur (clé "container", standard - # Gitea/GitHub Actions) plutôt que de lancer un `docker run` imbriqué - # depuis le runner — un essai précédent (docker run -v "$PWD":/app ...) - # échouait avec "Could not open requirements file" : sur un runner qui - # exécute déjà le job dans un conteneur (Docker-outside-of-Docker), "$PWD" - # ne correspond à aucun chemin que le démon Docker de l'HÔTE peut monter, - # /app se retrouvait donc vide dans le conteneur imbriqué. Avec "container", - # le checkout dépose directement les fichiers dans le système de fichiers - # du conteneur du job, aucun montage de volume à faire soi-même. + # Deux essais précédents ont échoué : + # 1. docker run -v "$PWD":/app ... imbriqué depuis le runner — sur un + # runner qui exécute déjà le job dans son propre conteneur (Docker- + # outside-of-Docker), "$PWD" ne correspond à aucun chemin que le démon + # Docker de l'HÔTE peut monter : /app se retrouvait vide. + # 2. jobs.test.container: image: python:3.13-slim — actions/checkout@v4 + # (une action Node.js) a alors besoin de Node dans CE conteneur pour + # s'exécuter, qui ne l'a pas ("command not found", voir + # nektos/act#107) : `container:` remplace tout l'environnement des + # steps, pas seulement celui des commandes qu'on y lance soi-même. + # Solution : le job tourne sur le runner PAR DÉFAUT (checkout fonctionne, + # Node y est déjà disponible), et les tests s'exécutent PENDANT un + # `docker build` (Dockerfile passé par stdin, jamais commité) — le + # transfert du contexte de build au démon Docker ne dépend JAMAIS d'un + # chemin hôte/montage de volume (protocole API, un tar envoyé tel quel), + # donc insensible au problème Docker-outside-of-Docker du point 1. test-python: runs-on: ubuntu-latest - container: - image: python:3.13-slim steps: - uses: actions/checkout@v4 - - name: Installer les dépendances Python (dev) - run: pip install --quiet -r requirements-dev.txt - - name: Tests Python (pytest) - run: python -m pytest tests/ -q + - name: Tests Python (pytest, exécutés PENDANT le build d'une image jetable) + run: | + docker build -f - -t forge-test-python:${{ gitea.sha }} . <<'DOCKERFILE' + FROM python:3.13-slim + WORKDIR /app + COPY requirements.txt requirements-dev.txt ./ + RUN pip install --no-cache-dir -r requirements-dev.txt + COPY . . + RUN python -m pytest tests/ -q + DOCKERFILE test-js: runs-on: ubuntu-latest - container: - image: node:20-slim steps: - uses: actions/checkout@v4 - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) - run: node --test static/js/play/__tests__/*.test.js + run: | + docker build -f - -t forge-test-js:${{ gitea.sha }} . <<'DOCKERFILE' + FROM node:20-slim + WORKDIR /app + COPY static/js/play/ static/js/play/ + RUN node --test static/js/play/__tests__/*.test.js + DOCKERFILE build-and-push: needs: [test-python, test-js] -- 2.54.0 From cc89b3f7e2ecc373764b0fa56a610d17d167b259 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 16:14:17 +0200 Subject: [PATCH 6/6] Corrige la CI (suite) : neutralise .dockerignore pour le build de test Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "no tests ran ... file or directory not found: tests/" : .dockerignore (à la racine, pensé pour l'image de PROD buildée par build-and-push) exclut tests/ du contexte de build — COPY . . dans le Dockerfile jetable de test-python ne l'incluait donc jamais, quel que soit le Dockerfile utilisé (.dockerignore s'applique au contexte entier envoyé au démon, pas à un -f en particulier). Renomme .dockerignore avant ce build précis (le checkout de ce job est jetable, propre à lui, jamais repoussé vers le dépôt réel) — test-js n'a pas besoin du même correctif, il ne copie que static/js/play/, jamais exclu. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/deploy.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 367d88e7..54894c7a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -48,6 +48,13 @@ jobs: - uses: actions/checkout@v4 - name: Tests Python (pytest, exécutés PENDANT le build d'une image jetable) run: | + # .dockerignore (à la racine, pensé pour l'image de PROD) exclut + # tests/ du contexte de build — COPY . . ne l'aurait donc jamais + # inclus, quel que soit le Dockerfile utilisé (.dockerignore + # s'applique au contexte entier, pas à un Dockerfile en + # particulier). Neutralisé ici SANS RISQUE : ce checkout est + # propre à ce job, jetable, jamais repoussé vers le dépôt. + mv .dockerignore .dockerignore.disabled-for-ci docker build -f - -t forge-test-python:${{ gitea.sha }} . <<'DOCKERFILE' FROM python:3.13-slim WORKDIR /app -- 2.54.0