Merge pull request 'Dev' (#7) from dev into main
Build and deploy / test-python (push) Successful in 1m28s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Successful in 10s
Build and deploy / deploy (push) Successful in 10s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-30 14:18:48 +00:00
55 changed files with 935 additions and 136 deletions
+44 -16
View File
@@ -26,31 +26,59 @@ on:
# github.com / du miroir gitea.com depuis le runner self-hosted. # github.com / du miroir gitea.com depuis le runner self-hosted.
jobs: jobs:
test: # 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Tests Python (pytest, exécutés PENDANT le build d'une image jetable)
# 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: Tests Python (pytest)
run: | run: |
docker run --rm -v "$PWD":/app -w /app python:3.13-slim sh -c " # .dockerignore (à la racine, pensé pour l'image de PROD) exclut
pip install --quiet -r requirements-dev.txt && # tests/ du contexte de build — COPY . . ne l'aurait donc jamais
python -m pytest tests/ -q # 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
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
steps:
- uses: actions/checkout@v4
- name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation)
run: | run: |
docker run --rm -v "$PWD":/app -w /app node:20-slim \ docker build -f - -t forge-test-js:${{ gitea.sha }} . <<'DOCKERFILE'
node --test static/js/play/__tests__/*.test.js 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: build-and-push:
needs: test needs: [test-python, test-js]
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+9
View File
@@ -19,9 +19,18 @@ from .flask_app import app
# Endpoints accessibles SANS être connecté — tout le reste exige une # Endpoints accessibles SANS être connecté — tout le reste exige une
# session valide. "static" (CSS/JS/images) doit rester public : la page # session valide. "static" (CSS/JS/images) doit rester public : la page
# de connexion elle-même en a besoin pour s'afficher. # 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/<slug>, 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 = { _PUBLIC_ENDPOINTS = {
"static", "login", "login_2fa", "register", "register_2fa", "logout", "static", "login", "login_2fa", "register", "register_2fa", "logout",
"forgot_password", "reset_password", "forgot_password", "reset_password",
"game_play_public", "runtime_payload_public",
"flow_node_run_data_public", "flow_node_run_variable_public",
} }
+35
View File
@@ -0,0 +1,35 @@
"""Identité d'un visiteur anonyme de la route publique /jouer/<slug> (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/<slug>) 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
+7 -2
View File
@@ -29,6 +29,8 @@ from .connection import connect
from .games.list_games import list_games from .games.list_games import list_games
from .games.game_meta import game_meta 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.create_game import create_game
from .games.update_game_name import update_game_name from .games.update_game_name import update_game_name
from .games.delete_game import delete_game from .games.delete_game import delete_game
@@ -53,7 +55,9 @@ from .rows.update_row_field import update_row_field
from .rows.delete_row import delete_row from .rows.delete_row import delete_row
from .rows.rows_referencing import rows_referencing 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 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.get_global_variable import get_global_variable
from .global_vars.create_global_variable import create_global_variable from .global_vars.create_global_variable import create_global_variable
from .global_vars.update_global_variable_value import update_global_variable_value from .global_vars.update_global_variable_value import update_global_variable_value
@@ -67,15 +71,16 @@ from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event from .custom_events.update_custom_event import update_custom_event
__all__ = [ __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", "slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", "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", "list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition", "delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row", "list_rows", "relation_options", "insert_row", "get_row", "update_row",
"update_row_field", "delete_row", "rows_referencing", "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", "update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id", "delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
+14 -3
View File
@@ -7,18 +7,28 @@ from .get_definition import get_definition
from .ensure_field_bounds_schema import ensure_field_bounds_schema 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 """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 exécute le vrai CREATE TABLE correspondant, avec les bons types de
colonne, y compris les colonnes de clé étrangère pour les relations 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 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) ensure_field_bounds_schema(slug)
conn = connect(slug) conn = connect(slug)
tname = table_name_for(name) tname = table_name_for(name)
conn.execute( 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"] 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), (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") columns_sql.append("created_at TEXT DEFAULT CURRENT_TIMESTAMP")
create_sql = f"CREATE TABLE {tname} ({', '.join(columns_sql)})" create_sql = f"CREATE TABLE {tname} ({', '.join(columns_sql)})"
conn.execute(create_sql) conn.execute(create_sql)
+8 -2
View File
@@ -4,13 +4,19 @@ from ..connection import connect
def ensure_field_bounds_schema(slug): def ensure_field_bounds_schema(slug):
"""Migration légère (voir screens/screens_repo/ensure_schema.py pour le """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 même principe) : ajoute les colonnes min_value/max_value à _fields pour
les jeux créés avant le bornage automatique (2.2) — aucune migration les jeux créés avant le bornage automatique (2.2), et per_player à
manuelle nécessaire, la prochaine lecture d'une définition suffit.""" _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) conn = connect(slug)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_fields)").fetchall()} cols = {r["name"] for r in conn.execute("PRAGMA table_info(_fields)").fetchall()}
if "min_value" not in cols: if "min_value" not in cols:
conn.execute("ALTER TABLE _fields ADD COLUMN min_value REAL") conn.execute("ALTER TABLE _fields ADD COLUMN min_value REAL")
if "max_value" not in cols: if "max_value" not in cols:
conn.execute("ALTER TABLE _fields ADD COLUMN max_value REAL") 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.commit()
conn.close() conn.close()
+2
View File
@@ -1,7 +1,9 @@
from ..connection import connect from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def list_definitions(slug): def list_definitions(slug):
ensure_field_bounds_schema(slug)
conn = connect(slug) conn = connect(slug)
rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall() rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall()
conn.close() conn.close()
+9 -1
View File
@@ -1,8 +1,16 @@
from ..connection import connect from ..connection import connect
from .is_public_played import is_public_played
def game_meta(slug): def game_meta(slug):
conn = connect(slug) conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close() 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),
}
+14
View File
@@ -0,0 +1,14 @@
from ..connection import connect
def is_public_played(slug):
"""Un jeu n'est jouable sur la route publique /jouer/<slug> (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")
+14
View File
@@ -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()
+20 -10
View File
@@ -1,27 +1,37 @@
from ..connection import connect 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 from .coerce_structured_value import coerce_structured_value
def create_global_variable(slug, name, var_type="texte", default_value=""): def create_global_variable(slug, name, var_type="texte", default_value="", per_player=True):
"""Crée une variable globale si son nom n'existe pas déjà (les noms sont """Crée la ligne "modèle" d'une variable globale (toujours
uniques — voir ensure_global_vars_schema.py) ; si elle existe déjà, ne player_id=PLAYER_SHARED, quel que soit per_player — voir
touche à rien et renvoie simplement son id existant, pour que "créer" ensure_global_vars_schema.py) si son nom n'existe pas déjà ; si elle
depuis le sélecteur de condition de visibilité (voir screen_edit.html) existe déjà, ne touche à rien et renvoie simplement son id existant,
reste sans risque même en cas de double clic/soumission.""" 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() name = (name or "").strip()
if not name: if not name:
return None return None
default_value = coerce_structured_value(var_type, default_value) default_value = coerce_structured_value(var_type, default_value)
ensure_global_vars_schema(slug) ensure_global_vars_schema(slug)
conn = connect(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: if existing:
conn.close() conn.close()
return existing["id"] return existing["id"]
conn.execute( conn.execute(
"INSERT INTO _global_variables (name, type, value) VALUES (?, ?, ?)", "INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)",
(name, var_type, default_value), (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"] new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit() conn.commit()
+3
View File
@@ -3,6 +3,9 @@ from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable(slug, name): 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) ensure_global_vars_schema(slug)
conn = connect(slug) conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE name = ?", (name,)) conn.execute("DELETE FROM _global_variables WHERE name = ?", (name,))
@@ -6,9 +6,15 @@ def delete_global_variable_by_id(slug, var_id):
"""Version par id de delete_global_variable — utilisée par la page de """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 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é 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) ensure_global_vars_schema(slug)
conn = connect(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.commit()
conn.close() conn.close()
+42 -6
View File
@@ -1,23 +1,59 @@
from ..connection import connect from ..connection import connect
PLAYER_SHARED = "__shared__"
def ensure_global_vars_schema(slug): def ensure_global_vars_schema(slug):
"""Migration légère (même principe que ensure_field_bounds_schema.py) : """Migration légère (même principe que ensure_field_bounds_schema.py) :
crée _global_variables si absente — aucune migration manuelle nécessaire crée _global_variables si absente. Depuis l'état par joueur (Phase 1),
pour les jeux créés avant cette fonctionnalité, la prochaine lecture une variable a une ligne "modèle" créée par le créateur du jeu
suffit. Une variable globale vit pour TOUT le jeu (pas par écran, pas (player_id=PLAYER_SHARED, voir PLAYER_SHARED ci-dessus), qui porte son
par ligne) : "nom" est donc UNIQUE, une seule valeur actuelle par nom.""" 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 = connect(slug)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS _global_variables ( CREATE TABLE IF NOT EXISTS _global_variables (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'texte', type TEXT NOT NULL DEFAULT 'texte',
value TEXT, 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.commit()
conn.close() conn.close()
+15 -7
View File
@@ -1,16 +1,24 @@
from ..connection import connect 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): def get_global_variable(slug, name, player_id=PLAYER_SHARED):
"""Par NOM (unique), pas par id — c'est comme ça qu'une variable est """Par NOM (pas par id — c'est comme ça qu'une variable est référencée
référencée partout ailleurs (condition de visibilité, action "Modifier partout ailleurs, condition de visibilité, action "Modifier une
une variable"), jamais par un id technique que l'utilisateur ne voit variable") et par joueur (voir resolve_player_key.py) : une variable
pas.""" 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: if not name:
return None return None
ensure_global_vars_schema(slug) ensure_global_vars_schema(slug)
conn = connect(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() conn.close()
return dict(row) if row else None return dict(row) if row else None
+9 -2
View File
@@ -1,10 +1,17 @@
from ..connection import connect 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): 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) ensure_global_vars_schema(slug)
conn = connect(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() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -0,0 +1,18 @@
from .ensure_global_vars_schema import PLAYER_SHARED
from .list_global_variables import list_global_variables
from .get_global_variable import get_global_variable
def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
"""Valeur EFFECTIVE de chaque variable pour ce joueur (voir
get_global_variable.py : la ligne propre à ce joueur si elle existe
déjà, sinon la valeur par défaut de la ligne modèle) — à la différence
de list_global_variables() (toujours les lignes modèles, pour le
tableau de bord), c'est cette version qu'il faut exposer au runtime
jouable (full_game_payload.py), pour qu'une condition évaluée côté
client reflète bien l'état RÉEL de CE joueur."""
result = []
for template in list_global_variables(slug):
value = get_global_variable(slug, template["name"], player_id)
result.append(value if value else template)
return result
+17
View File
@@ -0,0 +1,17 @@
from .ensure_global_vars_schema import PLAYER_SHARED
def resolve_player_key(conn, name, player_id):
"""Détermine le player_id à utiliser RÉELLEMENT pour lire/écrire cette
variable : celui du joueur (variable per_player=1, le cas par défaut),
ou toujours PLAYER_SHARED (variable explicitement partagée entre tous
les joueurs, per_player=0) — décidé par le réglage de la ligne
"modèle" créée par le créateur du jeu (voir create_global_variable.py).
Une variable encore inconnue (aucune ligne modèle) est traitée comme
per_player=1 par défaut."""
row = conn.execute(
"SELECT per_player FROM _global_variables WHERE name = ? AND player_id = ?",
(name, PLAYER_SHARED),
).fetchone()
per_player = row["per_player"] if row else 1
return player_id if per_player else PLAYER_SHARED
+28 -8
View File
@@ -1,18 +1,38 @@
from ..connection import connect from ..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 .get_global_variable import get_global_variable 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 """É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 variable — utilisé aussi bien par l'action "Modifier une variable" (voir
screens/global_vars/apply_variable_action.py) que par une éventuelle screens/data_actions/apply_variable_action.py) que par une éventuelle
modification directe (page de gestion des variables).""" modification directe (page de gestion des variables).
if not get_global_variable(slug, name):
return False 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) ensure_global_vars_schema(slug)
conn = connect(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.commit()
conn.close() conn.close()
return True return True
+6 -2
View File
@@ -1,8 +1,12 @@
from ..connection import connect 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 = 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.commit()
conn.close() conn.close()
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
def ensure_player_id_column(slug, table_name):
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
ajoute player_id à la table d'un objet créé avant l'état par joueur
(Phase 1) — ses lignes existantes deviennent implicitement
PLAYER_SHARED (comportement le plus proche de l'ancien : une seule
liste jusqu'ici, désormais la liste "partagée" par défaut tant que le
créateur n'a rien de plus à faire)."""
conn = connect(slug)
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table_name})").fetchall()}
if "player_id" not in cols:
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN player_id TEXT NOT NULL DEFAULT '__shared__'")
conn.commit()
conn.close()
+11 -2
View File
@@ -1,10 +1,19 @@
from ..connection import connect from ..connection import connect
from .ensure_player_id_column import ensure_player_id_column
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
def get_row(slug, definition, row_id): def get_row(slug, definition, row_id, player_id=PLAYER_SHARED):
"""Filtre aussi par player_id (pas seulement id) : même si `row_id` est
déjà un identifiant unique dans toute la table, ce garde-fou empêche un
joueur de lire la ligne d'un AUTRE joueur en devinant/altérant un
row_id (garantie utile dès qu'un objet per_player est exposé sur la
route publique /jouer/<slug>, voir routes/public_play/)."""
ensure_player_id_column(slug, definition["table_name"])
key = resolve_row_player_key(definition, player_id)
conn = connect(slug) conn = connect(slug)
row = conn.execute( 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() ).fetchone()
conn.close() conn.close()
return dict(row) if row else None return dict(row) if row else None
+9 -1
View File
@@ -1,16 +1,24 @@
from ..connection import connect from ..connection import connect
from ..quote_ident import quote_ident from ..quote_ident import quote_ident
from .row_columns_and_values import row_columns_and_values 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 """Feature 3 : enregistre en base les données saisies dans le formulaire
généré à partir de la définition d'objet.""" 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, values = row_columns_and_values(definition, form_data)
cols = cols + ["player_id"]
values = values + [key]
conn = connect(slug) conn = connect(slug)
placeholders = ["?"] * len(cols) placeholders = ["?"] * len(cols)
quoted_cols = ", ".join(quote_ident(c) for c in cols) quoted_cols = ", ".join(quote_ident(c) for c in cols)
sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})" sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})"
conn.execute(sql, values) conn.execute(sql, values)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit() conn.commit()
conn.close() conn.close()
return new_id
+8 -2
View File
@@ -1,8 +1,14 @@
from ..connection import connect 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) 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() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
+10 -2
View File
@@ -1,13 +1,21 @@
from ..connection import connect from ..connection import connect
from ..slugify import slugify from ..slugify import slugify
from ..definitions.get_definition import get_definition 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): def relation_options(slug, related_definition_id):
"""Lignes existantes d'un objet lié, pour peupler un <select> de relation.""" """Lignes existantes d'un objet lié, pour peupler un <select> de
relation — toujours la vue "modèle"/créateur (PLAYER_SHARED), cette
fonction n'est utilisée que côté tableau de bord/éditeur, jamais
depuis la route publique /jouer/<slug>."""
related = get_definition(slug, related_definition_id) related = get_definition(slug, related_definition_id)
ensure_player_id_column(slug, related["table_name"])
conn = connect(slug) 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() conn.close()
display_field = related["fields"][0]["name"] if related["fields"] else "id" display_field = related["fields"][0]["name"] if related["fields"] else "id"
display_col = slugify(display_field).replace("-", "_") display_col = slugify(display_field).replace("-", "_")
+14
View File
@@ -0,0 +1,14 @@
PLAYER_SHARED = "__shared__"
def resolve_row_player_key(definition, player_id):
"""Détermine le player_id à utiliser réellement pour lire/écrire les
lignes de cet objet : celui du joueur (objet per_player=1, le cas par
défaut — voir create_definition.py), ou toujours PLAYER_SHARED (objet
explicitement partagé, per_player=0, ex. un catalogue commun à tous
les joueurs). Contrairement à une variable globale (db/global_vars/
resolve_player_key.py), pas de repli implicite sur la ligne "modèle" :
un objet per_player démarre VIDE pour chaque nouveau joueur, il n'y a
pas de valeur par défaut unique à copier pour une LISTE de lignes."""
per_player = definition.get("per_player", 1)
return player_id if per_player else PLAYER_SHARED
+7 -2
View File
@@ -3,6 +3,8 @@ from ..quote_ident import quote_ident
from ..slugify import slugify from ..slugify import slugify
from ..definitions.definitions_referencing import definitions_referencing from ..definitions.definitions_referencing import definitions_referencing
from ..definitions.get_definition import get_definition 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): 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 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 pointent vers CETTE ligne précise — pour avertir avant une suppression
qui laisserait des relations orphelines (aucune suppression en cascade 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 = [] refs = []
for d in definitions_referencing(slug, definition_id): for d in definitions_referencing(slug, definition_id):
full = get_definition(slug, d["id"]) full = get_definition(slug, d["id"])
for f in full["fields"]: for f in full["fields"]:
if f["type"] == "relation" and f["relation_definition_id"] == definition_id: if f["type"] == "relation" and f["relation_definition_id"] == definition_id:
col = slugify(f["name"]).replace("-", "_") + "_id" col = slugify(f["name"]).replace("-", "_") + "_id"
ensure_player_id_column(slug, full["table_name"])
conn = connect(slug) conn = connect(slug)
count = conn.execute( 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"] ).fetchone()["c"]
conn.close() conn.close()
if count: if count:
+10 -4
View File
@@ -1,17 +1,23 @@
from ..connection import connect from ..connection import connect
from ..quote_ident import quote_ident from ..quote_ident import quote_ident
from .row_columns_and_values import row_columns_and_values 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 """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) cols, values = row_columns_and_values(definition, form_data)
conn = connect(slug) conn = connect(slug)
set_clause = ", ".join(f"{quote_ident(c)} = ?" for c in cols) set_clause = ", ".join(f"{quote_ident(c)} = ?" for c in cols)
conn.execute( conn.execute(
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ?", f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ? AND player_id = ?",
values + [row_id], values + [row_id, key],
) )
conn.commit() conn.commit()
conn.close() conn.close()
+11 -3
View File
@@ -1,16 +1,24 @@
from ..connection import connect from ..connection import connect
from ..quote_ident import quote_ident from ..quote_ident import quote_ident
from ..slugify import slugify 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 """Met à jour UNE SEULE colonne d'une ligne existante — utilisé par les
actions "Modifier une donnée" du mode jouable, qui ne doivent jamais actions "Modifier une donnée" du mode jouable, qui ne doivent jamais
écraser les autres champs de la ligne (contrairement à update_row, qui é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("-", "_") fname = slugify(field_def["name"]).replace("-", "_")
col = f"{fname}_id" if field_def["type"] == "relation" else fname col = f"{fname}_id" if field_def["type"] == "relation" else fname
conn = connect(slug) 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.commit()
conn.close() conn.close()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/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/<slug> (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))
+6 -1
View File
@@ -13,6 +13,11 @@ def create_global_var(slug):
name = (request.form.get("name") or "").strip() name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte" var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or "" 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: 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")) return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
+6 -1
View File
@@ -23,8 +23,13 @@ def object_new(slug):
# message d'erreur, juste un retour au tableau de bord) dès qu'on # message d'erreur, juste un retour au tableau de bord) dès qu'on
# tapait le nom sans avoir encore cliqué "+ Ajouter un champ". # tapait le nom sans avoir encore cliqué "+ Ajouter un champ".
fields = _parse_field_rows(request.form) 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: 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("object_view", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug)) return redirect(url_for("game_dashboard", slug=slug))
+8 -1
View File
@@ -1,6 +1,6 @@
import json import json
from flask import render_template from flask import render_template, url_for
import db import db
import screens import screens
@@ -10,8 +10,15 @@ from core.flask_app import app
@app.route("/game/<slug>/play") @app.route("/game/<slug>/play")
def game_play(slug): 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/<slug>
(routes/public_play/), la vraie route publique par joueur."""
game = db.game_meta(slug) game = db.game_meta(slug)
payload = screens.full_game_payload(slug) payload = screens.full_game_payload(slug)
return render_template( return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), "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),
) )
+1
View File
@@ -0,0 +1 @@
from . import game_play_public, runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public
@@ -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/<slug>/flow/nodes/<int:node_id>/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
@@ -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/<slug>/flow/nodes/<int:node_id>/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
+36
View File
@@ -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/<slug>")
def game_play_public(slug):
"""Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) —
distincte de /game/<slug>/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
@@ -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/<slug>/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
+6 -4
View File
@@ -3,12 +3,14 @@ import db
from .compute_operation import compute_new_value 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" : """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 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, 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 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") definition_id = action.get("target_definition_id")
row_id = action.get("target_row_id") row_id = action.get("target_row_id")
field_name = action.get("target_field") 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) field_def = next((f for f in definition["fields"] if f["name"] == field_name), None)
if not field_def: if not field_def:
return False return False
row = db.get_row(slug, definition, row_id) row = db.get_row(slug, definition, row_id, player_id)
if not row: if not row:
return False return False
col = db.slugify(field_def["name"]).replace("-", "_") col = db.slugify(field_def["name"]).replace("-", "_")
@@ -36,7 +38,7 @@ def apply_data_action(slug, action):
return False return False
new_value = _clamp_to_field_bounds(new_value, field_def) 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 return True
@@ -3,18 +3,19 @@ import db
from .compute_operation import compute_new_value 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 """Exécute au moment du clic (mode jouable) une action
"modifier_variable" — même logique/vocabulaire d'opérations que "modifier_variable" — même logique/vocabulaire d'opérations que
apply_data_action.py (DATA_OPERATIONS), appliqué à une variable globale 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 plutôt qu'au champ d'une ligne d'objet. `player_id` (état par joueur,
d'objet.""" 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") var_name = action.get("target_variable")
operation = action.get("data_operation") operation = action.get("data_operation")
raw_value = action.get("data_value") raw_value = action.get("data_value")
if not (var_name and operation): if not (var_name and operation):
return False return False
variable = db.get_global_variable(slug, var_name) variable = db.get_global_variable(slug, var_name, player_id)
if not variable: if not variable:
return False return False
var_type = variable["type"] var_type = variable["type"]
@@ -32,4 +33,4 @@ def apply_variable_action(slug, action):
except ValueError: except ValueError:
return False return False
return db.update_global_variable_value(slug, var_name, new_value) return db.update_global_variable_value(slug, var_name, new_value, player_id)
+9 -3
View File
@@ -6,7 +6,7 @@ from ..rendering.render_element_html import render_element_html
from ..element_types.is_overlay_only import _is_overlay_only_element_type 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 """Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" : é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 full_game_payload.py) — applique la "condition de visibilité" de chaque
élément (voir visibility_condition.py). Toujours False pour l'éditeur, élément (voir visibility_condition.py). Toujours False pour l'éditeur,
qui doit continuer à montrer/sélectionner un élément même si sa 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) conn = db.connect(slug)
rows = conn.execute( rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,) "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: for d in result:
if d.get("parent_id"): if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d) 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: for d in result:
# Un exemplaire de premier niveau d'un élément de jeu dont le # Un exemplaire de premier niveau d'un élément de jeu dont le
# modèle N'EST QU'une superposition : son cadre de positionnement # modèle N'EST QU'une superposition : son cadre de positionnement
+21 -11
View File
@@ -8,21 +8,28 @@ from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types 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 """Toutes les données nécessaires au runtime JS de la page de jeu jouable
(/game/<slug>/play) : chaque écran avec ses éléments (HTML déjà généré, (/game/<slug>/play, ou /jouer/<slug> — voir routes/public_play/) : chaque
y compris les Répéteurs de données, lus en direct), le graphe de logique écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de
(nœuds + fils) de chaque écran, et un instantané des données du jeu données, lus en direct), le graphe de logique (nœuds + fils) de chaque
(avec le type de chaque champ, nécessaire pour évaluer une condition écran, et un instantané des données du jeu (avec le type de chaque
correctement) — un seul aller-retour serveur, ensuite tout se joue côté champ, nécessaire pour évaluer une condition correctement) — un seul
client (aucune navigation ne recharge la page, sauf pour appliquer une aller-retour serveur, ensuite tout se joue côté client (aucune
action "modifier une donnée", qui doit passer par le serveur).""" 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) screens_ = list_screens(slug)
payload_screens = [] payload_screens = []
flows = {} flows = {}
animations = {} animations = {}
for s in screens_: 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}) payload_screens.append({**s, "elements": elements})
# Le graphe de logique et les animations sont, eux, lus pour TOUS les # Le graphe de logique et les animations sont, eux, lus pour TOUS les
@@ -50,7 +57,7 @@ def full_game_payload(slug):
fields_meta = {} fields_meta = {}
for d in definitions: for d in definitions:
full = db.get_definition(slug, d["id"]) 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"]} cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]}
data[str(d["id"])] = [ data[str(d["id"])] = [
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["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 # SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de
# ça. refreshRuntimeData() récupère un payload entier (donc des # ça. refreshRuntimeData() récupère un payload entier (donc des
# variables à jour) après toute action qui en modifie une. # 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 { return {
"screens": payload_screens, "flows": flows, "animations": animations, "screens": payload_screens, "flows": flows, "animations": animations,
+15 -11
View File
@@ -65,7 +65,7 @@ def _field_column(definition, field_name):
return col 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 """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 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, 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 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 nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
lire la valeur ACTUELLE de cette variable globale (voir 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: if not raw_value:
return raw_value return raw_value
stripped = raw_value.strip() stripped = raw_value.strip()
var_match = _VAR_REF_PATTERN.match(stripped) var_match = _VAR_REF_PATTERN.match(stripped)
if var_match: 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: if not variable:
return None # variable supprimée depuis : ne matche rien plutôt que planter return None # variable supprimée depuis : ne matche rien plutôt que planter
return _resolve_variable_path(variable.get("value"), var_match.group(2)) 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): for d in db.list_definitions(slug):
if d["name"] == object_name: if d["name"] == object_name:
full = db.get_definition(slug, d["id"]) full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full) rows = db.list_rows(slug, full, player_id)
if rows: if rows:
return rows[0].get(_field_column(full, field_name)) return rows[0].get(_field_column(full, field_name))
return None return None
@@ -156,18 +160,18 @@ def _compare(actual, operator, expected, field_type):
return False 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() field_name = (clause.get("champ") or "").strip()
if not field_name: if not field_name:
return True return True
operator = clause.get("operateur") or "egal" 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") field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = _field_column(definition, field_name) col = _field_column(definition, field_name)
return _compare(row.get(col), operator, expected, field_type) 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 """Ne garde, parmi les lignes, que celles qui satisfont la liste de
clauses données (un nombre quelconque), combinées entre elles par ET clauses données (un nombre quelconque), combinées entre elles par ET
(toutes vraies) ou par OU (au moins une vraie) selon `combinator` (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: if not clauses:
return rows return rows
if combinator == "ou": 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 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) 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 """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/ 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/ _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 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.""" 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") clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator) return filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id)
+1 -1
View File
@@ -74,7 +74,7 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
markers += "<!--dataBound-->" markers += "<!--dataBound-->"
return html_str + 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: if bound_ctx:
ctx = {**(ctx or {}), **bound_ctx} ctx = {**(ctx or {}), **bound_ctx}
# Chaque branche "special_render" passe elle aussi par _mark() : sans # Chaque branche "special_render" passe elle aussi par _mark() : sans
+5 -4
View File
@@ -22,7 +22,7 @@ def _interpolate_color(low_hex, high_hex, ratio):
return "#{:02x}{:02x}{:02x}".format(*mixed) 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 """Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de
l'objet suivi — quand un objet contient plusieurs enregistrements (ex: l'objet suivi — quand un objet contient plusieurs enregistrements (ex:
Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est 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)) definition = db.get_definition(slug, int(definition_id))
if not definition: if not definition:
return None return None
rows = db.list_rows(slug, definition) rows = db.list_rows(slug, definition, player_id)
if not rows: if not rows:
return None return None
row = 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" couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
afficher_valeur = attrs.get("_afficher_valeur") == "1" 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: try:
value = float(raw_value) value = float(raw_value)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -116,7 +117,7 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc
name_html = "" name_html = ""
if field_name_nom: 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, ""): if nom_value not in (None, ""):
text_align = "left" if (nom_position == "dessus" and nom_alignement == "gauche") else "center" text_align = "left" if (nom_position == "dessus" and nom_alignement == "gauche") else "center"
name_html = ( name_html = (
+3 -2
View File
@@ -30,8 +30,9 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>' return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>'
# (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois # (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois
# qu'on sait que l'objet est valide.) # qu'on sait que l'objet est valide.)
rows = db.list_rows(slug, definition) player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
rows = filter_repeater_rows(slug, definition, rows, attrs_raw) 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"]} 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) : # Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) :
+3 -3
View File
@@ -4,7 +4,7 @@ from .filter_repeater_rows import filter_rows_by_clauses, _field_column
from ..clause_list_codec import decode_clauses 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 — """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 réglage "Lier à un objet de données" des widgets Texte/Titre, voir
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions
@@ -27,9 +27,9 @@ def _resolve_bound_row_ctx(slug, attrs):
return None return None
if not definition: if not definition:
return None 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") 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: if not rows:
return None return None
row = rows[0] row = rows[0]
+4 -3
View File
@@ -26,17 +26,18 @@ def is_element_visible(slug, attrs, ctx=None):
pas dans ctx et on retombe sur la convention "1 seule ligne = état de 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 partie" (déjà utilisée par la Jauge/Donnée liée) : la ligne la plus
récente de l'objet choisi.""" récente de l'objet choisi."""
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
mode = attrs.get("_visibilite_cond_mode") mode = attrs.get("_visibilite_cond_mode")
if mode not in ("variable", "objet"): if mode not in ("variable", "objet"):
return True return True
operator = attrs.get("_visibilite_cond_operateur") or "egal" 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": if mode == "variable":
var_name = attrs.get("_visibilite_cond_variable") var_name = attrs.get("_visibilite_cond_variable")
if not var_name: if not var_name:
return True return True
variable = db.get_global_variable(slug, var_name) variable = db.get_global_variable(slug, var_name, player_id)
if not variable: if not variable:
return True # variable supprimée depuis : ne bloque jamais l'affichage return True # variable supprimée depuis : ne bloque jamais l'affichage
value = variable.get("value") value = variable.get("value")
@@ -61,7 +62,7 @@ def is_element_visible(slug, attrs, ctx=None):
if ctx and field_name in ctx: if ctx and field_name in ctx:
return _compare(ctx.get(field_name), operator, expected, field_type) 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: if not rows:
return True # objet sans donnée pour l'instant : ne bloque pas 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 row = rows[0] # convention "état de partie" : une seule ligne, voir data_definition_options.py
+15 -1
View File
@@ -49,8 +49,22 @@
jouable dans un nouvel onglet), pas une page de gestion de plus. --> jouable dans un nouvel onglet), pas une page de gestion de plus. -->
<nav class="gameNavBar" aria-label="Navigation du jeu"> <nav class="gameNavBar" aria-label="Navigation du jeu">
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a> <a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a> <a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button> <button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
"Publier" ci-dessus (export .zip mono-joueur) — active/désactive la
route publique /jouer/<slug>, jouable par n'importe quel visiteur
anonyme avec SA PROPRE partie (voir routes/public_play/,
db/games/is_public_played.py). -->
<form method="post" action="{{ url_for('game_toggle_public_played', slug=game.slug) }}" style="display:inline;">
<input type="hidden" name="value" value="{{ '0' if game.is_public_played else '1' }}">
<button type="submit" class="gameNavLink" title="{{ 'Rendre le jeu privé (désactive /jouer)' if game.is_public_played else 'Rendre le jeu jouable par tous, chacun avec sa propre partie' }}">
{{ '🌐 En ligne ✓' if game.is_public_played else '🌐 Publier en ligne' }}
</button>
</form>
{% if game.is_public_played %}
<a href="{{ url_for('game_play_public', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Ouvre la partie publique, comme un vrai visiteur">🔗 Lien public</a>
{% endif %}
</nav> </nav>
{% endif %} {% endif %}
</div> </div>
+23 -2
View File
@@ -76,12 +76,13 @@
{% if definitions %} {% if definitions %}
<div class="fieldsTableWrap"> <div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable"> <table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Table SQL</th><th></th></tr></thead> <thead><tr><th>Nom</th><th>Table SQL</th><th>Par joueur</th><th></th></tr></thead>
<tbody> <tbody>
{% for d in definitions %} {% for d in definitions %}
<tr> <tr>
<td><button type="button" class="linkLikeButton" onclick="openObjectEditPanel({{ d.id }})">{{ d.name }}</button></td> <td><button type="button" class="linkLikeButton" onclick="openObjectEditPanel({{ d.id }})">{{ d.name }}</button></td>
<td class="has-text-grey">{{ d.table_name }}</td> <td class="has-text-grey">{{ d.table_name }}</td>
<td><span class="hint">{{ 'Oui' if d.per_player else 'Non — partagé' }}</span></td>
<td class="fieldsTableActions"> <td class="fieldsTableActions">
<button type="button" class="button is-small" onclick="openObjectEditPanel({{ d.id }})" title="Modifier cet objet">✏️</button> <button type="button" class="button is-small" onclick="openObjectEditPanel({{ d.id }})" title="Modifier cet objet">✏️</button>
</td> </td>
@@ -158,6 +159,15 @@
</select> </select>
<input type="text" id="newVarValue" name="default_value" placeholder="Valeur de départ"> <input type="text" id="newVarValue" name="default_value" placeholder="Valeur de départ">
<button type="button" id="newVarConfigureBtn" class="button is-small" style="display:none;" onclick="openVarValuePanel('newVarValue', document.getElementById('newVarType').value)">🔧 Configurer la valeur</button> <button type="button" id="newVarConfigureBtn" class="button is-small" style="display:none;" onclick="openVarValuePanel('newVarValue', document.getElementById('newVarType').value)">🔧 Configurer la valeur</button>
<!-- État par joueur (Phase 1) : réglé une seule fois, à la création
(voir db/global_vars/create_global_variable.py) — coché par
défaut (chaque joueur a sa propre valeur), décoché pour une
variable explicitement PARTAGÉE par tous les joueurs (ex. un
compteur de visiteurs global). -->
<label class="checkbox" style="display:flex; align-items:center; gap:6px; font-size:12px; white-space:nowrap;" title="Décoché : une seule valeur, partagée par tous les joueurs.">
<input type="checkbox" name="per_player" value="1" checked style="width:auto;">
Par joueur
</label>
<button type="submit" class="primary">Créer</button> <button type="submit" class="primary">Créer</button>
</form> </form>
{% if variables %} {% if variables %}
@@ -166,7 +176,7 @@
{% endfor %} {% endfor %}
<div class="fieldsTableWrap"> <div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable"> <table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th></th></tr></thead> <thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th>Par joueur</th><th></th></tr></thead>
<tbody> <tbody>
{% for v in variables %} {% for v in variables %}
<tr> <tr>
@@ -188,6 +198,7 @@
<input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" aria-label="Valeur de « {{ v.name }} »"> <input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" aria-label="Valeur de « {{ v.name }} »">
{% endif %} {% endif %}
</td> </td>
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non — partagée' }}</span></td>
<td class="fieldsTableActions"> <td class="fieldsTableActions">
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small">Enregistrer</button> <button type="submit" form="varEditForm{{ v.id }}" class="button is-small">Enregistrer</button>
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}" <form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
@@ -295,6 +306,16 @@
<input type="text" id="object_name" name="object_name" required autocomplete="off"> <input type="text" id="object_name" name="object_name" required autocomplete="off">
</div> </div>
<!-- État par joueur (Phase 1) : réglé une seule fois, à la création
(voir db/definitions/create_definition.py) — coché par défaut
(chaque joueur a ses propres lignes, ex. un inventaire),
décoché pour un objet explicitement PARTAGÉ par tous les
joueurs (ex. un catalogue, une liste de questions). -->
<label class="checkbox" style="display:flex; align-items:center; gap:6px; margin-top:10px;" title="Décoché : les lignes de cet objet sont partagées par tous les joueurs.">
<input type="checkbox" name="per_player" value="1" checked style="width:auto;">
Chaque joueur a ses propres lignes ("par joueur")
</label>
<div class="dashPanelHeader" style="margin-top:14px;"> <div class="dashPanelHeader" style="margin-top:14px;">
<h2>Champs</h2> <h2>Champs</h2>
<button type="button" class="button is-small" id="btnAddField">+ Ajouter un champ</button> <button type="button" class="button is-small" id="btnAddField">+ Ajouter un champ</button>
+10 -4
View File
@@ -79,13 +79,19 @@
// le JS), qui ne peuvent pas contenir de code Jinja puisqu'ils ne // 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 // passent jamais par le moteur de templates. FORGE_PLAY_URLS porte donc
// les URLs de routes dont ces fichiers ont besoin (actions.js, // 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/<slug>/play, routes/play/game_play.py) que la partie
// publique par joueur (/jouer/<slug>, routes/public_play/), deux
// familles de routes distinctes (voir état par joueur, Phase 1).
let gameData = {{ payload_json|safe }}; let gameData = {{ payload_json|safe }};
let screensData = gameData.screens; let screensData = gameData.screens;
var FORGE_PLAY_URLS = { var FORGE_PLAY_URLS = {
runtimePayload: {{ url_for('runtime_payload', slug=game.slug)|tojson }}, runtimePayload: {{ runtime_payload_url|tojson }},
flowNodeRunData: {{ url_for('flow_node_run_data', slug=game.slug, node_id=999999)|tojson }}, flowNodeRunData: {{ flow_node_run_data_url|tojson }},
flowNodeRunVariable: {{ url_for('flow_node_run_variable', slug=game.slug, node_id=999999)|tojson }}, flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }},
}; };
</script> </script>
<script src="{{ url_for('static', filename='js/play/screens.js') }}"></script> <script src="{{ url_for('static', filename='js/play/screens.js') }}"></script>
+131
View File
@@ -0,0 +1,131 @@
"""Tests de la couche db/ pour l'état par joueur (Phase 1 du plan
d'extension du moteur) : une variable globale/un objet marqué per_player=1
(par défaut) donne une valeur/des lignes indépendantes par joueur ; marqué
per_player=0, la valeur/les lignes restent partagées entre tous les
joueurs. L'aperçu créateur (PLAYER_SHARED, le défaut de tous les
paramètres player_id) doit continuer de fonctionner exactement comme
avant ces changements — voir tests/test_confort.py, test_flow.py etc., qui
n'ont pas eu besoin d'être modifiés."""
import db
def test_global_variable_per_player_gives_independent_values(game):
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
db.update_global_variable_value(game, "score", "99", player_id="bob")
assert db.get_global_variable(game, "score", player_id="alice")["value"] == "10"
assert db.get_global_variable(game, "score", player_id="bob")["value"] == "99"
# Le créateur (PLAYER_SHARED, la ligne "modèle") garde la valeur par
# défaut — jamais écrasée par les joueurs.
assert db.get_global_variable(game, "score")["value"] == "0"
def test_global_variable_new_player_falls_back_to_default_value(game):
db.create_global_variable(game, "vies", "nombre_entier", "3", per_player=True)
assert db.get_global_variable(game, "vies", player_id="charlie")["value"] == "3"
def test_global_variable_shared_ignores_player_id(game):
db.create_global_variable(game, "compteur_visites", "nombre_entier", "0", per_player=False)
db.update_global_variable_value(game, "compteur_visites", "1", player_id="alice")
db.update_global_variable_value(game, "compteur_visites", "2", player_id="bob")
# Même valeur pour tout le monde, y compris un 3e joueur qui n'a jamais écrit.
assert db.get_global_variable(game, "compteur_visites", player_id="alice")["value"] == "2"
assert db.get_global_variable(game, "compteur_visites", player_id="bob")["value"] == "2"
assert db.get_global_variable(game, "compteur_visites", player_id="charlie")["value"] == "2"
def test_global_variable_list_only_shows_template_row(game):
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
variables = db.list_global_variables(game)
assert len(variables) == 1
assert variables[0]["value"] == "0"
def test_global_variable_delete_by_id_cascades_all_players(game):
var_id = db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
db.delete_global_variable_by_id(game, var_id)
assert db.get_global_variable(game, "score", player_id="alice") is None
assert db.get_global_variable(game, "score") is None
def test_object_per_player_gives_independent_rows(game):
definition_id = db.create_definition(
game, "Inventaire", [{"name": "objet", "type": "texte", "required": False}], per_player=True
)
definition = db.get_definition(game, definition_id)
db.insert_row(game, definition, {"objet": "épée"}, player_id="alice")
db.insert_row(game, definition, {"objet": "bouclier"}, player_id="bob")
alice_rows = db.list_rows(game, definition, player_id="alice")
bob_rows = db.list_rows(game, definition, player_id="bob")
assert [r["objet"] for r in alice_rows] == ["épée"]
assert [r["objet"] for r in bob_rows] == ["bouclier"]
# Un nouveau joueur qui n'a encore rien démarre VIDE (pas de valeur par
# défaut à copier pour une LISTE, contrairement à une variable scalaire).
assert db.list_rows(game, definition, player_id="charlie") == []
def test_object_shared_gives_same_rows_to_every_player(game):
definition_id = db.create_definition(
game, "Catalogue", [{"name": "nom", "type": "texte", "required": False}], per_player=False
)
definition = db.get_definition(game, definition_id)
db.insert_row(game, definition, {"nom": "Potion"})
alice_rows = db.list_rows(game, definition, player_id="alice")
bob_rows = db.list_rows(game, definition, player_id="bob")
assert [r["nom"] for r in alice_rows] == ["Potion"]
assert [r["nom"] for r in bob_rows] == ["Potion"]
def test_get_row_does_not_leak_across_players(game):
definition_id = db.create_definition(
game, "Inventaire", [{"name": "objet", "type": "texte", "required": False}], per_player=True
)
definition = db.get_definition(game, definition_id)
row_id = db.insert_row(game, definition, {"objet": "épée"}, player_id="alice")
# Bob ne doit jamais pouvoir lire/modifier/supprimer la ligne d'Alice,
# même en devinant son row_id (garde-fou de sécurité pour la route
# publique /jouer/<slug>).
assert db.get_row(game, definition, row_id, player_id="bob") is None
db.update_row_field(game, definition, row_id, definition["fields"][0], "hache", player_id="bob")
assert db.get_row(game, definition, row_id, player_id="alice")["objet"] == "épée"
db.delete_row(game, definition, row_id, player_id="bob")
assert db.get_row(game, definition, row_id, player_id="alice") is not None
def test_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
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"]
+116
View File
@@ -0,0 +1,116 @@
"""Tests de la route publique /jouer/<slug> (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/<slug>/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"