Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
326 lines
17 KiB
JavaScript
326 lines
17 KiB
JavaScript
// ---------- Données liées : résolution des placeholders, rafraîchissement ----------
|
|
// Extrait de templates/game/play.html (voir plan de modularisation).
|
|
|
|
// Résout, sur l'écran couramment affiché, les placeholders "champ entre
|
|
// doubles accolades" restés tels quels dans le HTML statique (hors
|
|
// Répéteur, donc jamais résolus au moment du rendu serveur) avec les
|
|
// valeurs de la DERNIÈRE ligne ouverte via l'action "Ouvrir la ligne
|
|
// cliquée" (window.currentOpenRow). On part à chaque fois du HTML
|
|
// D'ORIGINE (el.rendered_html, jamais modifié) et pas du DOM déjà
|
|
// affiché, sinon rouvrir un mail différent laisserait les valeurs du mail
|
|
// précédent là où le nouveau mail n'a pas ce champ.
|
|
//
|
|
// Note : on construit les accolades doubles caractère par caractère
|
|
// (PLACEHOLDER_OPEN/CLOSE) plutôt que de les écrire en dur — {{ et }}
|
|
// adjacents dans templates/game/play.html seraient interprétés par Jinja (le
|
|
// moteur de templates serveur) comme DU JINJA ; ici, dans un fichier
|
|
// statique jamais passé par Jinja, ce n'est plus nécessaire, mais la
|
|
// construction est conservée pour rester visuellement cohérente avec le
|
|
// reste du moteur et éviter toute confusion en relisant le code.
|
|
const PLACEHOLDER_OPEN = '{' + '{';
|
|
const PLACEHOLDER_CLOSE = '}' + '}';
|
|
|
|
// Partagée entre applyOpenRowBindings() et _forgeApplyFreshRuntimeData()
|
|
// (même algorithme, dupliqué à l'identique dans les deux avant ce
|
|
// refactor S4144) : `byId` explicite en paramètre plutôt que capturé par
|
|
// fermeture, puisque chaque appelant construit sa PROPRE table (éléments
|
|
// de l'écran au moment de son propre appel, jamais partagés entre les
|
|
// deux).
|
|
function isDescendantOfElement(byId, e, ancestorId) {
|
|
let p = e.parent_id ? byId[e.parent_id] : null;
|
|
while (p) {
|
|
if (p.id === ancestorId) return true;
|
|
p = p.parent_id ? byId[p.parent_id] : null;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function applyOpenRowBindings() {
|
|
if (!window.currentOpenRow) return;
|
|
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
|
const row = rows.find((r) => r.id === window.currentOpenRow.rowId);
|
|
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
|
const screenDiv = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"]`);
|
|
if (!row || !screenData || !screenDiv) return;
|
|
|
|
// Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les
|
|
// éléments qui portent vraiment un {{champ}} non résolu — et non plus
|
|
// seulement les éléments de PREMIER NIVEAU de l'écran. Avant ce
|
|
// correctif, un panneau de détail niché à 2-3 niveaux (ex. "mail
|
|
// content" posé dans un conteneur, lui-même dans un autre) forçait à
|
|
// regénérer TOUT son ancêtre de premier niveau — potentiellement
|
|
// l'écran entier (jauges, onglets, Répéteur compris) s'il n'y avait
|
|
// qu'un seul gros conteneur racine — d'où l'impression trompeuse d'un
|
|
// rechargement de page (flash à vide) à chaque clic sur une ligne.
|
|
//
|
|
// Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que
|
|
// les plus SPÉCIFIQUES (les plus profonds) : si un élément ET l'un de
|
|
// ses DESCENDANTS sont tous deux candidats, seul le descendant est
|
|
// régénéré (rendered_html d'un conteneur inclut TOUJOURS celui de ses
|
|
// descendants, donc un conteneur "candidat" ne l'est souvent que parce
|
|
// qu'un descendant à lui l'est — le garder en plus regénérerait
|
|
// inutilement tout son sous-arbre, à l'exact opposé du but recherché).
|
|
// ATTENTION : la version précédente de ce filtre vérifiait l'inverse
|
|
// (exclure un élément si un ANCÊTRE est candidat) — comme un ancêtre
|
|
// est presque toujours "candidat" dès qu'un descendant l'est (son HTML
|
|
// le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine
|
|
// de l'écran, annulant complètement l'effet recherché par ce correctif.
|
|
const byId = {};
|
|
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
|
function hasUnresolvedPlaceholder(e) {
|
|
return !!e.rendered_html?.includes(PLACEHOLDER_OPEN);
|
|
}
|
|
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
|
const targets = candidates.filter(
|
|
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)),
|
|
);
|
|
|
|
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
|
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
|
|
// mais posé à la main puisqu'il n'y a pas de vrai DOM virtuel ici :
|
|
// - dans un nœud TEXTE : {{champ}} est coupé en 3 (texte avant, un
|
|
// <span data-bind-field="champ"> qui portera la valeur, texte
|
|
// après) LA PREMIÈRE FOIS SEULEMENT ; les ouvertures suivantes se
|
|
// contentent de changer le textContent de ce span.
|
|
// - dans un ATTRIBUT (ex. href d'un lien "{{link_real_url}}") : le
|
|
// gabarit d'origine est mémorisé sur un data-bind-attr-<nom> lors du
|
|
// premier passage, pour pouvoir recalculer et réécrire l'attribut à
|
|
// chaque fois sans jamais avoir besoin de reconstruire le nœud.
|
|
// Avant ce correctif, tout le sous-arbre ciblé était détruit et recréé
|
|
// depuis zéro à chaque clic sur une ligne — ce qui causait un flash
|
|
// visuel (perte de tout état transitoire posé par "Modifier un
|
|
// élément", donnant l'impression trompeuse d'un rechargement de page).
|
|
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
|
|
// rien à sauvegarder ni à restaurer.
|
|
const placeholderRe = new RegExp(`${PLACEHOLDER_OPEN}([^{}]+)${PLACEHOLDER_CLOSE}`, 'g');
|
|
|
|
function resolveTextNodes(root) {
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
const toSplit = [];
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
if (node.nodeValue.includes(PLACEHOLDER_OPEN)) toSplit.push(node);
|
|
}
|
|
toSplit.forEach((textNode) => {
|
|
const raw = textNode.nodeValue;
|
|
placeholderRe.lastIndex = 0;
|
|
const frag = document.createDocumentFragment();
|
|
let lastIndex = 0; let
|
|
match;
|
|
while ((match = placeholderRe.exec(raw))) {
|
|
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
|
|
const span = document.createElement('span');
|
|
span.dataset.bindField = match[1];
|
|
span.textContent = row[match[1]] == null ? '' : String(row[match[1]]);
|
|
frag.appendChild(span);
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
if (lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex)));
|
|
textNode.replaceWith(frag);
|
|
});
|
|
}
|
|
|
|
function updateTextSlots(root) {
|
|
root.querySelectorAll('[data-bind-field]').forEach((slot) => {
|
|
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
|
|
if (slot.textContent !== value) slot.textContent = value;
|
|
});
|
|
}
|
|
|
|
function resolveAttributes(root) {
|
|
[root].concat(Array.from(root.querySelectorAll('*'))).forEach((node) => {
|
|
Array.from(node.attributes).forEach((attr) => {
|
|
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
|
const bindKey = `data-bind-attr-${attr.name}`;
|
|
let template = null;
|
|
if (node.hasAttribute(bindKey)) {
|
|
template = node.getAttribute(bindKey);
|
|
} else if (attr.value.includes(PLACEHOLDER_OPEN)) {
|
|
template = attr.value;
|
|
}
|
|
if (template === null) return;
|
|
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
|
placeholderRe.lastIndex = 0;
|
|
const resolved = template.replace(placeholderRe, (_, fieldName) => (row[fieldName] == null ? '' : String(row[fieldName])));
|
|
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
|
|
});
|
|
});
|
|
}
|
|
|
|
targets.forEach((el) => {
|
|
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
|
if (!target) return;
|
|
if (target.querySelector('[data-bind-field]')) {
|
|
updateTextSlots(target);
|
|
} else {
|
|
resolveTextNodes(target);
|
|
}
|
|
resolveAttributes(target);
|
|
});
|
|
}
|
|
|
|
// Recharge l'état du jeu depuis le serveur (après une action qui a
|
|
// modifié une donnée d'objet) pour refléter la nouvelle valeur — utile
|
|
// pour un Répéteur de données, qui doit réafficher ses lignes à jour.
|
|
// Important : on ne touche QU'AUX éléments qui contiennent réellement un
|
|
// répéteur (repérable par la classe "repeaterItem" dans leur HTML rendu).
|
|
// Reconstruire TOUT l'écran à chaque rafraîchissement effacerait les
|
|
// changements purement visuels faits en même temps par une action
|
|
// "Modifier un élément" (ex: masquer une boîte) déclenchée par le même
|
|
// clic, puisque ces changements ne sont jamais enregistrés côté serveur.
|
|
function refreshRuntimeData() {
|
|
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
|
|
// navigateur") : window.FORGE_OFFLINE (posé UNIQUEMENT dans le paquet
|
|
// exporté, jamais dans /game/<slug>/play ni /jouer/<slug>) recalcule le
|
|
// payload EN MÉMOIRE (static/game/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(() => _forgeApplyFreshRuntimeData(gameData));
|
|
}
|
|
return fetch(FORGE_PLAY_URLS.runtimePayload)
|
|
.then((r) => r.json())
|
|
.then(_forgeApplyFreshRuntimeData);
|
|
}
|
|
|
|
function _forgeApplyFreshRuntimeData(newData) {
|
|
gameData = newData;
|
|
screensData = gameData.screens;
|
|
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
|
const screenDiv = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"]`);
|
|
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
|
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
|
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
|
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
|
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
|
// de clic pour le reste de la partie (plus aucune ligne ne
|
|
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
|
// risque à rappeler même quand rien n'a changé.
|
|
if (screenData && screenDiv) {
|
|
// try/catch : une erreur inattendue pendant la régénération (ex.
|
|
// structure de données imprévue) ne doit jamais empêcher les
|
|
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
|
// tout autre élément cliquable) reste silencieusement inerte pour
|
|
// le reste de la partie, sans le moindre message d'erreur visible.
|
|
try {
|
|
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
|
// à une donnée), "visibilityGated" (condition de visibilité) et
|
|
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
|
|
// render_element_html.py) sont les types d'éléments dont le
|
|
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
|
// d'une action "Modifier un élément" posée à la main dans le
|
|
// graphe — eux seuls doivent être régénérés après un changement
|
|
// de donnée ou de variable.
|
|
//
|
|
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
|
// (pas seulement le premier niveau de l'écran), via leur
|
|
// data-element-id — disponible sur chaque élément rendu, pas
|
|
// seulement les éléments de premier niveau. Avant ce correctif,
|
|
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
|
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
|
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
|
// et se retrouvait régénéré en entier à sa place — potentiellement
|
|
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
|
// qu'un seul gros conteneur racine.
|
|
//
|
|
// Parmi les éléments marqués, on ne garde que les plus
|
|
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
|
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
|
// son rendered_html contient celui de ses descendants — le
|
|
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
|
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
|
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
|
// gardait en pratique que l'ancêtre racine de l'écran et
|
|
// annulait complètement l'effet recherché.)
|
|
const byId = {};
|
|
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
|
const hasMarker = (e) => !!(e.rendered_html && (
|
|
e.rendered_html.includes('repeaterItem')
|
|
|| e.rendered_html.includes('jaugeBar')
|
|
|| e.rendered_html.includes('visibilityGated')
|
|
|| e.rendered_html.includes('dataBound')
|
|
));
|
|
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
|
// HTML juste après la balise concernée (voir _mark() dans
|
|
// render_element_html.py) — jamais comme une classe CSS, à la
|
|
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
|
|
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
|
|
// comportement déjà correct pour ces deux-là). Repérer ce
|
|
// commentaire permet de retrouver, à l'intérieur d'un fragment
|
|
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
|
|
// est niché dans un exemplaire d'élément de jeu réutilisable
|
|
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
|
|
// dialogue), où lui seul porte l'attribut data-element-id qui
|
|
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
|
// de premier niveau existant (l'exemplaire) que parce que son
|
|
// HTML est imbriqué dans le sien.
|
|
const COMMENT_MARKERS = new Set(['visibilityGated', 'dataBound']);
|
|
const findCommentMarkedDescendants = (root) => {
|
|
const found = [];
|
|
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
|
const sib = node.nextSibling;
|
|
const hasCommentMarker = !!(
|
|
sib?.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.has(sib.data)
|
|
);
|
|
// Un élément actuellement CACHÉ par une condition de
|
|
// visibilité (voir render_element_html.py) n'a pas de
|
|
// commentaire marqueur à sa suite — sa balise porte
|
|
// directement la classe "visibilityGated" à la place. Sans
|
|
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
|
|
// n'était jamais retrouvé ici (seul le sens caché->visible
|
|
// l'était, via le commentaire) : son ancien nœud visible
|
|
// restait donc affiché indéfiniment à côté du nouveau,
|
|
// maintenant visible, qui le remplace normalement (ex. une
|
|
// icône "enveloppe fermée" jamais masquée en même temps que
|
|
// "enveloppe ouverte" qui apparaît après avoir cliqué).
|
|
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
|
|
if (hasCommentMarker || isHiddenPlaceholder) {
|
|
found.push(node);
|
|
}
|
|
});
|
|
return found;
|
|
};
|
|
const markedCandidates = screenData.elements.filter(hasMarker);
|
|
markedCandidates
|
|
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)))
|
|
.forEach((el) => {
|
|
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
|
if (!target) return;
|
|
const container = document.createElement('div');
|
|
container.innerHTML = el.rendered_html;
|
|
const newNode = container.firstElementChild;
|
|
if (!newNode) return;
|
|
const innerMarked = findCommentMarkedDescendants(newNode);
|
|
if (innerMarked.length) {
|
|
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
|
|
// Texte "Donnée liée" posé dans un élément de jeu
|
|
// réutilisable) : on ne patche QUE ceux-là, en préservant
|
|
// tout le reste — une boîte de dialogue ne doit ni
|
|
// disparaître ni réapparaître juste parce qu'un texte à
|
|
// l'intérieur a changé.
|
|
innerMarked.forEach((innerNode) => {
|
|
const innerId = innerNode.dataset.elementId;
|
|
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
|
if (liveInner) liveInner.replaceWith(innerNode);
|
|
});
|
|
} else {
|
|
target.replaceWith(newNode);
|
|
}
|
|
});
|
|
applyOpenRowBindings();
|
|
} catch (err) {
|
|
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
|
}
|
|
}
|
|
bindClicks();
|
|
bindHoverTexts();
|
|
bindHoverTriggers();
|
|
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
|
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
|
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
|
// tout de suite, sans attendre un aller-retour d'écran.
|
|
runScreenShowTriggers(window.currentScreenId);
|
|
}
|