diff --git a/db/constants.py b/db/constants.py index 4572394c..6489623c 100644 --- a/db/constants.py +++ b/db/constants.py @@ -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) diff --git a/db/scoring/__init__.py b/db/scoring/__init__.py new file mode 100644 index 00000000..66c320c2 --- /dev/null +++ b/db/scoring/__init__.py @@ -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).""" diff --git a/db/scoring/ensure_scoring_schema.py b/db/scoring/ensure_scoring_schema.py new file mode 100644 index 00000000..90a656ca --- /dev/null +++ b/db/scoring/ensure_scoring_schema.py @@ -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() diff --git a/db/scoring/get_score.py b/db/scoring/get_score.py new file mode 100644 index 00000000..dcda310b --- /dev/null +++ b/db/scoring/get_score.py @@ -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) diff --git a/db/scoring/set_score_value.py b/db/scoring/set_score_value.py new file mode 100644 index 00000000..323f50ed --- /dev/null +++ b/db/scoring/set_score_value.py @@ -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 diff --git a/db/scoring/set_status.py b/db/scoring/set_status.py new file mode 100644 index 00000000..a0cffa78 --- /dev/null +++ b/db/scoring/set_status.py @@ -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 diff --git a/routes/flow/__init__.py b/routes/flow/__init__.py index 9864306b..4d46c046 100644 --- a/routes/flow/__init__.py +++ b/routes/flow/__init__.py @@ -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, ) diff --git a/routes/flow/flow_node_run_score.py b/routes/flow/flow_node_run_score.py new file mode 100644 index 00000000..8a0b80a5 --- /dev/null +++ b/routes/flow/flow_node_run_score.py @@ -0,0 +1,18 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//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}) diff --git a/routes/flow/flow_node_run_status.py b/routes/flow/flow_node_run_status.py new file mode 100644 index 00000000..89af2bc1 --- /dev/null +++ b/routes/flow/flow_node_run_status.py @@ -0,0 +1,17 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//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}) diff --git a/routes/games/__init__.py b/routes/games/__init__.py index bfc0d253..2d18d699 100644 --- a/routes/games/__init__.py +++ b/routes/games/__init__.py @@ -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 diff --git a/routes/games/game_scoring_view.py b/routes/games/game_scoring_view.py new file mode 100644 index 00000000..c53ae8c8 --- /dev/null +++ b/routes/games/game_scoring_view.py @@ -0,0 +1,16 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//scoring/") +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/, 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)) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index fed186ca..4e7f8cba 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -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), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py index 6984c1bb..03b1db91 100644 --- a/routes/public_play/__init__.py +++ b/routes/public_play/__init__.py @@ -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, ) diff --git a/routes/public_play/flow_node_run_score_public.py b/routes/public_play/flow_node_run_score_public.py new file mode 100644 index 00000000..5c337186 --- /dev/null +++ b/routes/public_play/flow_node_run_score_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-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 diff --git a/routes/public_play/flow_node_run_status_public.py b/routes/public_play/flow_node_run_status_public.py new file mode 100644 index 00000000..c6238e0a --- /dev/null +++ b/routes/public_play/flow_node_run_status_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-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 diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py index b142810d..06ba226c 100644 --- a/routes/public_play/game_play_public.py +++ b/routes/public_play/game_play_public.py @@ -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), ) ) diff --git a/screens/__init__.py b/screens/__init__.py index d39e440f..55997ef0 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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 diff --git a/screens/data_actions/apply_score_action.py b/screens/data_actions/apply_score_action.py new file mode 100644 index 00000000..e08fee9c --- /dev/null +++ b/screens/data_actions/apply_score_action.py @@ -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) diff --git a/screens/data_actions/apply_status_action.py b/screens/data_actions/apply_status_action.py new file mode 100644 index 00000000..47719d61 --- /dev/null +++ b/screens/data_actions/apply_status_action.py @@ -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) diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index 5d78197c..3b5620cf 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -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", diff --git a/screens/scenes/flow_palette.py b/screens/scenes/flow_palette.py index 778ac0d3..1cf47c0d 100644 --- a/screens/scenes/flow_palette.py +++ b/screens/scenes/flow_palette.py @@ -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", diff --git a/static/js/play/actions.js b/static/js/play/actions.js index d2c53355..339916a5 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -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" diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index afb81796..771ffbf4 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -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') { diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 80aadd56..bd4c7aef 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -400,6 +400,27 @@ {% endif %} + + + + + + + + + +