Score/Progression (Phase 1.1 de la feuille de route produit)
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:
co-authored by
Claude Sonnet 5
parent
76a50fa87a
commit
9776fbc057
@@ -41,3 +41,17 @@ GLOBAL_VARIABLE_TYPES = {
|
|||||||
"objet": {"label": "Objet (JSON)"},
|
"objet": {"label": "Objet (JSON)"},
|
||||||
"tableau": {"label": "Tableau (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)
|
||||||
|
|||||||
@@ -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)."""
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -6,6 +6,8 @@ from . import (
|
|||||||
flow_edge_delete,
|
flow_edge_delete,
|
||||||
flow_node_run_data,
|
flow_node_run_data,
|
||||||
flow_node_run_variable,
|
flow_node_run_variable,
|
||||||
|
flow_node_run_score,
|
||||||
|
flow_node_run_status,
|
||||||
flow_node_run_add_row,
|
flow_node_run_add_row,
|
||||||
runtime_payload,
|
runtime_payload,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -21,5 +21,7 @@ def game_play(slug):
|
|||||||
runtime_payload_url=url_for("runtime_payload", slug=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_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_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),
|
flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ from . import (
|
|||||||
runtime_payload_public,
|
runtime_payload_public,
|
||||||
flow_node_run_data_public,
|
flow_node_run_data_public,
|
||||||
flow_node_run_variable_public,
|
flow_node_run_variable_public,
|
||||||
|
flow_node_run_score_public,
|
||||||
|
flow_node_run_status_public,
|
||||||
flow_node_run_add_row_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
|
||||||
@@ -29,6 +29,8 @@ def game_play_public(slug):
|
|||||||
runtime_payload_url=url_for("runtime_payload_public", slug=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_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_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),
|
flow_node_run_add_row_url=url_for("flow_node_run_add_row_public", slug=slug, node_id=999999),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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_data_action import apply_data_action
|
||||||
from .data_actions.apply_variable_action import apply_variable_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 .data_actions.apply_add_row_action import apply_add_row_action
|
||||||
|
|
||||||
from .animations.ensure_animation_schema import ensure_animation_schema
|
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)
|
||||||
@@ -5,6 +5,8 @@ ACTION_TYPE_LABELS = {
|
|||||||
"modifier_element": "Modifier un élément",
|
"modifier_element": "Modifier un élément",
|
||||||
"modifier_donnee": "Modifier une donnée d'un objet",
|
"modifier_donnee": "Modifier une donnée d'un objet",
|
||||||
"modifier_variable": "Modifier une variable globale",
|
"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",
|
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
|
||||||
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
|
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
|
||||||
"attendre": "Attendre quelques secondes avant de continuer",
|
"attendre": "Attendre quelques secondes avant de continuer",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ ACTION_TYPE_LABELS_2D = {
|
|||||||
"modifier_objet_scene": "Modifier un objet de scène",
|
"modifier_objet_scene": "Modifier un objet de scène",
|
||||||
"modifier_variable": "Modifier une variable globale",
|
"modifier_variable": "Modifier une variable globale",
|
||||||
"modifier_donnee": "Modifier une donnée d'un objet",
|
"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_animation_sprite": "Jouer une animation de sprites",
|
||||||
"jouer_son": "Jouer un son",
|
"jouer_son": "Jouer un son",
|
||||||
"declencher_evenement": "Déclencher un événement",
|
"declencher_evenement": "Déclencher un événement",
|
||||||
|
|||||||
@@ -268,6 +268,19 @@ function runActionNode(node) {
|
|||||||
return fetch(runVarUrl, {method: 'POST'})
|
return fetch(runVarUrl, {method: 'POST'})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => refreshRuntimeData());
|
.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') {
|
} else if (node.action_type === 'ajouter_ligne') {
|
||||||
// Phase 4 — crée une ligne VIDE côté serveur ; son id devient
|
// Phase 4 — crée une ligne VIDE côté serveur ; son id devient
|
||||||
// window.lastInsertedRowId pour que les nœuds "Modifier une donnée"
|
// window.lastInsertedRowId pour que les nœuds "Modifier une donnée"
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ function nodeLabel(node) {
|
|||||||
} else if (node.action_type === 'modifier_variable') {
|
} else if (node.action_type === 'modifier_variable') {
|
||||||
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
|
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
|
||||||
if (node.data_value) base += ' ' + node.data_value;
|
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') {
|
} else if (node.action_type === 'attendre') {
|
||||||
base += ' (' + (node.data_value || '0') + ' s)';
|
base += ' (' + (node.data_value || '0') + ' s)';
|
||||||
} else if (node.action_type === 'declencher_evenement') {
|
} else if (node.action_type === 'declencher_evenement') {
|
||||||
@@ -536,6 +541,8 @@ function toggleFlowActionFields() {
|
|||||||
var objectFields = document.getElementById('nf-action-object-fields');
|
var objectFields = document.getElementById('nf-action-object-fields');
|
||||||
var dataFields = document.getElementById('nf-action-data-fields');
|
var dataFields = document.getElementById('nf-action-data-fields');
|
||||||
var variableFields = document.getElementById('nf-action-variable-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 waitFields = document.getElementById('nf-action-wait-fields');
|
||||||
var eventFields = document.getElementById('nf-action-event-fields');
|
var eventFields = document.getElementById('nf-action-event-fields');
|
||||||
var addRowFields = document.getElementById('nf-action-add-row-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 (objectFields) objectFields.style.display = typeSel.value === 'modifier_objet_scene' ? 'block' : 'none';
|
||||||
if (dataFields) dataFields.style.display = typeSel.value === 'modifier_donnee' ? '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 (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 (waitFields) waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
|
||||||
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
|
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
|
||||||
if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? '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');
|
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
|
||||||
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
|
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
|
||||||
updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-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()
|
// Fondations multi-éditeurs — équivalent de toggleFlowElementPropertyValue()
|
||||||
@@ -811,6 +821,13 @@ function submitNodeForm() {
|
|||||||
payload.target_variable = document.getElementById('nf-action-variable-name').value;
|
payload.target_variable = document.getElementById('nf-action-variable-name').value;
|
||||||
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
|
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
|
||||||
payload.data_value = document.getElementById('nf-action-variable-value').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') {
|
} else if (actionType === 'attendre') {
|
||||||
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
|
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
|
||||||
} else if (actionType === 'declencher_evenement') {
|
} else if (actionType === 'declencher_evenement') {
|
||||||
|
|||||||
@@ -400,6 +400,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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;">
|
<div id="nf-action-event-fields" style="display:none;">
|
||||||
{% if not custom_events %}
|
{% 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>
|
<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_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
|
||||||
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
|
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
|
||||||
var DATA_OPERATION_LABELS_MAP = {{ data_operation_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 CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||||||
var TRIGGER_LABELS_MAP = {};
|
var TRIGGER_LABELS_MAP = {};
|
||||||
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
|
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
|
||||||
|
|||||||
@@ -609,6 +609,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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;">
|
<div id="nf-action-event-fields" style="display:none;">
|
||||||
{% if not custom_events %}
|
{% 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>
|
<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 SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||||
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
||||||
var DATA_OPERATION_LABELS_MAP = {{ data_operation_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 CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||||||
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
|
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
|
||||||
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
|
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user