Export Web/SCORM : port complet du runtime jouable côté navigateur
Build and deploy / test-python (push) Successful in 9m20s
Build and deploy / test-js (push) Successful in 1m6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-09-02 07:43:02 +02:00
co-authored by Claude Sonnet 5
parent 9776fbc057
commit f3b72d73c9
32 changed files with 2469 additions and 22 deletions
+102
View File
@@ -0,0 +1,102 @@
"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir
routes/publish/export_scorm.py) : à la différence de build_package.py
(qui embarque Python + Flask, voir player_app.py), ce paquet est
STATIQUE — index.html porte tout le runtime jouable directement en
mémoire (window.FORGE_OFFLINE, voir static/js/play/offline/) puisqu'un
SCORM tourne SEUL dans le LMS du client, sans serveur Forge Engine
disponible. Rien de commun avec le zip .exe hors le sous-ensemble de
static/ et _copy_characters (mêmes assets, mêmes règles de copie
sélective) — réutilisés depuis build_package plutôt que dupliqués."""
import json
import os
import shutil
import tempfile
import zipfile
from flask import render_template
import db
import screens
from .build_package import _copy_characters, _STATIC_ITEMS
from .scorm_manifest import build_manifest_xml
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
def _copy_static_assets(staging_dir, slug):
static_dir = os.path.join(staging_dir, "static")
used_forge_characters = screens.list_used_forge_characters(slug)
for item in _STATIC_ITEMS:
if item == "characters":
_copy_characters(static_dir, used_forge_characters)
continue
src = os.path.join(_BASE_DIR, "static", item)
dst = os.path.join(static_dir, item)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.isdir(src):
# "js/play" inclut déjà static/js/play/offline/ (dont
# scorm-api.js, jamais chargé en ligne mais propre à ce
# paquet) — seuls ses __tests__/ (node:test, inutiles dans
# un LMS) sont exclus.
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__tests__") if item == "js/play" else None)
else:
shutil.copy2(src, dst)
def _list_package_files(staging_dir):
"""Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir
(hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS
pour qu'un validateur strict (ADL SCORM Cloud) accepte le paquet."""
rels = []
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
if rel != "index.html":
rels.append(rel)
return rels
def _zip_dir(staging_dir, zip_path):
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
zf.write(full, rel)
def build_scorm_zip(slug):
"""Construit le zip Web/SCORM et renvoie (chemin, nom du jeu) — à
l'appelant de supprimer le zip une fois la réponse HTTP envoyée (voir
routes/publish/export_scorm.py). Doit être appelé DANS une requête
Flask en cours (render_template a besoin du contexte applicatif)."""
game = db.game_meta(slug)
# PLAYER_SHARED (aucun player_id) : un paquet exporté est joué par une
# seule personne à la fois sur son poste, comme /game/<slug>/play —
# jamais d'état "par joueur" à distinguer hors ligne.
payload = screens.full_game_payload(slug)
staging_dir = tempfile.mkdtemp(prefix="forge_scorm_")
try:
_copy_static_assets(staging_dir, slug)
html = render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename,
)
with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f:
f.write(html)
package_files = _list_package_files(staging_dir)
manifest_xml = build_manifest_xml(slug, game["name"], package_files)
with open(os.path.join(staging_dir, "imsmanifest.xml"), "w", encoding="utf-8") as f:
f.write(manifest_xml)
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_scorm_", suffix=".zip")
os.close(zip_fd)
_zip_dir(staging_dir, zip_path)
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
return zip_path, game["name"]
+48
View File
@@ -0,0 +1,48 @@
"""Génère imsmanifest.xml (SCORM 1.2) pour le paquet Web/SCORM (voir
build_scorm_package.py). SCORM 1.2 plutôt que 2004 : support le plus
large chez les clients visés (Moodle, la plupart des LMS d'entreprise
acceptent les deux, l'inverse est faux)."""
import os
from xml.sax.saxutils import escape
_MANIFEST_TEMPLATE = """<?xml version="1.0" standalone="no" ?>
<manifest identifier="FORGE_{identifier}" version="1"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd
http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
<metadata>
<schema>ADL SCORM</schema>
<schemaversion>1.2</schemaversion>
</metadata>
<organizations default="FORGE_ORG_{identifier}">
<organization identifier="FORGE_ORG_{identifier}">
<title>{title}</title>
<item identifier="FORGE_ITEM_{identifier}" identifierref="FORGE_RES_{identifier}">
<title>{title}</title>
</item>
</organization>
</organizations>
<resources>
<resource identifier="FORGE_RES_{identifier}" type="webcontent" adlcp:scormtype="sco" href="index.html">
<file href="index.html"/>
{file_entries}
</resource>
</resources>
</manifest>
"""
def build_manifest_xml(slug, game_name, package_files):
"""`package_files` : chemins relatifs (depuis la racine du paquet, tel
qu'écrits dans le zip) de TOUS les fichiers autres que index.html —
un LMS strict (SCORM Cloud) rejette un manifeste qui omet un fichier
réellement présent dans le paquet."""
file_entries = "\n".join(
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/")))
for rel in package_files
)
return _MANIFEST_TEMPLATE.format(
identifier=escape(slug), title=escape(game_name), file_entries=file_entries,
)