Score/Progression (Phase 1.1 de la feuille de route produit)
Build and deploy / test-python (push) Successful in 7m27s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Concept de premier ordre, distinct du système de variables globales —
préalable identifié aux futurs exports SCORM/xAPI (note de cadrage
.claude/Forge_Engine_Cadrage.pdf) : ces standards ont besoin d'un signal
"score"/"terminé" propre, pas d'une convention sur une variable choisie
par le créateur.

- db/scoring/ (même patron que db/global_vars/) : table _scoring, un
  score numérique + un statut (non_commence/en_cours/termine/reussi/
  echoue) par joueur (voir db.PLAYER_SHARED pour l'aperçu créateur).
- Deux nouvelles actions de flux, dans les deux éditeurs (document et
  scène) : "Modifier le score" (réutilise le vocabulaire d'opérations
  déjà là pour "Modifier une variable" — incrémenter/définir/etc., aucune
  nouvelle colonne de nœud) et "Définir le statut de la partie".
- Routes créateur (routes/flow/flow_node_run_score.py,
  flow_node_run_status.py) + miroirs publics par joueur
  (routes/public_play/) — même principe que flow_node_run_variable.py.
- GET /game/<slug>/scoring/<player_id> : lecture interne, pas exposée au
  joueur, préparée pour être consommée par le futur export Web/SCORM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 06:23:37 +02:00
co-authored by Claude Sonnet 5
parent 76a50fa87a
commit 9776fbc057
26 changed files with 486 additions and 1 deletions
+14
View File
@@ -41,3 +41,17 @@ GLOBAL_VARIABLE_TYPES = {
"objet": {"label": "Objet (JSON)"},
"tableau": {"label": "Tableau (JSON)"},
}
# Statuts de partie (voir db/scoring/) — préalable aux exports SCORM/xAPI,
# qui ont besoin d'un signal "terminé"/"réussi" propre plutôt que d'une
# convention sur une variable choisie par le créateur (voir le plan
# "Feuille de route produit"). "non_commence" est le défaut d'un joueur
# qui n'a encore rien écrit (voir db/scoring/get_score.py).
SCORE_STATUS_CHOICES = [
("non_commence", "Non commencée"),
("en_cours", "En cours"),
("termine", "Terminée"),
("reussi", "Réussie"),
("echoue", "Échouée"),
]
SCORE_STATUS_LABELS = dict(SCORE_STATUS_CHOICES)
+6
View File
@@ -0,0 +1,6 @@
"""Score/progression (voir le plan "Feuille de route produit") : un
concept de premier ordre, distinct du système de variables globales
(db/global_vars/) — un score numérique et un statut de partie, TOUJOURS
par joueur (voir db.PLAYER_SHARED pour l'aperçu créateur), préalable aux
exports SCORM/xAPI (qui ont besoin d'un signal "score"/"terminé" propre,
pas d'une convention sur une variable choisie par le créateur)."""
+22
View File
@@ -0,0 +1,22 @@
from ..connection import connect
def ensure_scoring_schema(slug):
"""Migration légère (même principe que ensure_global_vars_schema.py) :
crée _scoring si absente. Une seule ligne par joueur (player_id, voir
db.PLAYER_SHARED pour l'aperçu créateur — même modèle que
db/global_vars/, mais pas de notion "per_player=0" ici : le score
d'UN joueur n'a de sens que pour CE joueur, jamais partagé)."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _scoring (
player_id TEXT PRIMARY KEY,
score REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'non_commence',
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+17
View File
@@ -0,0 +1,17 @@
from ..connection import connect
from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .ensure_scoring_schema import ensure_scoring_schema
def get_score(slug, player_id=PLAYER_SHARED):
"""Score/statut de CE joueur — (0, "non_commence") tant qu'il n'a
encore rien écrit (voir set_score_value.py/set_status.py, qui créent
la ligne à la première écriture, même patron que
update_global_variable_value.py)."""
ensure_scoring_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _scoring WHERE player_id = ?", (player_id,)).fetchone()
conn.close()
if not row:
return {"player_id": player_id, "score": 0, "status": "non_commence"}
return dict(row)
+23
View File
@@ -0,0 +1,23 @@
from ..connection import connect
from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .ensure_scoring_schema import ensure_scoring_schema
def set_score_value(slug, new_value, player_id=PLAYER_SHARED):
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant, voir
screens/data_actions/apply_score_action.py) — même patron que
update_global_variable_value.py : la ligne de ce joueur est créée à sa
toute première écriture (INSERT ... ON CONFLICT évite un aller-retour
SELECT préalable, un joueur ne peut écrire que la SIENNE)."""
ensure_scoring_schema(slug)
conn = connect(slug)
conn.execute(
"""
INSERT INTO _scoring (player_id, score) VALUES (?, ?)
ON CONFLICT(player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
""",
(player_id, new_value),
)
conn.commit()
conn.close()
return True
+25
View File
@@ -0,0 +1,25 @@
from ..connection import connect
from ..constants import SCORE_STATUS_LABELS
from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
from .ensure_scoring_schema import ensure_scoring_schema
def set_status(slug, status, player_id=PLAYER_SHARED):
"""Voir set_score_value.py pour le patron (upsert, ligne créée à la
première écriture). `status` doit être une clé de
db.SCORE_STATUS_LABELS — un statut inconnu (ex. faute de frappe dans
une valeur de nœud de flux) est refusé plutôt qu'écrit tel quel."""
if status not in SCORE_STATUS_LABELS:
return False
ensure_scoring_schema(slug)
conn = connect(slug)
conn.execute(
"""
INSERT INTO _scoring (player_id, status) VALUES (?, ?)
ON CONFLICT(player_id) DO UPDATE SET status = excluded.status, updated_at = CURRENT_TIMESTAMP
""",
(player_id, status),
)
conn.commit()
conn.close()
return True
+2
View File
@@ -6,6 +6,8 @@ from . import (
flow_edge_delete,
flow_node_run_data,
flow_node_run_variable,
flow_node_run_score,
flow_node_run_status,
flow_node_run_add_row,
runtime_payload,
)
+18
View File
@@ -0,0 +1,18 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-score", methods=["POST"])
def flow_node_run_score(slug, node_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un
nœud Action de type "Modifier le score" — même principe que
flow_node_run_variable.py, pour le score plutôt qu'une variable
globale (voir db/scoring/, screens/data_actions/apply_score_action.py)."""
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_score":
return jsonify({"ok": False}), 400
ok = screens.apply_score_action(slug, node)
return jsonify({"ok": ok})
+17
View File
@@ -0,0 +1,17 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-status", methods=["POST"])
def flow_node_run_status(slug, node_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un
nœud Action de type "Définir le statut de la partie" — voir
db/scoring/, screens/data_actions/apply_status_action.py."""
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "definir_statut_partie":
return jsonify({"ok": False}), 400
ok = screens.apply_status_action(slug, node)
return jsonify({"ok": ok})
+1 -1
View File
@@ -1 +1 @@
from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played
from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played, game_scoring_view
+16
View File
@@ -0,0 +1,16 @@
from flask import jsonify
import db
from core.flask_app import app
@app.route("/game/<slug>/scoring/<player_id>")
def game_scoring_view(slug, player_id):
"""Lecture du score/statut d'UN joueur (voir db/scoring/) — route
interne (protégée comme toute route /game/<slug>, voir
core/auth_guard.py), pas destinée au joueur lui-même : préparée pour
être consommée par les futurs exports SCORM/xAPI (voir le plan
"Feuille de route produit"), qui ont besoin de lire ce signal une fois
la partie terminée."""
return jsonify(db.get_score(slug, player_id))
+2
View File
@@ -21,5 +21,7 @@ def game_play(slug):
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),
flow_node_run_score_url=url_for("flow_node_run_score", slug=slug, node_id=999999),
flow_node_run_status_url=url_for("flow_node_run_status", slug=slug, node_id=999999),
flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999),
)
+2
View File
@@ -3,5 +3,7 @@ from . import (
runtime_payload_public,
flow_node_run_data_public,
flow_node_run_variable_public,
flow_node_run_score_public,
flow_node_run_status_public,
flow_node_run_add_row_public,
)
@@ -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-score", methods=["POST"])
def flow_node_run_score_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_score.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_score":
return jsonify({"ok": False}), 400
ok = screens.apply_score_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-status", methods=["POST"])
def flow_node_run_status_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_status.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") != "definir_statut_partie":
return jsonify({"ok": False}), 400
ok = screens.apply_status_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
+2
View File
@@ -29,6 +29,8 @@ def game_play_public(slug):
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),
flow_node_run_score_url=url_for("flow_node_run_score_public", slug=slug, node_id=999999),
flow_node_run_status_url=url_for("flow_node_run_status_public", slug=slug, node_id=999999),
flow_node_run_add_row_url=url_for("flow_node_run_add_row_public", slug=slug, node_id=999999),
)
)
+2
View File
@@ -144,6 +144,8 @@ from .flow.blocks.blocks_view import blocks_view
from .data_actions.apply_data_action import apply_data_action
from .data_actions.apply_variable_action import apply_variable_action
from .data_actions.apply_score_action import apply_score_action
from .data_actions.apply_status_action import apply_status_action
from .data_actions.apply_add_row_action import apply_add_row_action
from .animations.ensure_animation_schema import ensure_animation_schema
@@ -0,0 +1,23 @@
import db
from .compute_operation import compute_new_value
def apply_score_action(slug, action, player_id=db.PLAYER_SHARED):
"""Exécute au moment du clic (mode jouable) une action
"modifier_score" — même vocabulaire d'opérations que
apply_variable_action.py (DATA_OPERATIONS), appliqué au score de CE
joueur (voir db/scoring/) plutôt qu'à une variable globale. Le score
est TOUJOURS numérique (pas de "type" à lire au préalable, contrairement
à une variable — is_decimal=True partout, un score entier reste exact
en float pour de petits nombres)."""
operation = action.get("data_operation")
raw_value = action.get("data_value")
if not operation:
return False
current = db.get_score(slug, player_id)["score"] or 0
try:
new_value = compute_new_value(operation, current, raw_value, True)
except ValueError:
return False
return db.set_score_value(slug, new_value, player_id)
@@ -0,0 +1,13 @@
import db
def apply_status_action(slug, action, player_id=db.PLAYER_SHARED):
"""Exécute au moment du clic (mode jouable) une action
"definir_statut_partie" — réutilise data_value (déjà un simple champ
texte générique sur le nœud action, comme pour "attendre") pour porter
la clé du statut choisi (voir db.SCORE_STATUS_LABELS), plutôt qu'une
nouvelle colonne de nœud dédiée."""
status = action.get("data_value")
if not status:
return False
return db.set_status(slug, status, player_id)
+2
View File
@@ -5,6 +5,8 @@ ACTION_TYPE_LABELS = {
"modifier_element": "Modifier un élément",
"modifier_donnee": "Modifier une donnée d'un objet",
"modifier_variable": "Modifier une variable globale",
"modifier_score": "Modifier le score",
"definir_statut_partie": "Définir le statut de la partie",
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer",
+2
View File
@@ -20,6 +20,8 @@ ACTION_TYPE_LABELS_2D = {
"modifier_objet_scene": "Modifier un objet de scène",
"modifier_variable": "Modifier une variable globale",
"modifier_donnee": "Modifier une donnée d'un objet",
"modifier_score": "Modifier le score",
"definir_statut_partie": "Définir le statut de la partie",
"jouer_animation_sprite": "Jouer une animation de sprites",
"jouer_son": "Jouer un son",
"declencher_evenement": "Déclencher un événement",
+13
View File
@@ -268,6 +268,19 @@ function runActionNode(node) {
return fetch(runVarUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'modifier_score') {
// Score/progression (voir db/scoring/, screens/data_actions/
// apply_score_action.py) : même patron que "Modifier une variable
// globale" ci-dessus, calculé et écrit entièrement côté serveur.
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
return fetch(runScoreUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'definir_statut_partie') {
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
return fetch(runStatusUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'ajouter_ligne') {
// Phase 4 — crée une ligne VIDE côté serveur ; son id devient
// window.lastInsertedRowId pour que les nœuds "Modifier une donnée"
+17
View File
@@ -134,6 +134,11 @@ function nodeLabel(node) {
} else if (node.action_type === 'modifier_variable') {
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'modifier_score') {
base += ' → ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'definir_statut_partie') {
base += ' → ' + (SCORE_STATUS_LABELS_MAP[node.data_value] || node.data_value || '?');
} else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)';
} else if (node.action_type === 'declencher_evenement') {
@@ -536,6 +541,8 @@ function toggleFlowActionFields() {
var objectFields = document.getElementById('nf-action-object-fields');
var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields');
var scoreFields = document.getElementById('nf-action-score-fields');
var statusFields = document.getElementById('nf-action-status-fields');
var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields');
var addRowFields = document.getElementById('nf-action-add-row-fields');
@@ -555,6 +562,8 @@ function toggleFlowActionFields() {
if (objectFields) objectFields.style.display = typeSel.value === 'modifier_objet_scene' ? 'block' : 'none';
if (dataFields) dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
if (variableFields) variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
if (scoreFields) scoreFields.style.display = typeSel.value === 'modifier_score' ? 'block' : 'none';
if (statusFields) statusFields.style.display = typeSel.value === 'definir_statut_partie' ? 'block' : 'none';
if (waitFields) waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none';
@@ -567,6 +576,7 @@ function toggleFlowActionFields() {
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value');
updateDataValuePlaceholder('nf-action-score-operation', 'nf-action-score-value');
}
// Fondations multi-éditeurs — équivalent de toggleFlowElementPropertyValue()
@@ -811,6 +821,13 @@ function submitNodeForm() {
payload.target_variable = document.getElementById('nf-action-variable-name').value;
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'modifier_score') {
// Score/progression (voir db/scoring/) : un seul score par joueur,
// pas de nom à transmettre (contrairement à "modifier_variable").
payload.data_operation = document.getElementById('nf-action-score-operation').value;
payload.data_value = document.getElementById('nf-action-score-value').value;
} else if (actionType === 'definir_statut_partie') {
payload.data_value = document.getElementById('nf-action-status-value').value;
} else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} else if (actionType === 'declencher_evenement') {
+22
View File
@@ -400,6 +400,27 @@
{% endif %}
</div>
<!-- Score/progression (voir db/scoring/) : concept de premier
ordre distinct des variables globales ci-dessus — un seul
score par joueur, pas de nom à choisir. Réutilise le même
vocabulaire d'opérations (DATA_OPERATIONS). -->
<div id="nf-action-score-fields" style="display:none;">
<select id="nf-action-score-operation" onchange="updateDataValuePlaceholder('nf-action-score-operation', 'nf-action-score-value')">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-score-value" style="margin-top:6px;" placeholder="Valeur / montant">
</div>
<div id="nf-action-status-fields" style="display:none;">
<select id="nf-action-status-value">
{% for key, label in score_status_choices %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div id="nf-action-event-fields" style="display:none;">
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
@@ -677,6 +698,7 @@
var ELEMENT_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var SCORE_STATUS_LABELS_MAP = {{ score_status_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {};
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
+22
View File
@@ -609,6 +609,27 @@
{% endif %}
</div>
<!-- Score/progression (voir db/scoring/) : concept de premier
ordre distinct des variables globales ci-dessus — un seul
score par joueur, pas de nom à choisir. Réutilise le même
vocabulaire d'opérations (DATA_OPERATIONS). -->
<div id="nf-action-score-fields" style="display:none;">
<select id="nf-action-score-operation" onchange="updateDataValuePlaceholder('nf-action-score-operation', 'nf-action-score-value')">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-score-value" style="margin-top:6px;" placeholder="Valeur / montant">
</div>
<div id="nf-action-status-fields" style="display:none;">
<select id="nf-action-status-value">
{% for key, label in score_status_choices %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div id="nf-action-event-fields" style="display:none;">
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
@@ -1274,6 +1295,7 @@
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var SCORE_STATUS_LABELS_MAP = {{ score_status_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
+153
View File
@@ -0,0 +1,153 @@
"""Score/progression (db/scoring/) — voir le plan "Feuille de route
produit" : concept de premier ordre, distinct des variables globales,
préalable aux futurs exports SCORM/xAPI (qui ont besoin d'un signal
"score"/"terminé" propre)."""
import json
import db
import screens
from conftest import flask_app
def test_get_score_defaults_before_any_write(game):
assert db.get_score(game) == {"player_id": db.PLAYER_SHARED, "score": 0, "status": "non_commence"}
def test_set_score_value_and_status(game):
db.set_score_value(game, 42)
db.set_status(game, "en_cours")
score = db.get_score(game)
assert score["score"] == 42
assert score["status"] == "en_cours"
def test_set_status_rejects_unknown_value(game):
assert db.set_status(game, "n_importe_quoi") is False
assert db.get_score(game)["status"] == "non_commence"
def test_score_is_tracked_per_player(game):
db.set_score_value(game, 5, player_id="joueur-a")
db.set_score_value(game, 99, player_id="joueur-b")
assert db.get_score(game, "joueur-a")["score"] == 5
assert db.get_score(game, "joueur-b")["score"] == 99
assert db.get_score(game)["score"] == 0 # PLAYER_SHARED (aperçu créateur) inchangé
def test_apply_score_action_reuses_data_operations(game):
db.set_score_value(game, 10)
ok = screens.apply_score_action(game, {"data_operation": "incrementer", "data_value": "5"})
assert ok
assert db.get_score(game)["score"] == 15
ok = screens.apply_score_action(game, {"data_operation": "definir_nombre", "data_value": "100"})
assert ok
assert db.get_score(game)["score"] == 100
def test_apply_status_action_writes_status(game):
ok = screens.apply_status_action(game, {"data_value": "termine"})
assert ok
assert db.get_score(game)["status"] == "termine"
def test_apply_status_action_rejects_missing_value(game):
assert screens.apply_status_action(game, {}) is False
def test_flow_node_run_score_route(client, game):
screen_id = screens.create_screen(game, "Accueil")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "modifier_score", "data_operation": "incrementer", "data_value": "7"}),
content_type="application/json",
)
node_id = resp.get_json()["id"]
run_resp = client.post(f"/game/{game}/flow/nodes/{node_id}/run-score")
assert run_resp.get_json() == {"ok": True}
assert db.get_score(game)["score"] == 7
def test_flow_node_run_score_route_rejects_wrong_action_type(client, game):
screen_id = screens.create_screen(game, "Accueil")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "attendre", "data_value": "1"}),
content_type="application/json",
)
node_id = resp.get_json()["id"]
run_resp = client.post(f"/game/{game}/flow/nodes/{node_id}/run-score")
assert run_resp.status_code == 400
def test_flow_node_run_status_route(client, game):
screen_id = screens.create_screen(game, "Accueil")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "definir_statut_partie", "data_value": "echoue"}),
content_type="application/json",
)
node_id = resp.get_json()["id"]
run_resp = client.post(f"/game/{game}/flow/nodes/{node_id}/run-status")
assert run_resp.get_json() == {"ok": True}
assert db.get_score(game)["status"] == "echoue"
def test_game_scoring_view_route_reads_player_score(client, game):
db.set_score_value(game, 33, player_id="joueur-x")
db.set_status(game, "reussi", player_id="joueur-x")
resp = client.get(f"/game/{game}/scoring/joueur-x")
assert resp.get_json() == {
"player_id": "joueur-x", "score": 33, "status": "reussi",
"updated_at": resp.get_json()["updated_at"],
}
def test_two_anonymous_players_get_independent_scores(client, game):
"""Bout en bout via HTTP, même patron que
test_public_play.py::test_two_anonymous_players_get_independent_variable_state
— le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé."""
screen_id = screens.create_screen(game, "Accueil")
node_id = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "modifier_score", "data_operation": "incrementer", "data_value": "10"}),
content_type="application/json",
).get_json()["id"]
status_node_id = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "definir_statut_partie", "data_value": "reussi"}),
content_type="application/json",
).get_json()["id"]
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice_landing = alice.get(f"/jouer/{game}")
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
assert resp.get_json()["ok"] is True
alice.post(f"/jouer/{game}/flow/nodes/{status_node_id}/run-status")
bob = flask_app.test_client()
bob_landing = bob.get(f"/jouer/{game}")
bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
alice_player_id = alice_landing.headers["Set-Cookie"].split("forge_player_id=", 1)[1].split(";", 1)[0]
bob_player_id = bob_landing.headers["Set-Cookie"].split("forge_player_id=", 1)[1].split(";", 1)[0]
assert db.get_score(game, alice_player_id) == {
"player_id": alice_player_id, "score": 10, "status": "reussi",
"updated_at": db.get_score(game, alice_player_id)["updated_at"],
}
assert db.get_score(game, bob_player_id)["score"] == 20
assert db.get_score(game, bob_player_id)["status"] == "non_commence"
assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé
def test_editor_exposes_score_and_status_action_fields(client, game):
screen_id = screens.create_screen(game, "Accueil")
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "nf-action-score-fields" in html
assert "nf-action-status-fields" in html
assert "Modifier le score" in html
assert "Définir le statut de la partie" in html