From 3c39f1a24937bc69a4478560dede3687952f87c2 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 15:46:00 +0200 Subject: [PATCH] =?UTF-8?q?Phase=201=20(2/3)=20:=20route=20publique=20/jou?= =?UTF-8?q?er/=20+=20identit=C3=A9=20visiteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la vraie route hébergée multi-joueurs qui manquait totalement (voir le constat d'exploration : /game//play est réservé au créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) — un visiteur anonyme peut maintenant jouer un jeu explicitement publié en ligne, avec sa propre partie (variables/objets per_player posés dans le commit précédent). core/player_identity.py : identité visiteur via un cookie NON SIGNÉ (forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de partition, jamais un jeton d'autorisation. routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu créateur mais threadées avec le vrai player_id du cookie au lieu du sentinel PLAYER_SHARED : - GET /jouer/ (game_play_public.py) - GET /jouer//runtime-payload - POST /jouer//flow/nodes//run-data - POST /jouer//flow/nodes//run-variable Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu n'est exposé publiquement que si le créateur l'a explicitement basculé "Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la table générique _meta, comme game_meta.py pour 'name'). core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS — la garde générique de connexion les laisse passer sans session, mais chaque vue vérifie quand même is_public_played elle-même (défense en profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py) n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask, qui existe pour n'importe quel visiteur (connecté ou non). templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload', ...)) mais reçoit des URLs déjà résolues par la route elle-même (runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url) — nécessaire puisque ce même template sert maintenant DEUX familles de routes (aperçu créateur ET partie publique), chacune avec ses propres noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ comportement) et game_play_public.py passent chacun ses propres URLs. templates/base.html : bascule "🌐 Publier en ligne" dans la barre de navigation du jeu, à côté de "📦 Publier" (export .zip) — deux fonctionnalités distinctes. db/games/game_meta.py expose maintenant is_public_played, disponible partout où `game` est dans le contexte. Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux cookies forge_player_id différents — qui obtiennent des valeurs de variable indépendantes, et un qui verrouille que l'aperçu créateur reste inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu (aperçu créateur et partie publique). Co-Authored-By: Claude Sonnet 5 --- core/auth_guard.py | 9 ++ core/player_identity.py | 35 ++++++ db/__init__.py | 3 + db/games/game_meta.py | 10 +- db/games/is_public_played.py | 14 +++ db/games/set_public_played.py | 14 +++ routes/__init__.py | 2 +- routes/games/__init__.py | 2 +- routes/games/game_toggle_public_played.py | 16 +++ routes/play/game_play.py | 9 +- routes/public_play/__init__.py | 1 + .../public_play/flow_node_run_data_public.py | 29 +++++ .../flow_node_run_variable_public.py | 25 ++++ routes/public_play/game_play_public.py | 36 ++++++ routes/public_play/runtime_payload_public.py | 20 +++ templates/base.html | 16 ++- templates/play.html | 14 ++- tests/test_public_play.py | 116 ++++++++++++++++++ 18 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 core/player_identity.py create mode 100644 db/games/is_public_played.py create mode 100644 db/games/set_public_played.py create mode 100644 routes/games/game_toggle_public_played.py create mode 100644 routes/public_play/__init__.py create mode 100644 routes/public_play/flow_node_run_data_public.py create mode 100644 routes/public_play/flow_node_run_variable_public.py create mode 100644 routes/public_play/game_play_public.py create mode 100644 routes/public_play/runtime_payload_public.py create mode 100644 tests/test_public_play.py diff --git a/core/auth_guard.py b/core/auth_guard.py index 949629c2..126f1b11 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -19,9 +19,18 @@ from .flask_app import app # Endpoints accessibles SANS être connecté — tout le reste exige une # session valide. "static" (CSS/JS/images) doit rester public : la page # de connexion elle-même en a besoin pour s'afficher. +# +# game_play_public/runtime_payload_public/flow_node_run_data_public/ +# flow_node_run_variable_public (routes/public_play/) : la route PUBLIQUE +# /jouer/, pour un vrai visiteur anonyme (état par joueur, Phase 1) +# — chaque vue vérifie ELLE-MÊME db.is_public_played(slug) (404 sinon), +# cette garde générique ne fait qu'autoriser l'accès sans connexion, elle +# ne dispense d'aucune autre vérification. _PUBLIC_ENDPOINTS = { "static", "login", "login_2fa", "register", "register_2fa", "logout", "forgot_password", "reset_password", + "game_play_public", "runtime_payload_public", + "flow_node_run_data_public", "flow_node_run_variable_public", } diff --git a/core/player_identity.py b/core/player_identity.py new file mode 100644 index 00000000..d1639c71 --- /dev/null +++ b/core/player_identity.py @@ -0,0 +1,35 @@ +"""Identité d'un visiteur anonyme de la route publique /jouer/ (voir +routes/public_play/) — état par joueur, Phase 1. Un cookie NON SIGNÉ (pas +besoin de itsdangerous/app.secret_key ici) : c'est une simple clé de +PARTITION des données (quelles lignes/valeurs appartiennent à ce visiteur), +jamais un jeton d'autorisation — la contrefaçon la plus grave possible +serait de se faire passer pour un AUTRE joueur anonyme du même jeu, pas de +franchir une frontière de sécurité (l'aperçu créateur, lui, reste protégé +par core/auth_guard.py, complètement indépendant de ce mécanisme).""" +import secrets + +from flask import request + +PLAYER_ID_COOKIE = "forge_player_id" +_PLAYER_ID_MAX_AGE = 60 * 60 * 24 * 365 # 1 an + + +def get_or_create_player_id(): + """Renvoie (player_id, is_new) — is_new indique s'il faut poser le + cookie sur la réponse avant de la renvoyer (voir + set_player_id_cookie ci-dessous) : à utiliser aussi bien depuis une + route qui rend une page (GET /jouer/) qu'une route API JSON + (runtime-payload, run-data, run-variable), dont la réponse n'est + construite qu'APRÈS avoir déjà besoin du player_id pour agir.""" + existing = request.cookies.get(PLAYER_ID_COOKIE) + if existing: + return existing, False + return secrets.token_urlsafe(16), True + + +def set_player_id_cookie(response, player_id): + response.set_cookie( + PLAYER_ID_COOKIE, player_id, + max_age=_PLAYER_ID_MAX_AGE, httponly=True, samesite="Lax", + ) + return response diff --git a/db/__init__.py b/db/__init__.py index a1d375c5..41be883d 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -29,6 +29,8 @@ from .connection import connect from .games.list_games import list_games from .games.game_meta import game_meta +from .games.is_public_played import is_public_played +from .games.set_public_played import set_public_played from .games.create_game import create_game from .games.update_game_name import update_game_name from .games.delete_game import delete_game @@ -72,6 +74,7 @@ __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "slugify", "table_name_for", "game_dir", "db_path", "connect", "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", + "is_public_played", "set_public_played", "list_definitions", "get_definition", "create_definition", "rename_definition", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "delete_definition", diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 3d806029..ab6de535 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -1,8 +1,16 @@ from ..connection import connect +from .is_public_played import is_public_played def game_meta(slug): conn = connect(slug) row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() conn.close() - return {"slug": slug, "name": row["value"] if row else slug} + return { + "slug": slug, "name": row["value"] if row else slug, + # État par joueur (Phase 1) : disponible partout où `game` est + # dans le contexte du template (base.html, la bascule "Publier en + # ligne" en a besoin pour afficher son état actuel), sans avoir à + # appeler is_public_played(slug) séparément dans chaque route. + "is_public_played": is_public_played(slug), + } diff --git a/db/games/is_public_played.py b/db/games/is_public_played.py new file mode 100644 index 00000000..32064302 --- /dev/null +++ b/db/games/is_public_played.py @@ -0,0 +1,14 @@ +from ..connection import connect + + +def is_public_played(slug): + """Un jeu n'est jouable sur la route publique /jouer/ (voir + routes/public_play/) que si le créateur l'a explicitement basculé + "Publier en ligne" dans son tableau de bord — jamais par défaut, pour + ne pas exposer un jeu en cours de conception. Réutilise la table + générique _meta (voir game_meta.py, déjà utilisée pour 'name'), + aucune nouvelle colonne/table nécessaire.""" + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'is_public_played'").fetchone() + conn.close() + return bool(row and row["value"] == "1") diff --git a/db/games/set_public_played.py b/db/games/set_public_played.py new file mode 100644 index 00000000..2842e47f --- /dev/null +++ b/db/games/set_public_played.py @@ -0,0 +1,14 @@ +from ..connection import connect + + +def set_public_played(slug, value): + """Bascule "Publier en ligne" (tableau de bord) — voir + is_public_played.py. `value` : bool.""" + conn = connect(slug) + conn.execute( + "INSERT INTO _meta (key, value) VALUES ('is_public_played', ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ("1" if value else "0",), + ) + conn.commit() + conn.close() diff --git a/routes/__init__.py b/routes/__init__.py index 2e9d945f..eab6058d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play diff --git a/routes/games/__init__.py b/routes/games/__init__.py index 471a48c9..bfc0d253 100644 --- a/routes/games/__init__.py +++ b/routes/games/__init__.py @@ -1 +1 @@ -from . import index, games_new, game_dashboard, game_rename, game_delete +from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played diff --git a/routes/games/game_toggle_public_played.py b/routes/games/game_toggle_public_played.py new file mode 100644 index 00000000..a50e7d5b --- /dev/null +++ b/routes/games/game_toggle_public_played.py @@ -0,0 +1,16 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//toggle-public-played", methods=["POST"]) +def game_toggle_public_played(slug): + """Bascule "Publier en ligne" (voir db/games/is_public_played.py) — + active/désactive la route publique /jouer/ (routes/public_play/), + jouable par n'importe quel visiteur anonyme avec sa propre partie + (état par joueur, Phase 1). Distinct de "Publier" (export .zip + exécutable mono-joueur, voir routes/publish/publish_game.py).""" + db.set_public_played(slug, request.form.get("value") == "1") + return redirect(request.referrer or url_for("game_dashboard", slug=slug)) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index 98c73c30..e677703e 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -1,6 +1,6 @@ import json -from flask import render_template +from flask import render_template, url_for import db import screens @@ -10,8 +10,15 @@ from core.flask_app import app @app.route("/game//play") def game_play(slug): + """Aperçu créateur (connecté, voir core/auth_guard.py) — une seule + partie partagée (PLAYER_SHARED, le défaut de full_game_payload), comme + avant l'état par joueur (Phase 1). Distincte de /jouer/ + (routes/public_play/), la vraie route publique par joueur.""" game = db.game_meta(slug) payload = screens.full_game_payload(slug) return render_template( "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + runtime_payload_url=url_for("runtime_payload", slug=slug), + flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999), + flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py new file mode 100644 index 00000000..8d21da09 --- /dev/null +++ b/routes/public_play/__init__.py @@ -0,0 +1 @@ +from . import game_play_public, runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public diff --git a/routes/public_play/flow_node_run_data_public.py b/routes/public_play/flow_node_run_data_public.py new file mode 100644 index 00000000..08576fd9 --- /dev/null +++ b/routes/public_play/flow_node_run_data_public.py @@ -0,0 +1,29 @@ +from flask import jsonify, request, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-data", methods=["POST"]) +def flow_node_run_data_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_data.py (aperçu créateur) + — voir routes/public_play/game_play_public.py pour le principe + général. `player_id` (le cookie du visiteur) est passé à + apply_data_action pour qu'un objet per_player agisse sur SA ligne.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": + return jsonify({"ok": False}), 400 + if node.get("target_row_id") == screens.CLICKED_ROW_ID: + body = request.get_json(silent=True) or {} + node = {**node, "target_row_id": body.get("clicked_row_id")} + ok = screens.apply_data_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": ok})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/flow_node_run_variable_public.py b/routes/public_play/flow_node_run_variable_public.py new file mode 100644 index 00000000..cb0ad632 --- /dev/null +++ b/routes/public_play/flow_node_run_variable_public.py @@ -0,0 +1,25 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-variable", methods=["POST"]) +def flow_node_run_variable_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_variable.py (aperçu + créateur) — voir routes/public_play/game_play_public.py pour le + principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable": + return jsonify({"ok": False}), 400 + ok = screens.apply_variable_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": ok})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py new file mode 100644 index 00000000..6381a5e1 --- /dev/null +++ b/routes/public_play/game_play_public.py @@ -0,0 +1,36 @@ +import json + +from flask import render_template, url_for, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer/") +def game_play_public(slug): + """Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) — + distincte de /game//play (aperçu créateur, connecté, INCHANGÉE) : + un vrai visiteur anonyme, identifié par un cookie (voir + core/player_identity.py), avec sa propre partie (variables/objets + per_player, voir db/global_vars/, db/rows/). 404 si le créateur n'a + pas explicitement basculé "Publier en ligne" dans son tableau de bord + — jamais exposé par défaut.""" + if not db.is_public_played(slug): + abort(404) + game = db.game_meta(slug) + player_id, is_new = get_or_create_player_id() + payload = screens.full_game_payload(slug, player_id) + resp = app.make_response( + render_template( + "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + runtime_payload_url=url_for("runtime_payload_public", slug=slug), + flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999), + flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999), + ) + ) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/runtime_payload_public.py b/routes/public_play/runtime_payload_public.py new file mode 100644 index 00000000..867706ea --- /dev/null +++ b/routes/public_play/runtime_payload_public.py @@ -0,0 +1,20 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//runtime-payload") +def runtime_payload_public(slug): + """Miroir public de routes/flow/runtime_payload.py (aperçu créateur) — + voir routes/public_play/game_play_public.py pour le principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + resp = app.make_response(jsonify(screens.full_game_payload(slug, player_id))) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/templates/base.html b/templates/base.html index 53f8de58..2beab4f6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -49,8 +49,22 @@ jouable dans un nouvel onglet), pas une page de gestion de plus. --> {% endif %} diff --git a/templates/play.html b/templates/play.html index f1c2427a..15c2161c 100644 --- a/templates/play.html +++ b/templates/play.html @@ -79,13 +79,19 @@ // le JS), qui ne peuvent pas contenir de code Jinja puisqu'ils ne // passent jamais par le moteur de templates. FORGE_PLAY_URLS porte donc // les URLs de routes dont ces fichiers ont besoin (actions.js, - // bindings.js), construites ici une seule fois. + // bindings.js), construites ici une seule fois — passées par la route + // elle-même (runtime_payload_url/flow_node_run_data_url/ + // flow_node_run_variable_url) plutôt que construites ici avec des noms + // de endpoint fixes : ce même template sert aussi bien l'aperçu + // créateur (/game//play, routes/play/game_play.py) que la partie + // publique par joueur (/jouer/, routes/public_play/), deux + // familles de routes distinctes (voir état par joueur, Phase 1). let gameData = {{ payload_json|safe }}; let screensData = gameData.screens; var FORGE_PLAY_URLS = { - runtimePayload: {{ url_for('runtime_payload', slug=game.slug)|tojson }}, - flowNodeRunData: {{ url_for('flow_node_run_data', slug=game.slug, node_id=999999)|tojson }}, - flowNodeRunVariable: {{ url_for('flow_node_run_variable', slug=game.slug, node_id=999999)|tojson }}, + runtimePayload: {{ runtime_payload_url|tojson }}, + flowNodeRunData: {{ flow_node_run_data_url|tojson }}, + flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }}, }; diff --git a/tests/test_public_play.py b/tests/test_public_play.py new file mode 100644 index 00000000..2ebcec8b --- /dev/null +++ b/tests/test_public_play.py @@ -0,0 +1,116 @@ +"""Tests de la route publique /jouer/ (routes/public_play/) — état +par joueur, Phase 1. Contrairement aux autres tests, ceux-ci utilisent +DIRECTEMENT flask_app.test_client() (pas la fixture `client`) pour de +vrais visiteurs anonymes, jamais connectés — le but est justement de +vérifier que core/auth_guard.py laisse passer ces routes sans session.""" +import json +import re + +import db + +from conftest import flask_app + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def _add_flow_node(client, slug, screen_id, **fields): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps(fields), + content_type="application/json", + ) + assert resp.status_code == 200 + return resp.get_json()["id"] + + +def test_public_route_404_until_explicitly_published(client, game): + anon = flask_app.test_client() + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 404 + + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 200 + + client.post(f"/game/{game}/toggle-public-played", data={"value": "0"}) + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 404 + + +def test_public_route_works_without_any_login_session(client, game): + """La garde globale (core/auth_guard.py) ne doit PAS exiger de session + pour cette route — c'est tout le sens de "publique".""" + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + anon = flask_app.test_client() + with anon.session_transaction() as sess: + assert "user_id" not in sess + resp = anon.get(f"/jouer/{game}") + assert resp.status_code == 200 + assert b"CSRF_TOKEN" in resp.data or b"gameData" in resp.data + + +def test_public_route_sets_player_id_cookie(client, game): + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + anon = flask_app.test_client() + resp = anon.get(f"/jouer/{game}") + assert "forge_player_id" in resp.headers.get("Set-Cookie", "") + + +def test_two_anonymous_players_get_independent_variable_state(client, game): + """Bout en bout, via HTTP : deux visiteurs anonymes (deux clients de + test SÉPARÉS, donc deux cookies forge_player_id différents) sur le + même jeu publié -> chacun sa propre valeur pour une variable + per_player, modifiée via l'action "Modifier une variable" (le vrai + chemin d'exécution du mode jouable, routes/public_play/ + flow_node_run_variable_public.py).""" + db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True) + screen_id = _create_screen(client, game) + node_id = _add_flow_node( + client, game, screen_id, + node_type="action", action_type="modifier_variable", + target_variable="score", data_operation="incrementer", data_value="10", + ) + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + + alice = flask_app.test_client() + alice.get(f"/jouer/{game}") # pose le cookie + resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + assert resp.get_json()["ok"] is True + + bob = flask_app.test_client() + bob.get(f"/jouer/{game}") + resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + assert resp.get_json()["ok"] is True + + assert db.get_global_variable(game, "score", player_id="__shared__")["value"] == "0" + + alice_payload = alice.get(f"/jouer/{game}/runtime-payload").get_json() + bob_payload = bob.get(f"/jouer/{game}/runtime-payload").get_json() + assert alice_payload["variables"]["score"]["value"] == "10" + assert bob_payload["variables"]["score"]["value"] == "20" + + +def test_creator_preview_unaffected_by_public_players(client, game): + """L'aperçu créateur (/game//play, connecté) doit continuer de + voir/modifier PLAYER_SHARED, jamais l'état d'un joueur public.""" + db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True) + screen_id = _create_screen(client, game) + node_id = _add_flow_node( + client, game, screen_id, + node_type="action", action_type="modifier_variable", + target_variable="score", data_operation="incrementer", data_value="10", + ) + client.post(f"/game/{game}/toggle-public-played", data={"value": "1"}) + + alice = flask_app.test_client() + alice.get(f"/jouer/{game}") + alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable") + + resp = client.get(f"/game/{game}/play") + assert resp.status_code == 200 + creator_payload = client.get(f"/game/{game}/runtime-payload").get_json() + assert creator_payload["variables"]["score"]["value"] == "0"