Export Web/SCORM : port complet du runtime jouable côté navigateur
Phase 1.2 de la feuille de route produit — un paquet SCORM tourne seul dans le LMS du client, sans serveur Forge Engine disponible. Ajoute static/js/play/offline/ (miroir JS de screens/rendering/ et data_actions/, sous window.FORGE_OFFLINE) pour que variables, score, données d'objet, répéteurs et conditions de visibilité fonctionnent entièrement en mémoire côté navigateur ; branche actions.js et bindings.js dessus au lieu d'un fetch() serveur. Ajoute publish/build_scorm_package.py (paquet statique + imsmanifest.xml SCORM 1.2 + wrapper API SCORM) et le bouton "Exporter (Web/SCORM)". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9776fbc057
commit
f3b72d73c9
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ def game_play(slug):
|
|||||||
payload = screens.full_game_payload(slug)
|
payload = screens.full_game_payload(slug)
|
||||||
return render_template(
|
return render_template(
|
||||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
"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),
|
runtime_payload_url=url_for("runtime_payload", slug=slug),
|
||||||
flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999),
|
flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999),
|
||||||
flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999),
|
flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def game_play_public(slug):
|
|||||||
resp = app.make_response(
|
resp = app.make_response(
|
||||||
render_template(
|
render_template(
|
||||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
"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),
|
runtime_payload_url=url_for("runtime_payload_public", slug=slug),
|
||||||
flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999),
|
flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999),
|
||||||
flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999),
|
flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
from . import publish_game
|
from . import publish_game, export_scorm
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -6,6 +6,7 @@ from ..flow.list_flow_nodes import list_flow_nodes
|
|||||||
from ..flow.list_flow_edges import list_flow_edges
|
from ..flow.list_flow_edges import list_flow_edges
|
||||||
from ..animations.list_animation_clips import list_animation_clips
|
from ..animations.list_animation_clips import list_animation_clips
|
||||||
from ..element_types.list_element_types import list_element_types
|
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 ..rendering.personnage_data import resolve_personnage_animations
|
||||||
from ..scenes.list_scene_objects import list_scene_objects
|
from ..scenes.list_scene_objects import list_scene_objects
|
||||||
from ..scenes.render_scene_object import render_scene_object
|
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"]}
|
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
|
||||||
for r in rows
|
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
|
# 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
|
# 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.
|
# modèle est utilisé ailleurs.
|
||||||
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
|
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
|
# Instantané des variables globales, pour qu'un nœud Condition puisse en
|
||||||
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
||||||
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
|
# {{$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)
|
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 {
|
return {
|
||||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||||
"variables": variables, "personnage_animations": personnage_animations,
|
"variables": variables, "personnage_animations": personnage_animations,
|
||||||
|
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,13 @@ function runActionNode(node) {
|
|||||||
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||||
} else if (node.action_type === 'modifier_donnee') {
|
} 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);
|
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
||||||
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
||||||
@@ -264,6 +271,10 @@ function runActionNode(node) {
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => refreshRuntimeData());
|
.then(() => refreshRuntimeData());
|
||||||
} else if (node.action_type === 'modifier_variable') {
|
} 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);
|
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||||
return fetch(runVarUrl, {method: 'POST'})
|
return fetch(runVarUrl, {method: 'POST'})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -271,22 +282,35 @@ function runActionNode(node) {
|
|||||||
} else if (node.action_type === 'modifier_score') {
|
} else if (node.action_type === 'modifier_score') {
|
||||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||||
// apply_score_action.py) : même patron que "Modifier une variable
|
// 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);
|
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||||
return fetch(runScoreUrl, {method: 'POST'})
|
return fetch(runScoreUrl, {method: 'POST'})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => refreshRuntimeData());
|
.then(() => refreshRuntimeData());
|
||||||
} else if (node.action_type === 'definir_statut_partie') {
|
} 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);
|
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||||
return fetch(runStatusUrl, {method: 'POST'})
|
return fetch(runStatusUrl, {method: 'POST'})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => refreshRuntimeData());
|
.then(() => refreshRuntimeData());
|
||||||
} else if (node.action_type === 'ajouter_ligne') {
|
} else if (node.action_type === 'ajouter_ligne') {
|
||||||
// Phase 4 — crée une ligne VIDE côté serveur ; son id devient
|
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
|
||||||
// window.lastInsertedRowId pour que les nœuds "Modifier une donnée"
|
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
|
||||||
// enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕ Dernière ligne
|
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
|
||||||
// ajoutée") sachent quelle ligne renseigner. refreshRuntimeData()
|
// "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||||
// affiche la nouvelle ligne dans tout Répéteur lié dès ce round-trip.
|
// 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);
|
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||||
return fetch(runAddRowUrl, { method: 'POST' })
|
return fetch(runAddRowUrl, { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -418,6 +442,6 @@ function stopAllSpriteAnimations() {
|
|||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||||
applyObjectProperty, clampSceneObjectPosition,
|
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,9 +158,23 @@ function applyOpenRowBindings() {
|
|||||||
// "Modifier un élément" (ex: masquer une boîte) déclenchée par le même
|
// "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.
|
// clic, puisque ces changements ne sont jamais enregistrés côté serveur.
|
||||||
function refreshRuntimeData() {
|
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)
|
return fetch(FORGE_PLAY_URLS.runtimePayload)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(newData => {
|
.then(_forgeApplyFreshRuntimeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _forgeApplyFreshRuntimeData(newData) {
|
||||||
gameData = newData;
|
gameData = newData;
|
||||||
screensData = gameData.screens;
|
screensData = gameData.screens;
|
||||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
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
|
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||||
// tout de suite, sans attendre un aller-retour d'écran.
|
// tout de suite, sans attendre un aller-retour d'écran.
|
||||||
runScreenShowTriggers(window.currentScreenId);
|
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 < 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 < 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'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 '.
|
||||||
|
assert.ok(html.includes("--icon-url:url('/static/icons/star.svg')"));
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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_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>
|
<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>
|
<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 en ligne" (état par joueur, Phase 1) : distinct de
|
||||||
"Publier" ci-dessus (export .zip mono-joueur) — active/désactive la
|
"Publier" ci-dessus (export .zip mono-joueur) — active/désactive la
|
||||||
route publique /jouer/<slug>, jouable par n'importe quel visiteur
|
route publique /jouer/<slug>, jouable par n'importe quel visiteur
|
||||||
@@ -185,6 +190,83 @@
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</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 %}
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+32
-11
@@ -4,12 +4,12 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ game.name }} — Jouer</title>
|
<title>{{ game.name }} — Jouer</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
|
<link rel="icon" type="image/svg+xml" href="{{ asset_url('branding/forge-favicon.svg') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ asset_url('style.css') }}">
|
||||||
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
|
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
|
||||||
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
|
<script src="{{ asset_url('csrf_fetch.js') }}"></script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
|
<link rel="stylesheet" href="{{ asset_url('vendor/fonts/fonts.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}"><style>
|
<link rel="stylesheet" href="{{ asset_url('vendor/animate.min.css') }}"><style>
|
||||||
body{ margin:0; background:#000; }
|
body{ margin:0; background:#000; }
|
||||||
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
|
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
|
||||||
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
|
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
|
||||||
@@ -113,6 +113,13 @@
|
|||||||
// distinctes (voir état par joueur, Phase 1).
|
// distinctes (voir état par joueur, Phase 1).
|
||||||
let gameData = {{ payload_json|safe }};
|
let gameData = {{ payload_json|safe }};
|
||||||
let screensData = gameData.screens;
|
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 = {
|
var FORGE_PLAY_URLS = {
|
||||||
runtimePayload: {{ runtime_payload_url|tojson }},
|
runtimePayload: {{ runtime_payload_url|tojson }},
|
||||||
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
|
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
|
||||||
@@ -121,13 +128,27 @@
|
|||||||
flowNodeRunStatus: {{ flow_node_run_status_url|tojson }},
|
flowNodeRunStatus: {{ flow_node_run_status_url|tojson }},
|
||||||
flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }},
|
flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }},
|
||||||
};
|
};
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/play/screens.js') }}"></script>
|
<script src="{{ asset_url('js/play/screens.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/play/conditions.js') }}"></script>
|
<script src="{{ asset_url('js/play/conditions.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/play/actions.js') }}"></script>
|
{% if offline_mode %}
|
||||||
<script src="{{ url_for('static', filename='js/play/triggers.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/compute-operation.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/play/bindings.js') }}"></script>
|
<script src="{{ asset_url('js/play/offline/apply-actions.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/play/flow-engine.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>
|
<script>
|
||||||
// L'écran affiché au démarrage doit être choisi AVANT le premier
|
// 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
|
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user