Dev #10

Merged
w-vandal merged 22 commits from dev into main 2026-09-02 19:17:50 +00:00
32 changed files with 2469 additions and 22 deletions
Showing only changes of commit f3b72d73c9 - Show all commits
+102
View File
@@ -0,0 +1,102 @@
"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir
routes/publish/export_scorm.py) : à la différence de build_package.py
(qui embarque Python + Flask, voir player_app.py), ce paquet est
STATIQUE — index.html porte tout le runtime jouable directement en
mémoire (window.FORGE_OFFLINE, voir static/js/play/offline/) puisqu'un
SCORM tourne SEUL dans le LMS du client, sans serveur Forge Engine
disponible. Rien de commun avec le zip .exe hors le sous-ensemble de
static/ et _copy_characters (mêmes assets, mêmes règles de copie
sélective) — réutilisés depuis build_package plutôt que dupliqués."""
import json
import os
import shutil
import tempfile
import zipfile
from flask import render_template
import db
import screens
from .build_package import _copy_characters, _STATIC_ITEMS
from .scorm_manifest import build_manifest_xml
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
def _copy_static_assets(staging_dir, slug):
static_dir = os.path.join(staging_dir, "static")
used_forge_characters = screens.list_used_forge_characters(slug)
for item in _STATIC_ITEMS:
if item == "characters":
_copy_characters(static_dir, used_forge_characters)
continue
src = os.path.join(_BASE_DIR, "static", item)
dst = os.path.join(static_dir, item)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.isdir(src):
# "js/play" inclut déjà static/js/play/offline/ (dont
# scorm-api.js, jamais chargé en ligne mais propre à ce
# paquet) — seuls ses __tests__/ (node:test, inutiles dans
# un LMS) sont exclus.
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__tests__") if item == "js/play" else None)
else:
shutil.copy2(src, dst)
def _list_package_files(staging_dir):
"""Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir
(hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS
pour qu'un validateur strict (ADL SCORM Cloud) accepte le paquet."""
rels = []
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
if rel != "index.html":
rels.append(rel)
return rels
def _zip_dir(staging_dir, zip_path):
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
zf.write(full, rel)
def build_scorm_zip(slug):
"""Construit le zip Web/SCORM et renvoie (chemin, nom du jeu) — à
l'appelant de supprimer le zip une fois la réponse HTTP envoyée (voir
routes/publish/export_scorm.py). Doit être appelé DANS une requête
Flask en cours (render_template a besoin du contexte applicatif)."""
game = db.game_meta(slug)
# PLAYER_SHARED (aucun player_id) : un paquet exporté est joué par une
# seule personne à la fois sur son poste, comme /game/<slug>/play —
# jamais d'état "par joueur" à distinguer hors ligne.
payload = screens.full_game_payload(slug)
staging_dir = tempfile.mkdtemp(prefix="forge_scorm_")
try:
_copy_static_assets(staging_dir, slug)
html = render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename,
)
with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f:
f.write(html)
package_files = _list_package_files(staging_dir)
manifest_xml = build_manifest_xml(slug, game["name"], package_files)
with open(os.path.join(staging_dir, "imsmanifest.xml"), "w", encoding="utf-8") as f:
f.write(manifest_xml)
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_scorm_", suffix=".zip")
os.close(zip_fd)
_zip_dir(staging_dir, zip_path)
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
return zip_path, game["name"]
+48
View File
@@ -0,0 +1,48 @@
"""Génère imsmanifest.xml (SCORM 1.2) pour le paquet Web/SCORM (voir
build_scorm_package.py). SCORM 1.2 plutôt que 2004 : support le plus
large chez les clients visés (Moodle, la plupart des LMS d'entreprise
acceptent les deux, l'inverse est faux)."""
import os
from xml.sax.saxutils import escape
_MANIFEST_TEMPLATE = """<?xml version="1.0" standalone="no" ?>
<manifest identifier="FORGE_{identifier}" version="1"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd
http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
<metadata>
<schema>ADL SCORM</schema>
<schemaversion>1.2</schemaversion>
</metadata>
<organizations default="FORGE_ORG_{identifier}">
<organization identifier="FORGE_ORG_{identifier}">
<title>{title}</title>
<item identifier="FORGE_ITEM_{identifier}" identifierref="FORGE_RES_{identifier}">
<title>{title}</title>
</item>
</organization>
</organizations>
<resources>
<resource identifier="FORGE_RES_{identifier}" type="webcontent" adlcp:scormtype="sco" href="index.html">
<file href="index.html"/>
{file_entries}
</resource>
</resources>
</manifest>
"""
def build_manifest_xml(slug, game_name, package_files):
"""`package_files` : chemins relatifs (depuis la racine du paquet, tel
qu'écrits dans le zip) de TOUS les fichiers autres que index.html —
un LMS strict (SCORM Cloud) rejette un manifeste qui omet un fichier
réellement présent dans le paquet."""
file_entries = "\n".join(
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/")))
for rel in package_files
)
return _MANIFEST_TEMPLATE.format(
identifier=escape(slug), title=escape(game_name), file_entries=file_entries,
)
+1
View File
@@ -18,6 +18,7 @@ def game_play(slug):
payload = screens.full_game_payload(slug)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
asset_url=lambda filename: url_for("static", filename=filename),
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),
+1
View File
@@ -26,6 +26,7 @@ def game_play_public(slug):
resp = app.make_response(
render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
asset_url=lambda filename: url_for("static", filename=filename),
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),
+1 -1
View File
@@ -1 +1 @@
from . import publish_game
from . import publish_game, export_scorm
+27
View File
@@ -0,0 +1,27 @@
import os
from flask import after_this_request, send_file
import db
from publish.build_scorm_package import build_scorm_zip
from core.flask_app import app
@app.route("/game/<slug>/export-scorm", methods=["POST"])
def export_scorm(slug):
"""Construit et renvoie le paquet Web/SCORM autonome du jeu (voir
publish/build_scorm_package.py) — même garde d'accès que
/game/<slug>/publish (core/auth_guard.py)."""
zip_path, game_name = build_scorm_zip(slug)
@after_this_request
def _cleanup(response):
try:
os.remove(zip_path)
except OSError:
pass
return response
download_name = f"{db.slugify(game_name)}-scorm.zip"
return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip")
+34 -1
View File
@@ -6,6 +6,7 @@ from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
from ..element_types.load_template_tree import _load_template_tree
from ..rendering.personnage_data import resolve_personnage_animations
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
@@ -100,7 +101,16 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
for r in rows
]
fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]]
# min_value/max_value (2.2 — bornage automatique, voir
# apply_data_action.py) : nécessaires ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
# voir le plan "port complet du runtime jouable côté navigateur")
# puisse reproduire le même bornage sans aller-retour serveur —
# inoffensif pour le runtime en ligne, qui ignore ces deux clés.
fields_meta[str(d["id"])] = [
{"name": f["name"], "type": f["type"], "min_value": f.get("min_value"), "max_value": f.get("max_value")}
for f in full["fields"]
]
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
@@ -111,6 +121,21 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
# Arbre complet (éléments + descendants) de l'écran-modèle de chaque
# élément de jeu réutilisable — voir screens/element_types/
# load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu
# créateur ET jeu publié en ligne). Nécessaire ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
# rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de
# Répéteur) puisse le rejouer sans base de données — inoffensif pour
# le runtime en ligne, qui l'ignore.
element_type_templates = {}
for type_id_str, template_screen_id in element_types.items():
if template_screen_id:
tree = _load_template_tree(slug, int(type_id_str))
if tree:
element_type_templates[type_id_str] = tree
# Instantané des variables globales, pour qu'un nœud Condition puisse en
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
@@ -122,8 +147,16 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
for v in db.list_global_variables_for_player(slug, player_id)
}
# id de définition -> nom de l'objet (voir filter_repeater_rows.py côté
# serveur, static/js/play/offline/filter-repeater-rows.js côté port
# hors ligne) : nécessaire pour résoudre une référence "{{Objet.champ}}"
# (Répéteur/Donnée liée/Condition de visibilité) — data/fields_meta
# ci-dessus sont déjà indexés par id, jamais par nom.
definition_names = {str(d["id"]): d["name"] for d in definitions}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations,
"definition_names": definition_names, "element_type_templates": element_type_templates,
}
+31 -7
View File
@@ -252,6 +252,13 @@ function runActionNode(node) {
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
return new Promise(function(resolve) { setTimeout(resolve, ms); });
} else if (node.action_type === 'modifier_donnee') {
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
// aucun serveur disponible dans le paquet exporté — la même mutation
// s'applique directement sur gameData, en mémoire, dans le navigateur.
if (window.FORGE_OFFLINE) {
forgeApplyDataActionOffline(gameData, node);
return refreshRuntimeData();
}
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
@@ -264,6 +271,10 @@ function runActionNode(node) {
.then(r => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'modifier_variable') {
if (window.FORGE_OFFLINE) {
forgeApplyVariableActionOffline(gameData, node);
return refreshRuntimeData();
}
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
return fetch(runVarUrl, {method: 'POST'})
.then(r => r.json())
@@ -271,22 +282,35 @@ function runActionNode(node) {
} 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.
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
if (window.FORGE_OFFLINE) {
forgeApplyScoreActionOffline(gameData, node);
return refreshRuntimeData();
}
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') {
if (window.FORGE_OFFLINE) {
forgeApplyStatusActionOffline(gameData, node);
return refreshRuntimeData();
}
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"
// enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕ Dernière ligne
// ajoutée") sachent quelle ligne renseigner. refreshRuntimeData()
// affiche la nouvelle ligne dans tout Répéteur lié dès ce round-trip.
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
// "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner.
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
if (window.FORGE_OFFLINE) {
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
return refreshRuntimeData();
}
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
return fetch(runAddRowUrl, { method: 'POST' })
.then(r => r.json())
@@ -418,6 +442,6 @@ function stopAllSpriteAnimations() {
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
applyObjectProperty, clampSceneObjectPosition,
applyObjectProperty, clampSceneObjectPosition, runActionNode,
};
}
+15 -2
View File
@@ -158,9 +158,23 @@ function applyOpenRowBindings() {
// "Modifier un élément" (ex: masquer une boîte) déclenchée par le même
// clic, puisque ces changements ne sont jamais enregistrés côté serveur.
function refreshRuntimeData() {
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") : window.FORGE_OFFLINE (posé UNIQUEMENT dans le paquet
// exporté, jamais dans /game/<slug>/play ni /jouer/<slug>) recalcule le
// payload EN MÉMOIRE (static/js/play/offline/full-payload.js) au lieu
// d'un fetch — le mécanisme de repérage/remplacement DOM qui suit
// (_forgeApplyFreshRuntimeData) reste ensuite EXACTEMENT le même code,
// en ligne comme hors ligne.
if (window.FORGE_OFFLINE) {
forgeRecomputeFullPayloadOffline(gameData);
return Promise.resolve().then(function () { return _forgeApplyFreshRuntimeData(gameData); });
}
return fetch(FORGE_PLAY_URLS.runtimePayload)
.then(r => r.json())
.then(newData => {
.then(_forgeApplyFreshRuntimeData);
}
function _forgeApplyFreshRuntimeData(newData) {
gameData = newData;
screensData = gameData.screens;
const screenData = screensData.find(s => s.id === window.currentScreenId);
@@ -305,5 +319,4 @@ function refreshRuntimeData() {
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
// tout de suite, sans attendre un aller-retour d'écran.
runScreenShowTriggers(window.currentScreenId);
});
}
@@ -0,0 +1,40 @@
// Charge tout le runtime hors ligne dans l'ordre où templates/play.html
// le fera (chaque fichier pose ses fonctions en global, comme en vrai
// navigateur) — nécessaire pour tester forgeRenderElementHtml() sur un
// widget à special_render (jauge, répéteur...), dont le rendu vit dans
// des fichiers frères de render-element.js, jamais require()-és entre eux
// directement (éviterait un cycle, voir la note en bas de render-element.js).
require('../../conditions.js');
global.compareValues = require('../../conditions.js').compareValues;
const filterMod = require('../filter-repeater-rows.js');
Object.assign(global, filterMod);
const visibilityMod = require('../visibility-condition.js');
Object.assign(global, visibilityMod);
const computeMod = require('../compute-operation.js');
Object.assign(global, computeMod);
const applyMod = require('../apply-actions.js');
Object.assign(global, applyMod);
const renderElementMod = require('../render-element.js');
Object.assign(global, renderElementMod);
const jaugeMod = require('../render-jauge.js');
Object.assign(global, jaugeMod);
const overlayMod = require('../render-overlay.js');
Object.assign(global, overlayMod);
const repeaterMod = require('../render-repeater.js');
Object.assign(global, repeaterMod);
const specialMod = require('../render-special-widgets.js');
Object.assign(global, specialMod);
module.exports = Object.assign(
{}, filterMod, visibilityMod, computeMod, applyMod, renderElementMod,
jaugeMod, overlayMod, repeaterMod, specialMod
);
@@ -0,0 +1,75 @@
// Export Web/SCORM — vérifie que static/js/play/actions.js bascule bien
// sur les fonctions forgeApply*Offline (apply-actions.js) quand
// window.FORGE_OFFLINE est actif, au lieu de fetch() vers le serveur
// (voir bindings.js, qui bascule déjà refreshRuntimeData() de son côté).
const test = require('node:test');
const assert = require('node:assert/strict');
require('./_load_offline_runtime.js');
global.window = global.window || {};
global.gameData = global.gameData || {};
const { runActionNode } = require('../../actions.js');
function withOfflineFetchGuard(fn) {
const previousFetch = global.fetch;
const previousOffline = global.window.FORGE_OFFLINE;
const previousGameData = global.gameData;
const previousRefresh = global.refreshRuntimeData;
global.window.FORGE_OFFLINE = true;
global.fetch = () => { throw new Error('fetch() ne doit jamais être appelé en mode FORGE_OFFLINE'); };
let refreshCalls = 0;
global.refreshRuntimeData = () => { refreshCalls++; return Promise.resolve(); };
try {
fn(() => refreshCalls);
} finally {
global.window.FORGE_OFFLINE = previousOffline;
global.fetch = previousFetch;
global.gameData = previousGameData;
global.refreshRuntimeData = previousRefresh;
}
}
test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { variables: { score_var: { value: '10', type: 'nombre_entier' } } };
const node = {
action_type: 'modifier_variable',
target_variable: 'score_var', data_operation: 'incrementer', data_value: '5',
};
runActionNode(node);
assert.equal(global.gameData.variables.score_var.value, '15');
assert.equal(getRefreshCalls(), 1);
});
});
test("modifier_score — mute gameData.scoring sans fetch()", () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
const node = { action_type: 'modifier_score', data_operation: 'incrementer', data_value: '3' };
runActionNode(node);
assert.equal(global.gameData.scoring.score, 3);
assert.equal(getRefreshCalls(), 1);
});
});
test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
const node = { action_type: 'definir_statut_partie', data_value: 'reussi' };
runActionNode(node);
assert.equal(global.gameData.scoring.status, 'reussi');
assert.equal(getRefreshCalls(), 1);
});
});
test("ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()", () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { data: { '1': [{ id: 5 }] }, definition_names: {}, fields_meta: { '1': [] } };
const node = { action_type: 'ajouter_ligne', target_definition_id: 1 };
runActionNode(node);
assert.equal(global.window.lastInsertedRowId, 6);
assert.equal(global.gameData.data['1'].length, 2);
assert.equal(getRefreshCalls(), 1);
});
});
@@ -0,0 +1,90 @@
// Export Web/SCORM — vérifie que les mutations hors ligne (apply-actions.js)
// produisent le même résultat que leur équivalent serveur Python (voir
// screens/data_actions/) sur les mêmes entrées.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline,
forgeApplyDataActionOffline, forgeApplyAddRowActionOffline,
} = require('../apply-actions.js');
function baseGameData() {
return {
variables: {
score_var: { value: '10', type: 'nombre_entier' },
flag: { value: '0', type: 'booleen' },
texte: { value: 'salut', type: 'texte' },
},
scoring: { score: 0, status: 'non_commence' },
data: {
'1': [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
},
fields_meta: {
'1': [
{ name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10 },
{ name: 'nom', type: 'texte', min_value: null, max_value: null },
],
},
};
}
test('modifier_variable — incrémenter un entier', () => {
const gd = baseGameData();
const ok = forgeApplyVariableActionOffline(gd, { target_variable: 'score_var', data_operation: 'incrementer', data_value: '5' });
assert.equal(ok, true);
assert.equal(gd.variables.score_var.value, '15');
});
test('modifier_variable — basculer un booléen (vocabulaire oui/vrai)', () => {
const gd = baseGameData();
forgeApplyVariableActionOffline(gd, { target_variable: 'flag', data_operation: 'basculer_bool' });
assert.equal(gd.variables.flag.value, '1');
});
test('modifier_variable — variable inconnue échoue proprement', () => {
const gd = baseGameData();
const ok = forgeApplyVariableActionOffline(gd, { target_variable: 'inexistante', data_operation: 'incrementer', data_value: '1' });
assert.equal(ok, false);
});
test('modifier_score — un seul score, pas de notion per-player hors ligne', () => {
const gd = baseGameData();
forgeApplyScoreActionOffline(gd, { data_operation: 'incrementer', data_value: '10' });
forgeApplyScoreActionOffline(gd, { data_operation: 'incrementer', data_value: '5' });
assert.equal(gd.scoring.score, 15);
});
test('definir_statut_partie — rejette un statut inconnu', () => {
const gd = baseGameData();
assert.equal(forgeApplyStatusActionOffline(gd, { data_value: 'n_importe_quoi' }), false);
assert.equal(forgeApplyStatusActionOffline(gd, { data_value: 'reussi' }), true);
assert.equal(gd.scoring.status, 'reussi');
});
test('modifier_donnee — bornage min/max comme apply_data_action.py', () => {
const gd = baseGameData();
forgeApplyDataActionOffline(gd, {
target_definition_id: 1, target_row_id: 1, target_field: 'points',
data_operation: 'incrementer', data_value: '100',
});
assert.equal(gd.data['1'][0].points, 10); // borné à max_value=10, pas 105
});
test('modifier_donnee — ligne/champ/objet inconnu échoue proprement', () => {
const gd = baseGameData();
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1' }), false);
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1' }), false);
});
test('ajouter_ligne — ligne vide, id auto-incrémenté, booléen à 0', () => {
const gd = baseGameData();
gd.fields_meta['1'].push({ name: 'actif', type: 'booleen', min_value: null, max_value: null });
const newId = forgeApplyAddRowActionOffline(gd, { target_definition_id: 1 });
assert.equal(newId, 3); // max existant (2) + 1
const row = gd.data['1'][2];
assert.equal(row.id, 3);
assert.equal(row.points, null);
assert.equal(row.nom, null);
assert.equal(row.actif, 0);
});
@@ -0,0 +1,61 @@
// Export Web/SCORM — vérifie que forgeComputeNewValue (port JS) produit
// EXACTEMENT les mêmes résultats que compute_new_value (Python, voir
// screens/data_actions/compute_operation.py) sur les mêmes entrées —
// toute divergence casserait silencieusement un jeu une fois exporté.
const test = require('node:test');
const assert = require('node:assert/strict');
const { forgeComputeNewValue, forgePyMod } = require('../compute-operation.js');
test('definir_texte / definir_nombre / bool', () => {
assert.equal(forgeComputeNewValue('definir_texte', 'x', 'bonjour', false), 'bonjour');
assert.equal(forgeComputeNewValue('definir_texte', 'x', '', false), '');
assert.equal(forgeComputeNewValue('definir_nombre', 0, '3.7', false), 3);
assert.equal(forgeComputeNewValue('definir_nombre', 0, '3.7', true), 3.7);
assert.equal(forgeComputeNewValue('definir_nombre', 0, 'pas un nombre', false), 0);
assert.equal(forgeComputeNewValue('definir_bool_vrai', 0, null, false), 1);
assert.equal(forgeComputeNewValue('definir_bool_faux', 1, null, false), 0);
assert.equal(forgeComputeNewValue('basculer_bool', 1, null, false), 0);
assert.equal(forgeComputeNewValue('basculer_bool', 0, null, false), 1);
});
test('incrementer / decrementer', () => {
assert.equal(forgeComputeNewValue('incrementer', 10, '5', false), 15);
assert.equal(forgeComputeNewValue('decrementer', 10, '5', false), 5);
assert.equal(forgeComputeNewValue('incrementer', null, '', false), 0);
assert.equal(forgeComputeNewValue('incrementer', 10.5, '0.5', true), 11);
});
test('multiplier / diviser / modulo', () => {
assert.equal(forgeComputeNewValue('multiplier', 4, '3', false), 12);
assert.equal(forgeComputeNewValue('diviser', 10, '4', false), 2); // int(2.5) = 2, pas d'arrondi
assert.equal(forgeComputeNewValue('diviser', 10, '4', true), 2.5);
assert.equal(forgeComputeNewValue('diviser', 10, '0', false), 10); // garde-fou division par zéro
assert.equal(forgeComputeNewValue('modulo', 10, '3', false), 1);
// Signe du modulo aligné sur Python (suit le diviseur), pas le JS natif
// (suivrait le dividende : -10 % 3 === -1 en JS, 2 en Python).
assert.equal(forgePyMod(-10, 3), 2);
assert.equal(forgeComputeNewValue('modulo', -10, '3', false), 2);
});
test('minimum / maximum', () => {
assert.equal(forgeComputeNewValue('minimum', 5, '10', false), 10);
assert.equal(forgeComputeNewValue('minimum', 15, '10', false), 15);
assert.equal(forgeComputeNewValue('maximum', 15, '10', false), 10);
assert.equal(forgeComputeNewValue('maximum', 5, '10', false), 5);
assert.equal(forgeComputeNewValue('minimum', 5, 'pas un nombre', false), 5); // inchangé
});
test('alea — bornes triées, entier vs décimal', () => {
for (let i = 0; i < 50; i++) {
const v = forgeComputeNewValue('alea', 0, '5,1', false); // bornes inversées, doit se corriger
assert.ok(Number.isInteger(v));
assert.ok(v >= 1 && v <= 5);
}
const d = forgeComputeNewValue('alea', 0, '1,2', true);
assert.ok(d >= 1 && d <= 2);
});
test('opération inconnue lève une erreur (comme ValueError côté Python)', () => {
assert.throws(() => forgeComputeNewValue('n_importe_quoi', 0, '1', false));
});
@@ -0,0 +1,103 @@
// Export Web/SCORM — vérifie que le port JS du moteur de filtrage
// (screens/rendering/filter_repeater_rows.py) produit les mêmes résultats
// que l'original Python sur les mêmes entrées.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
forgeDecodeClauses, forgeResolveVariablePath, forgeFieldColumn, forgeSlugifyFieldName,
forgeResolveFilterValue, forgeFilterRowsByClauses, forgeFilterRepeaterRows,
} = require('../filter-repeater-rows.js');
function baseGameData() {
return {
variables: {
niveau: { value: '3', type: 'nombre_entier' },
perso: { value: JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }), type: 'objet' },
},
definition_names: { '1': 'Progression', '2': 'Ennemis' },
fields_meta: {
'1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
'2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
},
data: {
'1': [{ id: 1, niveau_actuel: 4, boss_id: 7 }],
'2': [
{ id: 1, nom: 'Slime', pv: 10 },
{ id: 2, nom: 'Dragon', pv: 100 },
],
},
};
}
test('forgeSlugifyFieldName correspond à db.slugify(...).replace("-","_")', () => {
assert.equal(forgeSlugifyFieldName('Nom Complet'), 'nom_complet');
assert.equal(forgeSlugifyFieldName('Âge (ans)'), 'ge_ans');
});
test('forgeFieldColumn ajoute _id pour un champ relation', () => {
const fields = [{ name: 'boss', type: 'relation' }, { name: 'nom', type: 'texte' }];
assert.equal(forgeFieldColumn(fields, 'boss'), 'boss_id');
assert.equal(forgeFieldColumn(fields, 'nom'), 'nom');
});
test('forgeResolveVariablePath — chemin objet/tableau, chemin vide, JSON invalide', () => {
const raw = JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] });
assert.equal(forgeResolveVariablePath(raw, '.nom'), 'Zelda');
assert.equal(forgeResolveVariablePath(raw, '.armes[0]'), 'épée');
assert.equal(forgeResolveVariablePath('5', ''), '5');
assert.equal(forgeResolveVariablePath('pas du json', '.x'), null);
assert.equal(forgeResolveVariablePath(raw, '.absent'), null);
});
test('forgeResolveFilterValue — {{$variable.chemin}}', () => {
const gd = baseGameData();
assert.equal(forgeResolveFilterValue(gd, '{{$niveau}}'), '3');
assert.equal(forgeResolveFilterValue(gd, '{{$perso.nom}}'), 'Zelda');
assert.equal(forgeResolveFilterValue(gd, '{{$inexistante}}'), null);
});
test('forgeResolveFilterValue — {{Objet.champ}} (ligne la plus récente)', () => {
const gd = baseGameData();
assert.equal(forgeResolveFilterValue(gd, '{{Progression.niveau_actuel}}'), 4);
assert.equal(forgeResolveFilterValue(gd, '{{ObjetInconnu.champ}}'), null);
});
test('forgeResolveFilterValue — valeur fixe inchangée', () => {
const gd = baseGameData();
assert.equal(forgeResolveFilterValue(gd, 'texte fixe'), 'texte fixe');
});
test('forgeFilterRowsByClauses — ET (toutes vraies) vs OU (au moins une)', () => {
const gd = baseGameData();
const rows = gd.data['2'];
const clauses = [
{ champ: 'nom', operateur: 'egal', valeur: 'Dragon' },
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
];
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map(r => r.id), [2]);
const orClauses = [
{ champ: 'nom', operateur: 'egal', valeur: 'Slime' },
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
];
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map(r => r.id), [1, 2]);
});
test('forgeFilterRepeaterRows — ancien format 2 filtres fixes (repli)', () => {
const gd = baseGameData();
const attrs = { _filtre_champ: 'nom', _filtre_operateur: 'egal', _filtre_valeur: 'Slime' };
const result = forgeFilterRepeaterRows(gd, '2', gd.data['2'], attrs);
assert.deepEqual(result.map(r => r.id), [1]);
});
test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', () => {
const attrs = {
_filtres_json: JSON.stringify([{ champ: 'pv', operateur: 'superieur_egal', valeur: '10' }]),
_filtre_combinateur: 'ou',
_filtre_champ: 'ignoré',
};
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
assert.equal(decoded.clauses.length, 1);
assert.equal(decoded.combinator, 'ou');
});
@@ -0,0 +1,74 @@
// Export Web/SCORM — étape 5 : orchestrateur full-payload.js, appelé par
// refreshRuntimeData() (static/js/play/bindings.js) en mode hors ligne.
const test = require('node:test');
const assert = require('node:assert/strict');
const { forgeRecomputeFullPayloadOffline } = require('../full-payload.js');
require('./_load_offline_runtime.js'); // pour forgeRenderElementHtml + tous ses dépendants
function baseGameData() {
return {
variables: { score_var: { value: '10', type: 'nombre_entier' } },
scoring: { score: 0, status: 'non_commence' },
definition_names: {},
fields_meta: {},
data: {},
personnage_animations: {},
element_type_templates: {},
screens: [
{
id: 1, kind: 'document', preview_aspect: '9/16',
elements: [
{
id: 10, widget: 'texte', content: 'Score : x', attributes: {}, style: {}, parent_id: null,
rendered_html: '<p>ancien rendu</p>',
},
],
},
{
id: 2, kind: 'jeu_2d', scene_width: 960, scene_height: 540,
elements: [{ id: 20, kind: 'decor', rendered_html: '<img data-object-id="20">' }],
},
],
};
}
test('recalcule rendered_html pour un écran document', () => {
const gd = baseGameData();
forgeRecomputeFullPayloadOffline(gd);
assert.ok(gd.screens[0].elements[0].rendered_html.includes('Score : x'));
assert.ok(gd.screens[0].elements[0].rendered_html.startsWith('<p '));
});
test("ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)", () => {
const gd = baseGameData();
forgeRecomputeFullPayloadOffline(gd);
assert.equal(gd.screens[1].elements[0].rendered_html, '<img data-object-id="20">');
});
test('un élément imbriqué reçoit aussi son PROPRE rendered_html (pas seulement son ancêtre)', () => {
const gd = baseGameData();
gd.screens[0].elements.push({
id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10, rendered_html: '',
});
gd.screens[0].elements[0].widget = 'conteneur'; // devient le parent
forgeRecomputeFullPayloadOffline(gd);
const child = gd.screens[0].elements.find(e => e.id === 11);
assert.ok(child.rendered_html.includes('enfant'));
});
test('reflète une mutation déjà appliquée (score/variable) via la Donnée liée/condition de visibilité', () => {
const gd = baseGameData();
gd.variables.niveau = { value: '5', type: 'nombre_entier' };
gd.screens[0].elements[0].content = 'Visible';
gd.screens[0].elements[0].attributes = {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '5',
};
forgeRecomputeFullPayloadOffline(gd);
assert.ok(gd.screens[0].elements[0].rendered_html.includes('Visible'));
gd.variables.niveau.value = '1'; // mutation ultérieure (ex. modifier_variable) — score chute sous le seuil
forgeRecomputeFullPayloadOffline(gd);
assert.ok(gd.screens[0].elements[0].rendered_html.includes('visibilityGated'));
});
@@ -0,0 +1,110 @@
// Export Web/SCORM — port des widgets "simples" de render_element_html.py
// et du dispatcher lui-même (donnée liée, visibilité). Les 10 widgets à
// special_render (jauge, répéteur, personnage...) sont testés séparément
// dans render-special-widgets.test.js (dispatcher complet, via
// _load_offline_runtime.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const { forgeRenderElementHtml, forgeStyleString, forgeAttrString } = require('../render-element.js');
function baseGameData() {
return { variables: {}, definition_names: {}, fields_meta: {}, data: {} };
}
test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
const el = { id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<p '));
assert.ok(html.includes('a &lt; b<br>ligne 2'));
assert.ok(html.includes('data-element-id="1"'));
});
test('titre — balise dynamique h1/h2/h3 selon _niveau', () => {
const el = { id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<h1 '));
assert.ok(html.includes('class="title"'));
});
test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () => {
const el = { id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<button '));
assert.ok(html.includes('class="button"'));
assert.ok(html.includes('>Valider<'));
});
test('image — self-closing, pas de contenu ni enfants', () => {
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.equal(html.indexOf('</img>'), -1);
assert.ok(html.startsWith('<img '));
assert.ok(html.includes('src="chat.png"'));
});
test('liste — une <li> par ligne de contenu, échappée', () => {
const el = { id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('<li>Pomme</li><li>Poire &lt; Kiwi</li>'));
});
test('conteneur avec enfants — rendu récursif', () => {
const parent = { id: 10, widget: 'conteneur', content: '', attributes: {}, style: {} };
const child = { id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10 };
const html = forgeRenderElementHtml(parent, baseGameData(), { 10: [child] }, {});
assert.ok(html.includes('>enfant<'));
});
test('condition de visibilité fausse en mode jouable -> placeholder masqué', () => {
const gd = baseGameData();
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
const el = {
id: 6, widget: 'texte', content: 'secret', style: {},
attributes: {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
},
};
const html = forgeRenderElementHtml(el, gd, {}, { _forge_play_mode: true });
assert.ok(html.includes('visibilityGated'));
assert.ok(html.includes('display:none'));
assert.equal(html.indexOf('secret'), -1);
});
test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
const gd = baseGameData();
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
const el = {
id: 7, widget: 'texte', content: 'visible en édition', style: {},
attributes: {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
},
};
const html = forgeRenderElementHtml(el, gd, {}, {}); // pas de _forge_play_mode
assert.ok(html.includes('visible en édition'));
assert.ok(html.includes('<!--visibilityGated-->')); // marqueur quand même posé
});
test('{{champ}} substitué dans le contenu (contexte de Répéteur)', () => {
const el = { id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, { nom: 'Alice' });
assert.ok(html.includes('Bonjour Alice'));
});
test('forgeStyleString — largeur par défaut selon axe flex du parent (enfant)', () => {
const child = { id: 20, parent_id: 10, style: {} };
assert.ok(forgeStyleString(child, 'row', 'texte').startsWith('width:auto;'));
assert.ok(forgeStyleString(child, 'column', 'texte').startsWith('width:100%;'));
assert.ok(forgeStyleString(child, null, 'texte').startsWith('width:100%;')); // empilement libre
});
test('forgeAttrString — booléen True = attribut sans valeur, False/null omis', () => {
const s = forgeAttrString({ required: true, disabled: false, placeholder: null, name: 'x' }, 'color:red;');
assert.ok(s.includes('style="color:red;"'));
assert.ok(s.includes('required'));
assert.ok(!s.includes('disabled'));
assert.ok(!s.includes('placeholder'));
assert.ok(s.includes('name="x"'));
});
@@ -0,0 +1,150 @@
// Export Web/SCORM — étape 4 : widgets à special_render, testés via le
// dispatcher complet (forgeRenderElementHtml), comme en vrai jeu.
const test = require('node:test');
const assert = require('node:assert/strict');
const { forgeRenderElementHtml } = require('./_load_offline_runtime.js');
function baseGameData() {
return {
variables: {},
definition_names: { '1': 'Scores' },
fields_meta: { '1': [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
data: { '1': [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
personnage_animations: { '5': { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
element_type_templates: {},
};
}
test('case à cocher — <label class="checkbox"><input type="checkbox">', () => {
const el = { id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
// style toujours avant les autres attributs (voir forgeAttrString /
// attr_string.py — le même ordre côté serveur).
assert.ok(html.startsWith('<label style='));
assert.ok(html.includes('class="checkbox"'));
assert.ok(html.includes('type="checkbox"'));
assert.ok(html.includes("J&#x27;accepte")); // échappé, comme html.escape() côté serveur
});
test('liste déroulante — une <option> par ligne de contenu', () => {
const el = { id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('<div class="select is-fullwidth">'));
assert.equal((html.match(/<option/g) || []).length, 3);
});
test('groupe de champs — <fieldset><legend> + enfants', () => {
const parent = { id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {} };
const child = { id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3 };
const html = forgeRenderElementHtml(parent, baseGameData(), { 3: [child] }, {});
assert.ok(html.startsWith('<fieldset '));
assert.ok(html.includes('<legend>Coordonnées</legend>'));
assert.ok(html.includes('>Nom<'));
});
test('tableau — une ligne par ligne de contenu, cellules séparées par ";"', () => {
const el = { id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('<th>Nom</th><th>Score</th>'));
assert.ok(html.includes('<td>Alice</td><td>42</td>'));
});
test('icône — repli sur "star" si _icon_slug absent', () => {
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
// le style entier passe par forgeHtmlEscape (comme html.escape() côté
// serveur) : l'apostrophe de url('...') devient &#x27;.
assert.ok(html.includes("--icon-url:url(&#x27;/static/icons/star.svg&#x27;)"));
});
test('onglets — un panneau actif, les autres masqués', () => {
const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} };
const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
const tab2 = { id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
const html = forgeRenderElementHtml(parent, baseGameData(), { 7: [tab1, tab2] }, {});
assert.ok(html.includes('is-active'));
assert.ok(html.includes('tabpanel-8'));
assert.ok(html.includes('style="display:none;"')); // le 2e panneau masqué
});
test('personnage — lit gameData.personnage_animations (idle en premier)', () => {
const el = { id: 5, widget: 'personnage', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<img '));
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
assert.ok(html.includes('data-personnage="1"'));
});
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
const el = {
id: 10, widget: 'jauge', style: {},
attributes: { _definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff' },
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('value="42"')); // ligne la plus récente = Alice (id 1, points=42)
assert.ok(html.includes('jaugeBar'));
});
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
const el = {
id: 11, widget: 'repeteur_donnees', content: '{{nom}} : {{points}} pts',
attributes: { _definition_id: '1' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.equal((html.match(/repeaterItem/g) || []).length, 2);
assert.ok(html.includes('Alice : 42 pts'));
assert.ok(html.includes('Bob : 7 pts'));
assert.ok(html.includes('data-row-id="1"'));
});
test('répéteur — objet introuvable, message explicite plutôt qu\'un plantage', () => {
const el = { id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('Objet introuvable'));
});
test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', () => {
const el = {
id: 13, widget: 'texte', content: 'Score actuel : {{points}}', style: {},
attributes: {
_data_definition_id: '1',
_data_filtres_json: JSON.stringify([{ champ: 'nom', operateur: 'egal', valeur: 'Bob' }]),
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('Score actuel : 7'));
assert.ok(html.includes('<!--dataBound-->'));
});
test('superposition — voile plein écran seulement en mode jouable', () => {
const el = { id: 14, widget: 'superposition', content: '', attributes: {}, style: {} };
const editorHtml = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.equal(editorHtml.indexOf('position:fixed'), -1);
const playHtml = forgeRenderElementHtml(el, baseGameData(), {}, { _forge_play_mode: true });
assert.ok(playHtml.includes('position:fixed'));
assert.ok(playHtml.includes('modal is-active'));
});
test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameData.element_type_templates', () => {
const gd = baseGameData();
gd.element_type_templates['20'] = {
top: [{ id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root' }],
children_map: {},
};
const el = { id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, gd, {}, {});
assert.ok(html.includes('>Modèle<'));
});
test('élément de jeu = superposition seule -> enveloppe transparente sans classe "box"', () => {
const gd = baseGameData();
gd.element_type_templates['21'] = {
top: [{ id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root' }],
children_map: {},
};
const el = { id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, gd, {}, {});
assert.equal(html.indexOf('class="box"'), -1);
assert.ok(html.startsWith('<div '));
});
@@ -0,0 +1,56 @@
// Export Web/SCORM — port de screens/rendering/visibility_condition.py.
const test = require('node:test');
const assert = require('node:assert/strict');
const { forgeIsElementVisibleOffline } = require('../visibility-condition.js');
function baseGameData() {
return {
variables: { niveau: { value: '3', type: 'nombre_entier' } },
definition_names: { '1': 'Progression' },
fields_meta: { '1': [{ name: 'debloque', type: 'booleen' }] },
data: { '1': [{ id: 1, debloque: 1 }] },
};
}
test('sans réglage de condition, toujours visible', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {}), true);
});
test('mode variable — égal/supérieur', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '3',
}), true);
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur', _visibilite_cond_valeur: '3',
}), false);
});
test('mode variable — variable supprimée depuis : ne bloque jamais', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'disparue',
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '1',
}), true);
});
test('mode objet — lit la ligne la plus récente hors répéteur', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
}), true);
});
test('mode objet — utilise ctx (ligne du Répéteur) plutôt que la ligne la plus récente', () => {
const gd = baseGameData();
const ctx = { debloque: 0 };
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
}, ctx), false);
});
+163
View File
@@ -0,0 +1,163 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port fidèle des 5 handlers d'action qui, en ligne,
// passent par le serveur (voir static/js/play/actions.js) : ici, ils
// mutent gameData EN PLACE, sans aucun réseau. N'existe que pour
// window.FORGE_OFFLINE === true (paquet exporté) ; le runtime en ligne
// garde son comportement actuel, inchangé.
//
// Un score/statut de partie n'a PAS de notion per-player hors ligne (un
// paquet exporté est joué par UNE seule personne à la fois, sur SON
// poste) : gameData.scoring = {score, status}, pas une carte par joueur
// comme db/scoring/ côté serveur (voir db.PLAYER_SHARED).
// ---------- Modifier une variable globale ----------
// Port de screens/data_actions/apply_variable_action.py — même
// coercion de type, même vocabulaire d'opérations (compute-operation.js),
// la valeur reste stockée en CHAÎNE (gameData.variables[name].value est
// toujours une chaîne côté serveur, colonne SQLite TEXT — voir
// full_game_payload.py) pour que conditions.js continue de la lire
// exactement comme aujourd'hui.
function forgeApplyVariableActionOffline(gameData, action) {
var varName = action.target_variable;
var operation = action.data_operation;
var rawValue = action.data_value;
if (!varName || !operation) return false;
var variable = gameData.variables && gameData.variables[varName];
if (!variable) return false;
var varType = variable.type;
var current = variable.value;
if (varType === "nombre_entier" || varType === "nombre_decimal") {
current = (current === null || current === undefined || current === "") ? 0 : parseFloat(current);
if (isNaN(current)) current = 0;
} else if (varType === "booleen") {
var normalized = String(current == null ? "" : current).trim().toLowerCase();
current = (normalized === "1" || normalized === "true" || normalized === "vrai") ? 1 : 0;
}
var newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, varType === "nombre_decimal");
} catch (e) {
return false;
}
variable.value = String(newValue);
return true;
}
// ---------- Modifier le score / Définir le statut de la partie ----------
// Port de apply_score_action.py/apply_status_action.py — voir db/scoring/
// pour le vocabulaire des statuts (db.SCORE_STATUS_LABELS côté serveur,
// dupliqué ici en dur : ces 5 clés sont figées, voir db/constants.py).
var FORGE_SCORE_STATUS_KEYS = ["non_commence", "en_cours", "termine", "reussi", "echoue"];
function forgeApplyScoreActionOffline(gameData, action) {
var operation = action.data_operation;
var rawValue = action.data_value;
if (!operation) return false;
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
var current = gameData.scoring.score || 0;
var newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, true);
} catch (e) {
return false;
}
gameData.scoring.score = newValue;
return true;
}
function forgeApplyStatusActionOffline(gameData, action) {
var status = action.data_value;
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
gameData.scoring.status = status;
return true;
}
// ---------- Modifier une donnée d'un objet ----------
// Port de apply_data_action.py — même bornage min_value/max_value (voir
// full_game_payload.py, étendu pour transmettre ces deux bornes au
// client). gameData.data[definitionId] est un tableau de lignes
// {champ: valeur, id}, gameData.fields_meta[definitionId] la liste
// {name, type, min_value, max_value} — même forme que le payload en
// ligne (screens/payload/full_game_payload.py).
function forgeFindFieldMeta(gameData, definitionId, fieldName) {
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fields) return null;
for (var i = 0; i < fields.length; i++) {
if (fields[i].name === fieldName) return fields[i];
}
return null;
}
function forgeClampToFieldBounds(value, fieldMeta) {
if (fieldMeta.type !== "nombre_entier" && fieldMeta.type !== "nombre_decimal") return value;
if (fieldMeta.min_value !== null && fieldMeta.min_value !== undefined && value < fieldMeta.min_value) {
value = fieldMeta.min_value;
}
if (fieldMeta.max_value !== null && fieldMeta.max_value !== undefined && value > fieldMeta.max_value) {
value = fieldMeta.max_value;
}
return fieldMeta.type === "nombre_decimal" ? value : Math.trunc(value);
}
function forgeApplyDataActionOffline(gameData, action) {
var definitionId = String(action.target_definition_id || "");
var rowId = action.target_row_id;
var fieldName = action.target_field;
var operation = action.data_operation;
var rawValue = action.data_value;
if (!(definitionId && rowId && fieldName && operation)) return false;
var fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
if (!fieldMeta) return false;
var rows = gameData.data && gameData.data[definitionId];
if (!rows) return false;
var row = null;
for (var i = 0; i < rows.length; i++) {
if (rows[i].id === rowId) { row = rows[i]; break; }
}
if (!row) return false;
var current = row[fieldName];
var newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === "nombre_decimal");
} catch (e) {
return false;
}
row[fieldName] = forgeClampToFieldBounds(newValue, fieldMeta);
return true;
}
// ---------- Ajouter une ligne à un objet ----------
// Port de apply_add_row_action.py/db/rows/insert_row.py : une ligne VIDE
// (aucun champ renseigné — c'est à un ou plusieurs nœuds "Modifier une
// donnée" enchaînés, ciblant LAST_INSERTED_ROW_ID, de la remplir ensuite),
// même défauts par type que row_columns_and_values.py (booléen -> 0,
// tout le reste -> null). L'id est un simple auto-incrément local (pas
// de vraie base : aucune ligne d'un autre joueur à ignorer hors ligne).
function forgeApplyAddRowActionOffline(gameData, action) {
var definitionId = String(action.target_definition_id || "");
if (!definitionId) return null;
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fields) return null;
if (!gameData.data[definitionId]) gameData.data[definitionId] = [];
var rows = gameData.data[definitionId];
var maxId = 0;
rows.forEach(function (r) { if (r.id > maxId) maxId = r.id; });
var newRow = { id: maxId + 1 };
fields.forEach(function (f) {
newRow[f.name] = f.type === "booleen" ? 0 : null;
});
rows.push(newRow);
return newRow.id;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline,
forgeApplyDataActionOffline, forgeApplyAddRowActionOffline,
forgeFindFieldMeta, forgeClampToFieldBounds, FORGE_SCORE_STATUS_KEYS,
};
// node:test charge ce fichier isolément (voir __tests__/) — forgeComputeNewValue
// vit dans compute-operation.js, jamais un <script> séparé en test.
global.forgeComputeNewValue = global.forgeComputeNewValue || require('./compute-operation.js').forgeComputeNewValue;
}
@@ -0,0 +1,71 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port fidèle de screens/data_actions/compute_operation.py,
// utilisé UNIQUEMENT quand window.FORGE_OFFLINE est vrai (paquet exporté,
// aucun serveur disponible) — voir apply-actions.js, les deux seuls
// endroits qui en ont besoin. Toute divergence avec la version Python doit
// être volontaire et commentée (ex. modulo/int()).
// int() Python tronque vers zéro — Math.trunc fait exactement ça
// (contrairement à Math.floor, qui arrondit vers -l'infini).
function forgePyInt(n) {
return Math.trunc(n);
}
// Le signe du résultat de "%" suit le DIVISEUR en Python, le DIVIDENDE en
// JS — sans ce correctif, "modulo" donnerait un résultat différent de
// compute_operation.py dès qu'une des deux valeurs est négative.
function forgePyMod(a, b) {
return ((a % b) + b) % b;
}
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
var round = function (v) { return isDecimal ? v : forgePyInt(v); };
if (operation === "definir_texte") return rawValue || "";
if (operation === "definir_nombre") {
var n = parseFloat(rawValue);
return isNaN(n) ? 0 : round(n);
}
if (operation === "definir_bool_vrai") return 1;
if (operation === "definir_bool_faux") return 0;
if (operation === "basculer_bool") return current ? 0 : 1;
if (operation === "incrementer" || operation === "decrementer") {
var amount = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
if (isNaN(amount)) amount = 0;
if (operation === "decrementer") amount = -amount;
return round((current || 0) + amount);
}
if (operation === "multiplier") {
var factor = (rawValue === null || rawValue === undefined || rawValue === "") ? 1 : parseFloat(rawValue);
if (isNaN(factor)) factor = 1;
return round((current || 0) * factor);
}
if (operation === "diviser" || operation === "modulo") {
var divisor = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
if (isNaN(divisor)) divisor = 0;
if (divisor === 0) return current;
var result = operation === "diviser" ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
return round(result);
}
if (operation === "minimum" || operation === "maximum") {
var bound = parseFloat(rawValue);
if (isNaN(bound)) return current;
var res = operation === "minimum" ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
return round(res);
}
if (operation === "alea") {
var parts = String(rawValue || "").split(",");
var lo = parseFloat(parts[0]), hi = parseFloat(parts[1]);
if (isNaN(lo)) lo = 0;
if (isNaN(hi)) hi = 0;
if (lo > hi) { var tmp = lo; lo = hi; hi = tmp; }
if (isDecimal) return Math.random() * (hi - lo) + lo;
var iLo = Math.trunc(lo), iHi = Math.trunc(hi);
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo;
}
throw new Error("opération inconnue : " + operation);
}
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeComputeNewValue, forgePyInt, forgePyMod };
}
@@ -0,0 +1,158 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port de screens/rendering/filter_repeater_rows.py (le
// moteur de filtrage partagé par le Répéteur de données, la "Donnée
// liée" d'un Texte/Titre, et la Condition de visibilité en mode "objet")
// + screens/clause_list_codec.py (decode_clauses). Réutilise TELLE QUELLE
// compareValues() (static/js/play/conditions.js) — déjà le jumeau JS de
// _compare() côté Python (chargé avant ce fichier dans templates/
// play.html), pas de troisième copie de cette logique.
var FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
var FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
var FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
// l'ancien format à 2 filtres fixes si l'attribut JSON est absent, même
// convention que côté serveur (aucune migration de données nécessaire).
function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
var raw = attrs[clausesAttr];
var clauses = null;
if (raw) {
try {
var parsed = JSON.parse(raw);
if (Array.isArray(parsed)) clauses = parsed;
} catch (e) { clauses = null; }
}
if (clauses === null && legacyPrefix) {
clauses = [];
["", "2"].forEach(function (suffix) {
var champ = (attrs[legacyPrefix + suffix + "_champ"] || "").trim();
if (champ) {
clauses.push({
champ: champ,
operateur: attrs[legacyPrefix + suffix + "_operateur"] || "egal",
valeur: attrs[legacyPrefix + suffix + "_valeur"] || "",
});
}
});
}
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || "et" };
}
// Port de _resolve_variable_path — navigue ".champ"/"[index]" dans une
// valeur JSON (chaîne stockée). Ne lève jamais : chemin invalide/absent
// -> null, même convention que côté serveur (ne matche rien plutôt que
// planter).
function forgeResolveVariablePath(rawValue, path) {
if (!path) return rawValue;
var current;
try {
current = rawValue ? JSON.parse(rawValue) : null;
} catch (e) {
return null;
}
var m;
FORGE_PATH_SEGMENT_RE.lastIndex = 0;
while ((m = FORGE_PATH_SEGMENT_RE.exec(path)) !== null) {
var key = m[1], index = m[2];
try {
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
if (current === undefined) return null;
} catch (e) {
return null;
}
}
return current;
}
// Port de _field_column — un champ "relation" vit dans une colonne
// "<champ>_id" (voir screens/data_actions/apply_data_action.py) ; le
// payload hors ligne (gameData.fields_meta) indexe déjà les lignes par le
// nom SLUGIFIÉ du champ (voir full_game_payload.py), jamais par son nom
// affiché — slugifyLike() reproduit db.slugify()+replace("-","_").
function forgeSlugifyFieldName(name) {
return String(name).trim().toLowerCase()
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-/g, "_") || "jeu";
}
function forgeFieldColumn(fieldsMetaForDef, fieldName) {
var col = forgeSlugifyFieldName(fieldName);
var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; });
if (field && field.type === "relation") col += "_id";
return col;
}
// Port de _resolve_filter_value — {{Objet.champ}} (ligne la plus récente
// d'un AUTRE objet, convention "1 objet = 1 ligne = état de partie") ou
// {{$variable.chemin}} (variable globale) ; sinon la valeur fixe telle
// quelle. `gameData.definition_names` (id -> nom) permet de retrouver la
// définition visée par son nom affiché.
function forgeResolveFilterValue(gameData, rawValue) {
if (!rawValue) return rawValue;
var stripped = String(rawValue).trim();
var varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
if (varMatch) {
var variable = gameData.variables && gameData.variables[varMatch[1].trim()];
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
return forgeResolveVariablePath(variable.value, varMatch[2]);
}
var m = FORGE_REF_PATTERN.exec(stripped);
if (!m) return rawValue;
var objectName = m[1].trim(), fieldName = m[2].trim();
var names = gameData.definition_names || {};
for (var defId in names) {
if (names[defId] === objectName) {
var rows = gameData.data && gameData.data[defId];
if (rows && rows.length) {
var col = forgeFieldColumn(gameData.fields_meta && gameData.fields_meta[defId], fieldName);
return rows[0][col];
}
return null;
}
}
return null; // objet de référence introuvable
}
// Port de _clause_matches.
function forgeClauseMatches(gameData, definitionId, row, clause) {
var fieldName = (clause.champ || "").trim();
if (!fieldName) return true;
var operator = clause.operateur || "egal";
var expected = forgeResolveFilterValue(gameData, clause.valeur || "");
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; });
var fieldType = field ? field.type : "texte";
var col = forgeFieldColumn(fieldsMetaForDef, fieldName);
return compareValues(row[col], operator, expected, fieldType);
}
// Port de filter_rows_by_clauses.
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
if (!clauses || !clauses.length) return rows;
if (combinator === "ou") {
return rows.filter(function (r) {
return clauses.some(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
});
}
return rows.filter(function (r) {
return clauses.every(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
});
}
// Port de filter_repeater_rows — filtre appliqué par le widget Répéteur
// lui-même (attrs._filtres_json/_filtre_combinateur, avec repli sur
// l'ancien format à 2 filtres fixes _filtre(2)_champ/...).
function forgeFilterRepeaterRows(gameData, definitionId, rows, attrs) {
var decoded = forgeDecodeClauses(attrs, "_filtres_json", "_filtre_combinateur", "_filtre");
return forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeDecodeClauses, forgeResolveVariablePath, forgeFieldColumn, forgeSlugifyFieldName,
forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows,
};
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
}
+42
View File
@@ -0,0 +1,42 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — étape 5 : orchestrateur appelé par refreshRuntimeData()
// (static/js/play/bindings.js) en mode FORGE_OFFLINE, à la place du
// fetch(runtimePayload) habituel. Rejoue, pour chaque écran "document"
// (kind !== 'jeu_2d'), la même boucle que screens/payload/
// full_game_payload.py : régénère le rendered_html de CHAQUE élément
// (premier niveau ET imbriqué — voir screens/elements/list_elements.py,
// qui donne à chacun son propre rendered_html indépendant, pas seulement
// aux éléments de premier niveau) à partir de gameData déjà en mémoire
// (variables/score/données déjà mutées par apply-actions.js).
//
// Un écran "jeu_2d" (scène 2D) n'a JAMAIS besoin d'être régénéré ici :
// ses objets se mutent déjà directement dans le DOM, sans aller-retour
// serveur ni rendered_html à recalculer (voir applyObjectProperty() dans
// static/js/play/actions.js, appelée par l'action "Modifier un objet de
// scène" — comportement identique en ligne et hors ligne, rien à porter).
function forgeRecomputeElementsForScreenOffline(gameData, screen) {
var elements = screen.elements || [];
var childrenMap = {};
elements.forEach(function (el) {
if (el.parent_id) {
if (!childrenMap[el.parent_id]) childrenMap[el.parent_id] = [];
childrenMap[el.parent_id].push(el);
}
});
var playCtx = { _forge_play_mode: true };
elements.forEach(function (el) {
el.rendered_html = forgeRenderElementHtml(el, gameData, childrenMap, playCtx);
});
}
function forgeRecomputeFullPayloadOffline(gameData) {
(gameData.screens || []).forEach(function (s) {
if (s.kind !== "jeu_2d") forgeRecomputeElementsForScreenOffline(gameData, s);
});
return gameData;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRecomputeElementsForScreenOffline, forgeRecomputeFullPayloadOffline };
global.forgeRenderElementHtml = global.forgeRenderElementHtml || require('./render-element.js').forgeRenderElementHtml;
}
+326
View File
@@ -0,0 +1,326 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port de screens/rendering/render_element_html.py et de
// ses petites briques (attr_string.py, apply_ctx.py, style_string.py,
// visible_attrs.py, parent_flex_direction.py, auto_id.py), regroupées ici
// (un seul fichier JS plutôt qu'un par brique Python : elles sont trop
// petites individuellement pour justifier un fichier chacune côté client).
//
// Widgets à balise simple (texte, titre, bouton, lien, image, vidéo,
// champs de saisie, listes, séparateur, conteneur) rendus directement
// dans ce fichier ; les 10 widgets à "special_render" (case à cocher/
// radio, liste déroulante, groupe de champs, tableau, répéteur, jauge,
// superposition, onglets, icône, personnage) vivent chacun dans leur
// propre fichier (render-jauge.js, render-overlay.js, render-repeater.js,
// render-special-widgets.js, chargés avant celui-ci dans templates/
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
// via forgeSpecialRenderer().
var FORGE_LAYOUT_CAPABLE_WIDGETS = ["conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"];
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
// l'éditeur, qui ne concerne que le port serveur).
var FORGE_SIMPLE_WIDGETS = {
texte: { tag: "p", hasContent: true },
titre: { tag: null, hasContent: true, fixedAttrs: { class: "title" } },
bouton: { tag: "button", hasContent: true, fixedAttrs: { class: "button" } },
lien: { tag: "a", hasContent: true },
image: { tag: "img", hasContent: false, selfClosing: true },
video: { tag: "video", hasContent: false },
champ_texte: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "text", class: "input" } },
champ_email: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "email", class: "input" } },
champ_mot_de_passe: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "password", class: "input" } },
zone_texte: { tag: "textarea", hasContent: false, fixedAttrs: { class: "textarea" } },
liste: { tag: "ul", hasContent: true },
liste_numerotee: { tag: "ol", hasContent: true },
separateur: { tag: "hr", hasContent: false, selfClosing: true },
conteneur: { tag: "div", hasContent: true, fixedAttrs: { class: "box" } },
};
// widget -> clé de special_render (voir screens/widgets/registry.py).
var FORGE_SPECIAL_RENDER_BY_WIDGET = {
case_a_cocher: "checkbox_label", bouton_radio: "checkbox_label", liste_deroulante: "select_options",
groupe_champs: "fieldset_legend", tableau: "table", repeteur_donnees: "repeater",
jauge: "jauge", superposition: "overlay", onglets: "onglets", icone: "icone", personnage: "personnage",
};
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
var FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: "table" } };
function forgeHtmlEscape(s) {
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
}
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
function forgeApplyCtx(text, ctx) {
if (!ctx || !text) return text;
var result = text;
Object.keys(ctx).forEach(function (k) {
if (k === "_forge_play_mode") return; // indicateur interne, pas un {{champ}}
result = result.split("{{" + k + "}}").join(ctx[k]);
});
return result;
}
function forgeAutoId(el) {
return "elt-" + el.id;
}
// Port de parent_flex_direction.py.
function forgeParentFlexDirection(parentEl) {
var style = (parentEl && parentEl.style) || {};
if (style.display !== "flex") return null;
return style["flex-direction"] || "row";
}
// Port de style_string.py — même logique de taille par défaut (100% hors
// répéteur/conteneur, adaptée au contenu + à l'axe flex du parent pour un
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
function forgeStyleString(el, parentFlexDirection, widgetKey) {
var style = Object.assign({}, el.style || {});
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !("display" in style)) {
style.display = "flex";
if (!("flex-direction" in style)) style["flex-direction"] = "column";
}
var isChild = !!el.parent_id;
var baseStyle;
if (isChild) {
var widthDefault = (parentFlexDirection === "row" || parentFlexDirection === "row-reverse") ? "auto" : "100%";
baseStyle = "width:" + widthDefault + "; height:auto; box-sizing:border-box; margin:0;";
} else {
baseStyle = "width:100%; height:100%; box-sizing:border-box; margin:0;";
}
var hasSizeOverride = false;
var parts = [];
Object.keys(style).forEach(function (k) {
var v = style[k];
if (v === null || v === undefined || v === "") return;
if (typeof v === "object") return; // filet de sécurité, voir style_string.py
if (k === "transform" && !isChild) return;
var strV = String(v).trim();
if ((k === "width" || k === "height") && (strV === "0px" || strV === "0")) return;
if (k === "width" || k === "height") hasSizeOverride = true;
parts.push(k + ":" + v);
});
if (hasSizeOverride) parts.push("flex-shrink:0");
var userStyle = parts.join("; ");
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
}
// Port de attr_string.py.
function forgeAttrString(attrs, extraStyle) {
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
Object.keys(attrs).forEach(function (k) {
var v = attrs[k];
if (v === null || v === undefined || v === "" || v === false) return;
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
});
return parts.join(" ");
}
// Port de visible_attrs.py.
function forgeVisibleAttrs(el, meta, ctx) {
var rawAttrs = el.attributes || {};
var attrs = {};
Object.keys(rawAttrs).forEach(function (k) {
if (k.indexOf("_") === 0) return;
var v = rawAttrs[k];
attrs[k] = typeof v === "string" ? forgeApplyCtx(v, ctx) : v;
});
Object.assign(attrs, meta.fixedAttrs || {});
var extraClasses = Object.keys(rawAttrs)
.filter(function (k) { return k.indexOf("_class_") === 0 && rawAttrs[k]; })
.map(function (k) { return rawAttrs[k]; });
if (extraClasses.length) {
var baseClass = attrs.class || "";
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(" ");
}
if (!("id" in attrs)) attrs.id = forgeAutoId(el);
attrs["data-element-id"] = el.id;
if (el.parent_id) attrs["data-anim-target"] = el.id;
return attrs;
}
// Port de render_children.py.
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
var kids = (childrenMap && childrenMap[el.id]) || [];
var flexDir = forgeParentFlexDirection(el);
return kids.map(function (c) {
return forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir);
}).join("");
}
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
function forgeResolveBoundRowCtx(gameData, attrs) {
var definitionId = attrs._data_definition_id;
if (!definitionId) return null;
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) return null;
var rows = (gameData.data && gameData.data[definitionId]) || [];
var decoded = forgeDecodeClauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", "_data_filtre");
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
if (!rows.length) return null;
var row = rows[0];
var ctx = {};
fieldsMetaForDef.forEach(function (f) {
var v = row[forgeFieldColumn(fieldsMetaForDef, f.name)];
ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v);
});
return ctx;
}
// Port de render_element_html.py:_render_element_type_children — contenu
// d'un exemplaire d'élément de jeu réutilisable posé sur un écran, rejoué
// à chaque affichage depuis gameData.element_type_templates (précalculé
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
// d'éventuels enfants copiés en base.
function forgeRenderElementTypeChildren(el, gameData, ctx) {
var templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
if (!templateTree || !templateTree.top.length) return [null, false];
var isOverlayOnly = templateTree.top.every(function (c) { return c.widget === "superposition"; });
var top = templateTree.top;
if (isOverlayOnly) {
var overlayRoot = Object.assign({}, top[0]);
var newStyle = {};
Object.keys(overlayRoot.style || {}).forEach(function (k) { if (k !== "display") newStyle[k] = overlayRoot.style[k]; });
overlayRoot.style = newStyle;
top = top.slice();
top[0] = overlayRoot;
}
var html = top.map(function (c) {
return forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx);
}).join("");
return [html, isOverlayOnly];
}
// Les 10 widgets à special_render (voir render-jauge.js, render-overlay.js,
// render-repeater.js, render-special-widgets.js — chargés avant ce
// dispatcher dans templates/play.html) sont tous portés (étape 4 du plan
// terminée) ; ce registre reste indirection unique nécessaire pour éviter
// une dépendance circulaire fichier-à-fichier (chacun de ces fichiers a
// besoin, en retour, de forgeRenderElementHtml ci-dessous pour ses propres
// enfants/onglets/superposition).
function forgeSpecialRenderer(specialKey) {
switch (specialKey) {
case "checkbox_label": return forgeRenderCheckboxOrRadio;
case "select_options": return forgeRenderSelect;
case "fieldset_legend": return forgeRenderFieldset;
case "table": return forgeRenderTable;
case "repeater": return forgeRenderRepeater;
case "jauge": return forgeRenderJauge;
case "overlay": return forgeRenderOverlay;
case "onglets": return forgeRenderOnglets;
case "icone": return forgeRenderIcone;
case "personnage": return forgeRenderPersonnage;
default: return null;
}
}
// Port de render_element_html.py — dispatcher principal. `slug` n'existe
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
var widgetKey = el.widget;
var simple = FORGE_SIMPLE_WIDGETS[widgetKey];
var specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
var attrsRaw = el.attributes || {};
var hasVisibilityCondition = attrsRaw._visibilite_cond_mode === "variable" || attrsRaw._visibilite_cond_mode === "objet";
var playMode = !!((ctx || {})._forge_play_mode);
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
return '<div class="visibilityGated" data-element-id="' + el.id + '" style="display:none;"></div>';
}
var hasDataBinding = !!attrsRaw._data_definition_id;
var mark = function (htmlStr) {
var markers = "";
if (hasVisibilityCondition) markers += "<!--visibilityGated-->";
if (hasDataBinding) markers += "<!--dataBound-->";
return htmlStr + markers;
};
var boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
if (boundCtx) ctx = Object.assign({}, ctx || {}, boundCtx);
// meta d'un widget à special_render : fixedAttrs propre (voir
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
var meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
if (specialKey) {
var renderer = forgeSpecialRenderer(specialKey);
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
}
var tag = meta.tag;
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
var niveau = attrsRaw._niveau || "2";
tag = "h" + niveau;
}
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style = forgeStyleString(el, parentFlexDirection, widgetKey);
var hoverText = attrsRaw._survol_texte;
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
if (meta.selfClosing) {
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
}
var content = forgeApplyCtx(el.content || "", ctx);
var childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
var childHtml = childResult[0], isOverlayTemplate = childResult[1];
if (childHtml === null) {
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
} else if (isOverlayTemplate) {
delete attrs.class;
return mark("<div " + forgeAttrString(attrs, style) + ">" + childHtml + "</div>");
}
if (tag === "ul" || tag === "ol") {
var items = content.split("\n").map(function (l) { return l.trim(); }).filter(Boolean);
var inner = items.map(function (item) { return "<li>" + forgeHtmlEscape(item) + "</li>"; }).join("") + childHtml;
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + inner + "</" + tag + ">");
}
var safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, "<br>") : "";
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + safeContent + childHtml + "</" + tag + ">");
}
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
// les 10 widgets à special_render, des fonctions définies dans D'AUTRES
// fichiers (render-jauge.js, render-overlay.js, render-repeater.js,
// render-special-widgets.js) — de vrais globaux dans le navigateur (tous
// chargés ensemble par templates/play.html), mais qui n'existent PAS
// automatiquement ici : un test qui exerce le dispatcher sur un widget à
// special_render doit passer par __tests__/_load_offline_runtime.js
// (charge tout, dans l'ordre, comme le fait play.html) plutôt que
// require('../render-element.js') seul — sans quoi requérir ces fichiers
// D'ICI créerait un cycle (ils ont eux-mêmes besoin, en retour, des
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString,
forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml,
forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer,
FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET,
};
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
var filterMod = require('./filter-repeater-rows.js');
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn;
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
global.forgeFilterRowsByClauses = global.forgeFilterRowsByClauses || filterMod.forgeFilterRowsByClauses;
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
}
+110
View File
@@ -0,0 +1,110 @@
// Export Web/SCORM — port de screens/rendering/render_jauge.py (jauge liée
// à une donnée : remplissage % + dégradé de couleur recalculés depuis un
// champ numérique d'un objet). Lit gameData.data/fields_meta directement
// au lieu d'une requête SQL (voir _read_field_value côté serveur).
function forgeHexToRgb(hexColor) {
var hex = (hexColor || "#5b8cff").replace(/^#/, "");
if (hex.length !== 6) hex = "5b8cff";
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
}
function forgeInterpolateColor(lowHex, highHex, ratio) {
var lo = forgeHexToRgb(lowHex), hi = forgeHexToRgb(highHex);
var mixed = lo.map(function (c, i) { return Math.round(c + (hi[i] - c) * ratio); });
var toHex = function (n) { return n.toString(16).padStart(2, "0"); };
return "#" + mixed.map(toHex).join("");
}
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
// row_id est absent/introuvable (comportement historique, un objet à une
// seule ligne n'est jamais impacté).
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
if (!definitionId || !fieldName) return null;
definitionId = String(definitionId);
var rows = gameData.data && gameData.data[definitionId];
if (!rows || !rows.length) return null;
var row = null;
if (rowId) row = rows.find(function (r) { return String(r.id) === String(rowId); });
if (!row) row = rows[0];
return row[fieldName];
}
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = el.attributes || {};
var nomPosition = attrs._nom_position || "dessus";
var nomAlignement = attrs._nom_alignement || "centre";
var nomPolice = attrs._nom_police || "inherit";
var nomTaille = attrs._nom_taille || "12";
var nomGras = attrs._nom_gras === "1";
var nomItalique = attrs._nom_italique === "1";
var flexDir = nomPosition === "cote" ? "row" : "column";
var alignItems = nomPosition === "cote" ? "center" : "stretch";
var style = forgeStyleString(
el, parentFlexDirection, el.widget
) + " display:flex; flex-direction:" + flexDir + "; align-items:" + alignItems + "; justify-content:center; gap:4px;";
var definitionId = attrs._definition_id;
var rowId = attrs._row_id;
var fieldName = (attrs._champ || "").trim();
var fieldNameNom = (attrs._champ_nom || "").trim();
var minV = parseFloat(attrs._min); if (isNaN(minV)) minV = 0;
var maxV = parseFloat(attrs._max); if (isNaN(maxV)) maxV = 100;
var couleurBasse = attrs._couleur_basse || "#e05252";
var couleurHaute = attrs._couleur_haute || "#4caf50";
var afficherValeur = attrs._afficher_valeur === "1";
var rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
var value = parseFloat(rawValue);
if (isNaN(value)) value = minV;
var span = (maxV - minV) || 1;
var ratio = Math.max(0, Math.min(1, (value - minV) / span));
var percent = Math.round(ratio * 1000) / 10;
var fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
var labelHtml = "";
if (afficherValeur) {
labelHtml =
'<div style="position:absolute; inset:0; display:flex; align-items:center; ' +
'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; ' +
'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">' +
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : "—") + '</div>';
}
var nameHtml = "";
if (fieldNameNom) {
var nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
if (nomValue !== null && nomValue !== undefined && nomValue !== "") {
var textAlign = (nomPosition === "dessus" && nomAlignement === "gauche") ? "left" : "center";
nameHtml =
'<div style="flex:0 0 auto; font-family:' + forgeHtmlEscape(nomPolice) + '; ' +
'font-size:' + forgeHtmlEscape(String(nomTaille)) + 'px; ' +
'font-weight:' + (nomGras ? "700" : "400") + '; ' +
'font-style:' + (nomItalique ? "italic" : "normal") + '; ' +
'color:#e8eaf0; text-align:' + textAlign + ';">' + forgeHtmlEscape(String(nomValue)) + '</div>';
}
}
var extraClasses = Object.keys(attrs)
.filter(function (k) { return k.indexOf("_class_") === 0 && attrs[k]; })
.map(function (k) { return attrs[k]; }).join(" ");
var barClass = forgeHtmlEscape(("progress jaugeBar " + extraClasses).trim());
var bar =
'<progress class="' + barClass + '" value="' + percent + '" max="100" ' +
'style="--bulma-progress-value-background-color:' + fillColor + '; --bulma-progress-bar-background-color:#1c2130; ' +
'margin:0; border-radius:6px; transition:background-color .3s ease;">' + percent + '%</progress>';
var barWrap =
'<div style="position:relative; display:flex; align-items:center; ' +
'flex:1 1 auto; min-height:14px;">' + bar + labelHtml + '</div>';
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) + '">' +
nameHtml + barWrap + '</div>';
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue };
var renderMod = require('./render-element.js');
global.forgeStyleString = global.forgeStyleString || renderMod.forgeStyleString;
global.forgeHtmlEscape = global.forgeHtmlEscape || renderMod.forgeHtmlEscape;
global.forgeAutoId = global.forgeAutoId || renderMod.forgeAutoId;
}
+50
View File
@@ -0,0 +1,50 @@
// Export Web/SCORM — port de screens/rendering/render_overlay.py (3.4,
// overlay/modale réutilisable) : voile plein écran EN MODE JOUABLE
// uniquement (ctx._forge_play_mode), comportement de conteneur normal
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
// mais le port reste fidèle pour ne pas diverger inutilement).
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var isPlayMode = !!(ctx && ctx._forge_play_mode);
var attrsRaw = el.attributes || {};
var boxColor = attrsRaw._couleur_boite || "#1f2430";
var radius = attrsRaw._arrondi || "12";
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style, wrapperClass;
if (isPlayMode) {
var baseOverlayStyle =
"position:fixed; inset:0; z-index:9999; display:flex; " +
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);";
style = baseOverlayStyle + " " + forgeStyleString(el, parentFlexDirection, el.widget);
wrapperClass = "modal is-active";
} else {
style = forgeStyleString(el, parentFlexDirection, el.widget);
wrapperClass = "";
}
var attrParts = ['style="' + forgeHtmlEscape(style) + '"'];
Object.keys(attrs).forEach(function (k) {
var v = attrs[k];
if (v === null || v === undefined || v === "" || v === false) return;
attrParts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
});
var boxStyle =
"background:" + forgeHtmlEscape(String(boxColor)) + "; border-radius:" + forgeHtmlEscape(String(radius)) + "px; " +
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;";
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
var wrapperClassAttr = wrapperClass ? 'class="' + wrapperClass + '" ' : "";
return (
'<div ' + wrapperClassAttr + attrParts.join(" ") + '>' +
'<div class="box forgeOverlayBox" style="' + forgeHtmlEscape(boxStyle) + '">' + childHtml + '</div>' +
'</div>'
);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRenderOverlay };
var renderMod = require('./render-element.js');
["forgeVisibleAttrs", "forgeStyleString", "forgeHtmlEscape", "forgeRenderChildren"].forEach(function (name) {
global[name] = global[name] || renderMod[name];
});
}
+77
View File
@@ -0,0 +1,77 @@
// Export Web/SCORM — port de screens/rendering/render_repeater.py. Lit
// gameData.data/fields_meta (au lieu d'une requête SQL) et
// gameData.element_type_templates (voir full_game_payload.py — l'arbre
// d'un écran-modèle d'élément de jeu réutilisable, précalculé une fois à
// l'export) au lieu de screens/element_types/load_template_tree.py.
function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " overflow:auto;";
var attrsRaw = el.attributes || {};
var definitionId = attrsRaw._definition_id;
var definitionAttr = definitionId ? ' data-definition-id="' + forgeHtmlEscape(String(definitionId)) + '"' : "";
var templateTypeId = attrsRaw._template_type_id;
var template = forgeApplyCtx(el.content || "", ctx);
var children = (childrenMap && childrenMap[el.id]) || [];
if (!definitionId) {
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
'"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>';
}
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) {
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
'"><em>Objet introuvable — a-t-il été supprimé ?</em></div>';
}
var rows = gameData.data[definitionId] || [];
rows = forgeFilterRepeaterRows(gameData, definitionId, rows, attrsRaw);
var fieldCols = {};
fieldsMetaForDef.forEach(function (f) { fieldCols[f.name] = forgeFieldColumn(fieldsMetaForDef, f.name); });
var templateTree = null;
if (templateTypeId) {
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
}
var items = rows.map(function (row) {
var rowCtx = Object.assign({}, ctx || {});
Object.keys(fieldCols).forEach(function (fname) {
var v = row[fieldCols[fname]];
rowCtx[fname] = v === null || v === undefined ? "" : String(v);
});
var inner;
if (templateTree) {
inner = templateTree.top.map(function (c) {
return forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx);
}).join("");
} else if (children.length) {
inner = children.map(function (c) {
return forgeRenderElementHtml(c, gameData, childrenMap, rowCtx);
}).join("");
} else {
var line = template;
Object.keys(rowCtx).forEach(function (fname) {
if (fname === "_forge_play_mode") return;
line = line.split("{{" + fname + "}}").join(rowCtx[fname]);
});
inner = forgeHtmlEscape(line);
}
return '<div class="repeaterItem" data-row-id="' + row.id + '">' + inner + '</div>';
});
var emptyMsg = attrsRaw._filtre_champ ? "Aucune ligne ne correspond au filtre réglé." : "Aucune donnée dans cet objet pour l'instant.";
var innerAll = items.length ? items.join("") : "<em>" + emptyMsg + "</em>";
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '"' + definitionAttr +
' style="' + forgeHtmlEscape(style) + '">' + innerAll + '</div>';
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRenderRepeater };
var renderMod = require('./render-element.js');
["forgeStyleString", "forgeHtmlEscape", "forgeAutoId", "forgeApplyCtx", "forgeRenderElementHtml"].forEach(function (name) {
global[name] = global[name] || renderMod[name];
});
var filterMod = require('./filter-repeater-rows.js');
global.forgeFilterRepeaterRows = global.forgeFilterRepeaterRows || filterMod.forgeFilterRepeaterRows;
global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn;
}
@@ -0,0 +1,143 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — étape 4 : port des widgets à "special_render" les plus
// simples (screens/rendering/render_checkbox_or_radio.py, render_select.py,
// render_fieldset.py, render_table.py, render_icone.py, render_onglets.py).
// Jauge/Superposition/Personnage/Répéteur (logique/données plus lourde)
// vivent dans des fichiers dédiés (render-jauge.js, render-repeater.js).
// Dépend de render-element.js (forgeVisibleAttrs/forgeStyleString/
// forgeAttrString/forgeApplyCtx/forgeHtmlEscape/forgeAutoId/
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var inputType = el.widget === "case_a_cocher" ? "checkbox" : "radio";
var attrs = forgeVisibleAttrs(el, meta, ctx);
attrs.type = inputType;
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; align-items:center; gap:8px;";
var content = forgeApplyCtx(el.content || "", ctx);
return '<label ' + forgeAttrString({ class: inputType }, style) + '><input ' + forgeAttrString(attrs, "") + '> ' + forgeHtmlEscape(content) + '</label>';
}
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var outerAttrs = forgeVisibleAttrs(el, meta, ctx);
var bulmaClasses = outerAttrs.class || "";
var selectAttrs = {}, wrapperAttrs = {};
Object.keys(outerAttrs).forEach(function (k) {
if (k === "id" || k === "data-element-id" || k === "data-anim-target") wrapperAttrs[k] = outerAttrs[k];
else if (k !== "class") selectAttrs[k] = outerAttrs[k];
});
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex;";
var content = forgeApplyCtx(el.content || "", ctx);
var options = content.split("\n").map(function (o) { return o.trim(); }).filter(Boolean);
var optsHtml = options.map(function (o) {
var esc = forgeHtmlEscape(o);
return '<option value="' + esc + '">' + esc + '</option>';
}).join("");
var selectHtml = '<select ' + forgeAttrString(selectAttrs, "") + '>' + optsHtml + '</select>';
var selectWrapClass = forgeHtmlEscape(("select is-fullwidth " + bulmaClasses).trim());
return '<div ' + forgeAttrString(wrapperAttrs, style) + '><div class="' + selectWrapClass + '">' + selectHtml + '</div></div>';
}
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style = forgeStyleString(el, parentFlexDirection, el.widget);
var content = forgeApplyCtx(el.content || "", ctx) || "Groupe de champs";
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
return '<fieldset ' + forgeAttrString(attrs, style) + '><legend>' + forgeHtmlEscape(content) + '</legend>' + childHtml + '</fieldset>';
}
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = forgeVisibleAttrs(el, meta, ctx);
var headerRow = ["1", "true", "on"].indexOf((el.attributes || {})._header_row) !== -1;
var content = forgeApplyCtx(el.content || "", ctx);
var style = forgeStyleString(el, parentFlexDirection, el.widget);
var rows = content.split("\n").filter(function (r) { return r.trim(); });
var htmlRows = rows.map(function (r, i) {
var cells = r.split(";");
var cellTag = (headerRow && i === 0) ? "th" : "td";
var cellsHtml = cells.map(function (c) {
return "<" + cellTag + ">" + forgeHtmlEscape(c.trim()) + "</" + cellTag + ">";
}).join("");
return "<tr>" + cellsHtml + "</tr>";
}).join("");
return '<table ' + forgeAttrString(attrs, style) + '>' + htmlRows + '</table>';
}
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
// Contrairement au serveur, ne revalide pas _icon_slug contre le
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
var rawAttrs = el.attributes || {};
var iconSlug = rawAttrs._icon_slug || "star";
var attrs = forgeVisibleAttrs(el, meta, ctx);
var existingClass = attrs.class || "";
delete attrs.class;
attrs.class = (existingClass + " icon-svg").trim();
var iconUrl = "/static/icons/" + iconSlug + ".svg";
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
return "<i " + forgeAttrString(attrs, style) + "></i>";
}
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var tabs = (childrenMap && childrenMap[el.id]) || [];
var attrs = forgeVisibleAttrs(el, meta, ctx);
var tabsExtraClasses = attrs.class || "";
delete attrs.class;
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; flex-direction:column;";
var groupId = forgeAutoId(el);
if (!tabs.length) {
return '<div ' + forgeAttrString(attrs, style) + '><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>';
}
var flexDir = forgeParentFlexDirection(el);
var barItems = [], panels = [];
tabs.forEach(function (tab, i) {
var label = tab.name || ("Onglet " + (i + 1));
var active = i === 0;
var tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
var targetId = "tabpanel-" + tab.id;
barItems.push(
'<li class="' + (active ? "is-active" : "") + '" data-tab-btn="' + groupId + '" data-tab-target="' + targetId +
'" onclick="forgeShowTab(this)"><a>' + forgeHtmlEscape(String(label)) + '</a></li>'
);
var hiddenStyle = active ? "" : ' style="display:none;"';
panels.push('<div class="forgeTabPanel" id="' + targetId + '" data-tab-group="' + groupId + '"' + hiddenStyle + '>' + tabHtml + '</div>');
});
var tabsClass = forgeHtmlEscape(("tabs " + tabsExtraClasses).trim());
return (
'<div ' + forgeAttrString(attrs, style) + '>' +
'<div class="' + tabsClass + '"><ul>' + barItems.join("") + '</ul></div>' +
'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">' + panels.join("") + '</div>' +
'</div>'
);
}
// Port de render_personnage.py — lit directement gameData.personnage_animations
// (déjà résolu une fois par full_game_payload.py, à l'export) au lieu de
// re-résoudre la bibliothèque de sprites Forge/l'URL personnalisée : ce
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
// rendu côté client.
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = forgeVisibleAttrs(el, meta, ctx);
attrs["data-personnage"] = "1";
var animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
var idleFrame = frames && frames.length ? frames[0] : "";
if (idleFrame) attrs.src = idleFrame;
var style = forgeStyleString(el, parentFlexDirection, el.widget);
return "<img " + forgeAttrString(attrs, style) + ">";
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable,
forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage,
};
var renderMod = require('./render-element.js');
["forgeVisibleAttrs", "forgeStyleString", "forgeAttrString", "forgeApplyCtx", "forgeHtmlEscape",
"forgeAutoId", "forgeParentFlexDirection", "forgeRenderChildren", "forgeRenderElementHtml"].forEach(function (name) {
global[name] = global[name] || renderMod[name];
});
}
+66
View File
@@ -0,0 +1,66 @@
// Export Web/SCORM — étape 6 : pont minimal entre le runtime hors ligne
// (gameData.scoring, voir db/scoring/ côté serveur pour l'équivalent en
// ligne) et l'API SCORM exposée par le LMS. Chargé UNIQUEMENT dans le
// paquet exporté (voir publish/build_scorm_package.py), jamais dans
// /game/<slug>/play ni /jouer/<slug>.
//
// SCORM 1.2 : l'API vit dans une fenêtre ANCÊTRE (le LMS charge ce SCO
// dans un <iframe>), jamais dans window.top garanti — on remonte la
// chaîne opener/parent jusqu'à trouver window.API (algorithme standard
// ADL, voir la doc SCORM 1.2 RTE §3.2).
function forgeScormFindApi(win, triesLeft) {
if (!win || triesLeft <= 0) return null;
if (win.API) return win.API;
if (win.parent && win.parent !== win) return forgeScormFindApi(win.parent, triesLeft - 1);
if (win.opener) return forgeScormFindApi(win.opener, triesLeft - 1);
return null;
}
var forgeScormApi = null;
var forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
function forgeScormInit() {
forgeScormApi = forgeScormFindApi(window, 10);
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScormApi.LMSInitialize("");
}
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> vocabulaire
// figé cmi.core.lesson_status (SCORM 1.2) : pas de mapping 1-pour-1 pour
// "en_cours" (SCORM n'a pas d'équivalent "en cours" hors "incomplete").
var FORGE_TO_SCORM_STATUS = {
non_commence: "not attempted", en_cours: "incomplete", termine: "completed",
reussi: "passed", echoue: "failed",
};
function forgeScormSyncScore(gameData) {
if (!forgeScormApi || !gameData || !gameData.scoring) return;
var score = gameData.scoring.score || 0;
var status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || "incomplete";
var key = score + "|" + status;
if (key === forgeScormLastSync) return;
forgeScormLastSync = key;
forgeScormApi.LMSSetValue("cmi.core.score.raw", String(score));
forgeScormApi.LMSSetValue("cmi.core.lesson_status", status);
forgeScormApi.LMSCommit("");
}
function forgeScormFinish() {
if (!forgeScormApi) return;
forgeScormApi.LMSFinish("");
}
if (typeof window !== "undefined") {
window.addEventListener("load", forgeScormInit);
window.addEventListener("beforeunload", forgeScormFinish);
// gameData est déjà en mémoire (window.FORGE_OFFLINE) — pas de fetch à
// attendre, un intervalle court suffit à refléter score/statut sans
// devoir faire dépendre bindings.js de ce fichier optionnel.
setInterval(function () {
if (typeof gameData !== "undefined") forgeScormSyncScore(gameData);
}, 2000);
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { forgeScormFindApi, forgeScormSyncScore, FORGE_TO_SCORM_STATUS };
}
@@ -0,0 +1,56 @@
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port de screens/rendering/visibility_condition.py
// (is_element_visible). Réutilise forgeResolveFilterValue/
// forgeResolveVariablePath/forgeFieldColumn (filter-repeater-rows.js) et
// compareValues (conditions.js), chargés avant ce fichier.
//
// `ctx` : les {{champ}} de la ligne en cours quand cet élément est
// descendant d'un Répéteur (voir render-element.js) — en mode "objet", si
// le champ réglé s'y trouve déjà, sa valeur est utilisée telle quelle
// plutôt que la ligne la plus récente de l'objet (même raison que côté
// serveur : sans ça, tous les exemplaires d'un même modèle de ligne
// évalueraient la MÊME ligne au lieu de chacun la sienne).
function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
var mode = attrs._visibilite_cond_mode;
if (mode !== "variable" && mode !== "objet") return true;
var operator = attrs._visibilite_cond_operateur || "egal";
var expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || "");
if (mode === "variable") {
var varName = attrs._visibilite_cond_variable;
if (!varName) return true;
var variable = gameData.variables && gameData.variables[varName];
if (!variable) return true; // variable supprimée depuis : ne bloque jamais l'affichage
var value = variable.value;
var path = attrs._visibilite_cond_variable_chemin;
if (path) value = forgeResolveVariablePath(value, path);
return compareValues(value, operator, expected, variable.type || "texte");
}
var definitionId = attrs._visibilite_cond_definition_id;
var fieldName = attrs._visibilite_cond_champ;
if (!definitionId || !fieldName) return true;
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) return true;
var field = fieldsMetaForDef.find(function (f) { return f.name === fieldName; });
var fieldType = field ? field.type : "texte";
if (ctx && Object.prototype.hasOwnProperty.call(ctx, fieldName)) {
return compareValues(ctx[fieldName], operator, expected, fieldType);
}
var rows = gameData.data && gameData.data[definitionId];
if (!rows || !rows.length) return true; // objet sans donnée pour l'instant : ne bloque pas
var row = rows[0]; // convention "état de partie" : une seule ligne
return compareValues(row[forgeFieldColumn(fieldsMetaForDef, fieldName)], operator, expected, fieldType);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeIsElementVisibleOffline };
var filterMod = require('./filter-repeater-rows.js');
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn;
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
}
+82
View File
@@ -55,6 +55,11 @@
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
<!-- Phase 1.2 (feuille de route produit) : paquet STATIQUE (aucun
Python/Flask embarqué, voir publish/build_scorm_package.py), pensé
pour un import dans le LMS du client (Moodle...) — distinct du
zip exécutable ci-dessus, qui a besoin de Windows + double-clic. -->
<button type="button" class="gameNavLink" onclick="window.forgeOpenScormModal && window.forgeOpenScormModal()" title="Exporte le jeu en paquet Web/SCORM importable dans un LMS">🎓 Exporter (Web/SCORM)</button>
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
"Publier" ci-dessus (export .zip mono-joueur) — active/désactive la
route publique /jouer/<slug>, jouable par n'importe quel visiteur
@@ -185,6 +190,83 @@
};
})();
</script>
<div class="modal" id="scormModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Exporter « {{ game.name }} » en Web/SCORM</h2>
<p class="hint mb-4">Construit un paquet SCORM 1.2 (imsmanifest.xml + page web autonome, aucun serveur requis) : importe le .zip obtenu directement dans ton LMS (Moodle...) — le score et le statut de la partie remontent automatiquement.</p>
<div id="scormProgress">
<p class="hint" id="scormStepText">Préparation du jeu...</p>
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="scormProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
</div>
<div id="scormError" class="notification is-danger" style="display:none;"></div>
<div id="scormDone" class="notification is-success" style="display:none;">✅ Téléchargement lancé — importe directement le .zip dans ton LMS.</div>
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</button>
</div>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('scormModal');
var stepText = document.getElementById('scormStepText');
var progressBox = document.getElementById('scormProgress');
var errorBox = document.getElementById('scormError');
var doneBox = document.getElementById('scormDone');
var steps = ["Préparation du jeu...", "Génération du paquet hors ligne...", "Écriture du manifeste SCORM..."];
var stepTimer = null;
window.forgeOpenScormModal = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
modal.classList.add('is-active');
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
stepText.textContent = steps[stepIndex];
}, 1200);
var filename = "{{ game.slug }}-scorm.zip";
fetch("{{ url_for('export_scorm', slug=game.slug) }}", { method: 'POST' })
.then(function (resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var disposition = resp.headers.get('Content-Disposition') || '';
var match = /filename="?([^";]+)"?/.exec(disposition);
if (match) filename = match[1];
return resp.blob();
})
.then(function (blob) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
doneBox.style.display = '';
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
})
.catch(function (err) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
errorBox.style.display = '';
errorBox.textContent = "Échec de l'export (" + err.message + "). Réessaie.";
});
};
window.forgeCloseScormModal = function () {
modal.classList.remove('is-active');
if (stepTimer) clearInterval(stepTimer);
};
})();
</script>
{% endif %}
{% block content %}{% endblock %}
</main>
+32 -11
View File
@@ -4,12 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" type="image/svg+xml" href="{{ asset_url('branding/forge-favicon.svg') }}">
<link rel="stylesheet" href="{{ asset_url('style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}"><style>
<script src="{{ asset_url('csrf_fetch.js') }}"></script>
<link rel="stylesheet" href="{{ asset_url('vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ asset_url('vendor/animate.min.css') }}"><style>
body{ margin:0; background:#000; }
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
@@ -113,6 +113,13 @@
// distinctes (voir état par joueur, Phase 1).
let gameData = {{ payload_json|safe }};
let screensData = gameData.screens;
{% if offline_mode %}
// Export Web/SCORM (voir publish/build_scorm_package.py) : paquet
// autonome sans serveur Forge Engine — actions.js/bindings.js basculent
// sur static/js/play/offline/*.js (ci-dessous) au lieu d'un fetch().
window.FORGE_OFFLINE = true;
var FORGE_PLAY_URLS = {};
{% else %}
var FORGE_PLAY_URLS = {
runtimePayload: {{ runtime_payload_url|tojson }},
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
@@ -121,13 +128,27 @@
flowNodeRunStatus: {{ flow_node_run_status_url|tojson }},
flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }},
};
{% endif %}
</script>
<script src="{{ url_for('static', filename='js/play/screens.js') }}"></script>
<script src="{{ url_for('static', filename='js/play/conditions.js') }}"></script>
<script src="{{ url_for('static', filename='js/play/actions.js') }}"></script>
<script src="{{ url_for('static', filename='js/play/triggers.js') }}"></script>
<script src="{{ url_for('static', filename='js/play/bindings.js') }}"></script>
<script src="{{ url_for('static', filename='js/play/flow-engine.js') }}"></script>
<script src="{{ asset_url('js/play/screens.js') }}"></script>
<script src="{{ asset_url('js/play/conditions.js') }}"></script>
{% if offline_mode %}
<script src="{{ asset_url('js/play/offline/compute-operation.js') }}"></script>
<script src="{{ asset_url('js/play/offline/apply-actions.js') }}"></script>
<script src="{{ asset_url('js/play/offline/filter-repeater-rows.js') }}"></script>
<script src="{{ asset_url('js/play/offline/visibility-condition.js') }}"></script>
<script src="{{ asset_url('js/play/offline/render-jauge.js') }}"></script>
<script src="{{ asset_url('js/play/offline/render-overlay.js') }}"></script>
<script src="{{ asset_url('js/play/offline/render-special-widgets.js') }}"></script>
<script src="{{ asset_url('js/play/offline/render-repeater.js') }}"></script>
<script src="{{ asset_url('js/play/offline/render-element.js') }}"></script>
<script src="{{ asset_url('js/play/offline/full-payload.js') }}"></script>
{% if scorm_api_wrapper_url %}<script src="{{ scorm_api_wrapper_url }}"></script>{% endif %}
{% endif %}
<script src="{{ asset_url('js/play/actions.js') }}"></script>
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
<script>
// L'écran affiché au démarrage doit être choisi AVANT le premier
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se
+74
View File
@@ -0,0 +1,74 @@
"""Export Web/SCORM (bouton "Exporter (Web/SCORM)", voir
routes/publish/export_scorm.py et publish/build_scorm_package.py) —
paquet STATIQUE (aucun Python/Flask embarqué, contrairement à
publish/build_package.py testé dans test_publish.py) : ces tests
vérifient l'assemblage (imsmanifest.xml, index.html en FORGE_OFFLINE,
JS hors ligne présent), jamais un vrai import dans un LMS réel (voir le
plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
automatisée)."""
import zipfile
from tests.test_auth import anon_client # noqa: F401
def test_export_scorm_route_returns_a_zip_attachment(client, game):
resp = client.post(f"/game/{game}/export-scorm")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK"
def test_export_scorm_zip_contains_the_expected_files(client, game):
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
names = set(zf.namelist())
for expected in [
"imsmanifest.xml", "index.html",
"static/style.css", "static/csrf_fetch.js",
"static/js/play/actions.js", "static/js/play/bindings.js",
"static/js/play/offline/full-payload.js",
"static/js/play/offline/apply-actions.js",
"static/js/play/offline/scorm-api.js",
]:
assert expected in names, f"{expected} manquant du paquet SCORM"
# __tests__/ (node:test) n'a rien à faire dans un paquet livré au LMS.
assert not any("__tests__" in n for n in names)
def test_export_scorm_index_html_is_offline_and_self_contained(client, game):
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_OFFLINE = true" in html
# Chemins RELATIFS (static/...) — un /static/... absolu casserait un
# index.html ouvert en local (file://), sans racine de serveur.
assert 'src="static/js/play/actions.js"' in html
assert 'src="/static/' not in html
assert "let gameData = " in html
def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game):
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
manifest = zf.read("imsmanifest.xml").decode("utf-8")
assert 'href="index.html"' in manifest
assert "adlcp:scormtype=\"sco\"" in manifest
def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client):
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "scormexportisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/export-scorm")
assert resp.status_code == 403
finally:
_cleanup_project("scormexportisolation@example.com")