Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS

templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 13:59:11 +02:00
co-authored by Claude Sonnet 5
parent a445b72a6e
commit dbada333d5
17 changed files with 1216 additions and 1016 deletions
+309
View File
@@ -0,0 +1,309 @@
// ---------- Données liées : résolution des placeholders, rafraîchissement ----------
// Extrait de templates/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/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.
var PLACEHOLDER_OPEN = '{' + '{';
var PLACEHOLDER_CLOSE = '}' + '}';
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(function(e){ byId[e.id] = e; });
function hasUnresolvedPlaceholder(e) {
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
}
function isDescendantOf(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;
}
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
const targets = candidates.filter(function(e) {
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(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.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
}
toSplit.forEach(function(textNode) {
const raw = textNode.nodeValue;
placeholderRe.lastIndex = 0;
const frag = document.createDocumentFragment();
let lastIndex = 0, 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(function(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(function(node) {
Array.from(node.attributes).forEach(function(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;
const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
if (template === null) return;
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
placeholderRe.lastIndex = 0;
const resolved = template.replace(placeholderRe, function(_, fieldName) {
return row[fieldName] == null ? '' : String(row[fieldName]);
});
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
});
});
}
targets.forEach(function(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() {
return fetch(FORGE_PLAY_URLS.runtimePayload)
.then(r => r.json())
.then(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(function(e){ byId[e.id] = e; });
function hasMarker(e) {
return !!(e.rendered_html && (
e.rendered_html.indexOf('repeaterItem') !== -1 ||
e.rendered_html.indexOf('jaugeBar') !== -1 ||
e.rendered_html.indexOf('visibilityGated') !== -1 ||
e.rendered_html.indexOf('dataBound') !== -1
));
}
function isDescendantOf(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;
}
// "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 = ['visibilityGated', 'dataBound'];
function findCommentMarkedDescendants(root) {
const found = [];
root.querySelectorAll('[data-element-id]').forEach(function(node) {
const sib = node.nextSibling;
const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1);
// 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(function(e) {
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
}).forEach(function(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(function(innerNode) {
const innerId = innerNode.getAttribute('data-element-id');
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);
});
}