Phase 1 (2/3) : route publique /jouer/<slug> + identité visiteur
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Ajoute la vraie route hébergée multi-joueurs qui manquait totalement
(voir le constat d'exploration : /game/<slug>/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/<slug> (game_play_public.py)
- GET /jouer/<slug>/runtime-payload
- POST /jouer/<slug>/flow/nodes/<id>/run-data
- POST /jouer/<slug>/flow/nodes/<id>/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 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 15:46:00 +02:00
co-authored by Claude Sonnet 5
parent 236d6b4b46
commit 3c39f1a249
18 changed files with 362 additions and 9 deletions
+3
View File
@@ -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",
+9 -1
View File
@@ -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),
}
+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()