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:
co-authored by
Claude Sonnet 5
parent
a445b72a6e
commit
dbada333d5
@@ -23,7 +23,7 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR)
|
||||
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
|
||||
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
||||
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
|
||||
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"]
|
||||
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play"]
|
||||
|
||||
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Tests de la logique PURE du moteur (aucun accès DOM) — voir le plan de
|
||||
// modularisation JS. Lancé via `node --test static/js/play/__tests__/`.
|
||||
// Ces fonctions lisent des globals `gameData`/`window` (posés par
|
||||
// play.html en navigateur) — on les simule ici avant chaque test.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
const {
|
||||
compareValues,
|
||||
resolveVariablePath,
|
||||
readFieldValue,
|
||||
readVariableValue,
|
||||
evaluateConditionClause,
|
||||
evaluateConditionNode,
|
||||
} = require('../conditions.js');
|
||||
|
||||
function withGameData(data, fn) {
|
||||
const previous = global.gameData;
|
||||
global.gameData = data;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
global.gameData = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test('compareValues — comparaison numérique', () => {
|
||||
assert.equal(compareValues('10', 'superieur', '5'), true);
|
||||
assert.equal(compareValues('3', 'inferieur', '5'), true);
|
||||
assert.equal(compareValues('5', 'egal', '5'), true);
|
||||
assert.equal(compareValues('5', 'different', '5'), false);
|
||||
});
|
||||
|
||||
test('compareValues — repli sur comparaison de chaînes si non numérique', () => {
|
||||
assert.equal(compareValues('abc', 'egal', 'abc'), true);
|
||||
assert.equal(compareValues('abc', 'different', 'xyz'), true);
|
||||
});
|
||||
|
||||
test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => {
|
||||
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
|
||||
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
|
||||
assert.equal(compareValues(true, 'different', 'non', 'booleen'), true);
|
||||
});
|
||||
|
||||
test('resolveVariablePath — chemin vide renvoie la valeur brute', () => {
|
||||
assert.equal(resolveVariablePath('42', ''), '42');
|
||||
});
|
||||
|
||||
test('resolveVariablePath — chemin .champ / [index] chaînable', () => {
|
||||
const raw = JSON.stringify({ inventaire: [{ nom: 'clé' }, { nom: 'épée' }] });
|
||||
assert.equal(resolveVariablePath(raw, '.inventaire[1].nom'), 'épée');
|
||||
});
|
||||
|
||||
test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () => {
|
||||
assert.equal(resolveVariablePath('{pas du json', '.x'), null);
|
||||
assert.equal(resolveVariablePath('{"a":1}', '.b.c'), null);
|
||||
});
|
||||
|
||||
test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => {
|
||||
withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => {
|
||||
window.lastClickedRowId = 7;
|
||||
assert.equal(readFieldValue(1, -1, 'score'), 42);
|
||||
});
|
||||
});
|
||||
|
||||
test('readVariableValue — lit gameData.variables par nom', () => {
|
||||
withGameData({ variables: { points: { type: 'nombre_entier', value: '10' } } }, () => {
|
||||
assert.equal(readVariableValue('points'), '10');
|
||||
assert.equal(readVariableValue('inconnue'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "objet" (comportement historique)', () => {
|
||||
withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => {
|
||||
const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "variable"', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' };
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => {
|
||||
const node = {
|
||||
cond_combinator: 'et',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '3' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => {
|
||||
const node = {
|
||||
cond_combinator: 'ou',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '999' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// ---------- Exécution des actions ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation) :
|
||||
// application d'une propriété d'élément ("Modifier un élément") et
|
||||
// exécution de chaque type de nœud Action. Les URLs de routes serveur
|
||||
// (flow_node_run_data/run_variable) sont injectées par play.html dans
|
||||
// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici).
|
||||
|
||||
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
|
||||
// pilotable par une action "Modifier un élément".
|
||||
var ELEMENT_PROPERTY_CSS = {
|
||||
bg_color: { css: 'backgroundColor', suffix: '' },
|
||||
border_color: { css: 'borderColor', suffix: '' },
|
||||
width: { css: 'width', suffix: '%' },
|
||||
height: { css: 'height', suffix: '%' },
|
||||
};
|
||||
|
||||
function applyElementProperty(targetEl, property, value) {
|
||||
if (property === 'contenu') {
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||||
// imbriqué, à éviter.
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
return;
|
||||
}
|
||||
if (property === 'surbrillance') {
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
||||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
||||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property === 'desactive') {
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
||||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
||||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property === 'visibilite') {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||
if (!spec || value === undefined || value === null) return;
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = 'toggle' + property;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
return;
|
||||
}
|
||||
|
||||
targetEl.style[spec.css] = value + spec.suffix;
|
||||
}
|
||||
|
||||
function runActionNode(node) {
|
||||
if (node.action_type === 'ecran_suivant') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||||
// précise.
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||
// signaler l'événement, jamais choisir un élément — c'est à
|
||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
||||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter(function(e){ return (e.parent_id || null) === parentKey; })
|
||||
.forEach(function(e) {
|
||||
const el = document.querySelector('[data-element-id="' + e.id + '"]');
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
|
||||
else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'attendre') {
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du
|
||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
const seconds = parseFloat(node.data_value);
|
||||
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
} else if (node.action_type === 'modifier_donnee') {
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id : utilisé côté serveur SEULEMENT si ce nœud vise la
|
||||
// ligne cliquée (CLICKED_ROW_ID, voir flow_node_run_data.py) — sans
|
||||
// effet sinon, donc toujours transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'modifier_variable') {
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
// 'rien' : aucune action volontairement.
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// ---------- Évaluation des conditions ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation) : partie
|
||||
// PURE du moteur (aucun accès DOM) — lecture de champs/variables et
|
||||
// comparaison, c'est la logique la plus amenée à grossir (nouvelles
|
||||
// opérations, condition de collision...), donc la plus utile à tester
|
||||
// (voir static/js/play/__tests__/conditions.test.js).
|
||||
|
||||
// Lit la valeur actuelle d'un champ d'objet dans l'instantané de données
|
||||
// du jeu (gameData.data), pour l'évaluation d'une condition.
|
||||
function readFieldValue(definitionId, rowId, fieldName) {
|
||||
// CLICKED_ROW_ID (-1, voir flow_node_run_data.py) : "la ligne de
|
||||
// Répéteur sur laquelle on vient de cliqué" — jamais connue à l'avance
|
||||
// dans l'éditeur (choisie ici via "🖱️ Ligne cliquée (Répéteur)"),
|
||||
// résolue seulement au moment de l'évaluation via le dernier clic
|
||||
// capturé (voir bindClicks() dans triggers.js).
|
||||
if (rowId === -1) rowId = window.lastClickedRowId;
|
||||
const rows = gameData.data[String(definitionId)] || [];
|
||||
const row = rows.find(r => r.id === rowId);
|
||||
return row ? row[fieldName] : undefined;
|
||||
}
|
||||
|
||||
function compareValues(actual, operator, expected, fieldType) {
|
||||
if (fieldType === 'booleen') {
|
||||
const a = actual ? 1 : 0;
|
||||
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
||||
// partout ailleurs pour un champ booléen — une valeur de comparaison
|
||||
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
|
||||
// seulement "1"/"true" (et insensible à la casse, aligné avec
|
||||
// _compare() côté Python, voir filter_repeater_rows.py).
|
||||
const expectedStr = String(expected).trim().toLowerCase();
|
||||
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual), en = parseFloat(expected);
|
||||
const numeric = !isNaN(an) && !isNaN(en);
|
||||
if (numeric) {
|
||||
switch (operator) {
|
||||
case 'egal': return an === en;
|
||||
case 'different': return an !== en;
|
||||
case 'superieur': return an > en;
|
||||
case 'inferieur': return an < en;
|
||||
case 'superieur_egal': return an >= en;
|
||||
case 'inferieur_egal': return an <= en;
|
||||
}
|
||||
}
|
||||
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
||||
const es = (expected === undefined || expected === null) ? '' : String(expected);
|
||||
switch (operator) {
|
||||
case 'egal': return as === es;
|
||||
case 'different': return as !== es;
|
||||
case 'superieur': return as > es;
|
||||
case 'inferieur': return as < es;
|
||||
case 'superieur_egal': return as >= es;
|
||||
case 'inferieur_egal': return as <= es;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lit la valeur ACTUELLE d'une variable globale (gameData.variables,
|
||||
// exposé par full_game_payload.py, tenu à jour par refreshRuntimeData()
|
||||
// après toute action qui en modifie une), pour l'évaluation d'une
|
||||
// condition — équivalent, côté client, de _resolve_filter_value côté
|
||||
// serveur (screens/rendering/filter_repeater_rows.py), mais SANS la
|
||||
// syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais
|
||||
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||
// déroulant, voir screen_edit.html).
|
||||
function readVariableValue(name) {
|
||||
const v = (gameData.variables || {})[name];
|
||||
return v ? v.value : undefined;
|
||||
}
|
||||
|
||||
// Navigue dans une valeur JSON (variable de type "objet"/"tableau")
|
||||
// selon un chemin ".champ"/"[index]" chaînable — équivalent JS de
|
||||
// _resolve_variable_path (même fichier Python que ci-dessus). Chemin
|
||||
// vide -> valeur brute inchangée (le cas normal pour une variable
|
||||
// scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond
|
||||
// à rien -> null, comme côté serveur.
|
||||
function resolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
let current;
|
||||
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
|
||||
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
||||
}
|
||||
return current === undefined ? null : current;
|
||||
}
|
||||
|
||||
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
|
||||
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
|
||||
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
|
||||
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
|
||||
//
|
||||
// Chaque clause peut tester soit un champ d'objet (source absente/"objet",
|
||||
// comportement historique), soit une VARIABLE GLOBALE (source
|
||||
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
|
||||
// cond_variable_chemin).
|
||||
function evaluateConditionClause(clause) {
|
||||
const source = clause.source ?? clause.cond_source ?? 'objet';
|
||||
const operator = clause.operator ?? clause.cond_operator;
|
||||
const expected = clause.value ?? clause.cond_value;
|
||||
if (source === 'variable') {
|
||||
const varName = clause.variable ?? clause.cond_variable;
|
||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||
const varInfo = (gameData.variables || {})[varName];
|
||||
const actual = resolveVariablePath(readVariableValue(varName), path);
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
|
||||
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
|
||||
}
|
||||
|
||||
function evaluateConditionNode(node) {
|
||||
if (node.cond_clauses) {
|
||||
let clauses;
|
||||
try { clauses = JSON.parse(node.cond_clauses); } catch (e) { clauses = null; }
|
||||
if (Array.isArray(clauses) && clauses.length) {
|
||||
const results = clauses.map(evaluateConditionClause);
|
||||
return node.cond_combinator === 'ou' ? results.some(Boolean) : results.every(Boolean);
|
||||
}
|
||||
}
|
||||
return evaluateConditionClause(node);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) importe ces fonctions via
|
||||
// require() — pas de risque en navigateur : `module` n'existe pas là-bas,
|
||||
// cette branche ne s'exécute jamais côté client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ---------- Moteur du graphe de logique ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation) :
|
||||
// parcours du graphe (déclencheur → condition → action) et déclenchement
|
||||
// d'un événement personnalisé.
|
||||
|
||||
// Exécute le graphe de logique de l'écran courant à partir d'un nœud
|
||||
// (typiquement un Déclencheur touché par le joueur). Un même point de
|
||||
// sortie peut être relié à PLUSIEURS nœuds suivants (plusieurs actions
|
||||
// pour un même déclencheur, ou plusieurs suites pour une même branche
|
||||
// Vrai/Faux) — tous les fils sortants d'un port sont donc suivis, pas
|
||||
// seulement le premier. Chaque nœud Action produit son effet puis
|
||||
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
|
||||
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
|
||||
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
|
||||
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNodess
|
||||
// dans triggers.js) quand le nœud de départ vient d'un déclencheur posé
|
||||
// dans l'éditeur d'un élément de jeu réutilisable, sur un de ses propres
|
||||
// enfants. Sans ce paramètre, un tel déclencheur était bien retrouvé
|
||||
// (findTriggerNodess cherche dans tous les écrans) mais son graphe
|
||||
// n'existait jamais dans l'écran réellement affiché : rien ne se
|
||||
// déclenchait, silencieusement.
|
||||
function runFlowFrom(nodeId, flowScreenId) {
|
||||
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
|
||||
if (!flow) return;
|
||||
const nodesById = {};
|
||||
flow.nodes.forEach(n => { nodesById[n.id] = n; });
|
||||
|
||||
function nextEdges(fromId, port) {
|
||||
return flow.edges.filter(e => e.from_node_id === fromId && e.from_port === port);
|
||||
}
|
||||
|
||||
function step(currentId) {
|
||||
const node = nodesById[currentId];
|
||||
if (!node) return;
|
||||
if (node.node_type === 'trigger') {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'condition') {
|
||||
const port = evaluateConditionNode(node) ? 'vrai' : 'faux';
|
||||
nextEdges(node.id, port).forEach(edge => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'action') {
|
||||
runActionNode(node).then(() => {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
});
|
||||
}
|
||||
}
|
||||
step(nodeId);
|
||||
}
|
||||
|
||||
// Déclenche un événement personnalisé (voir screens/custom_events/,
|
||||
// action "declencher_evenement") : une pure NOTIFICATION, sans aucun
|
||||
// paramètre — scan GLOBAL de gameData.flows, même principe que
|
||||
// findTriggerNodess()/runScreenShowTriggers() (triggers.js), puisqu'un
|
||||
// écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur
|
||||
// N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché.
|
||||
// C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
|
||||
// suite condition/action, avec ses réglages habituels — cible fixe,
|
||||
// "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
|
||||
window.dispatchGameEvent = function(eventId) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
const flow = gameData.flows[screenIdKey];
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
|
||||
.forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
// ---------- Affichage des écrans, timeline d'animation ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation — un
|
||||
// fichier = une responsabilité, aussi pour le JS) : navigation entre
|
||||
// écrans, proportion de la zone de jeu, lecture des clips d'animation
|
||||
// posés dans l'éditeur ("Timeline d'animation").
|
||||
|
||||
// Élément à onglets (voir render_onglets.py) : bascule le panneau visible
|
||||
// sans aller-retour serveur — même fonction que dans screen_edit.html,
|
||||
// pour un comportement identique éditeur/jeu.
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function showScreen(id) {
|
||||
// Déjà l'écran affiché : rien à refaire. Sans ce garde-fou, l'action
|
||||
// "Ouvrir la ligne cliquée" (voir plus bas) — pensée pour révéler un
|
||||
// détail SUR LE MÊME écran qu'un Répéteur, en la combinant avec une
|
||||
// action "Modifier un élément → Visibilité" sur le même clic — rejouait
|
||||
// à chaque clic sur une ligne les déclencheurs "À l'affichage de
|
||||
// l'écran" et relançait la timeline d'animation depuis le début, ce qui
|
||||
// donnait l'impression trompeuse d'un rechargement de page (éléments
|
||||
// qui rejouent leur animation d'entrée, visibilité qu'un déclencheur
|
||||
// "affichage" repasse à son état initial en écrasant l'action "Rendre
|
||||
// visible" du même clic...). Aller vers un écran DIFFÉRENT continue de
|
||||
// tout rejouer normalement, comme un vrai premier affichage.
|
||||
if (id === window.currentScreenId) return;
|
||||
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
|
||||
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
|
||||
if (el) {
|
||||
el.style.display = 'block';
|
||||
window.currentScreenId = id;
|
||||
applyScreenAspect(el.dataset.aspect || '9/16');
|
||||
}
|
||||
runScreenShowTriggers(id);
|
||||
runAnimationTimeline(id);
|
||||
}
|
||||
|
||||
// Voir le commentaire sur #playFrame dans le <style> de play.html : la
|
||||
// zone de jeu garde toujours la proportion de l'écran affiché, quelle
|
||||
// que soit la forme réelle de la fenêtre du joueur.
|
||||
function applyScreenAspect(aspect) {
|
||||
var parts = String(aspect).split('/');
|
||||
var w = parseFloat(parts[0]) || 9, h = parseFloat(parts[1]) || 16;
|
||||
var frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.style.setProperty('--ratio-w', w);
|
||||
frame.style.setProperty('--ratio-h', h);
|
||||
}
|
||||
}
|
||||
|
||||
function screenIndexById(id) {
|
||||
return screensData.findIndex(s => s.id === id);
|
||||
}
|
||||
|
||||
// ---------- Timeline d'animation (menu "Ajouter un élément" → panneau
|
||||
// rétractable sous la Logique de la scène) : chaque clip démarre à un
|
||||
// instant précis (start_time, en secondes) après l'affichage de l'écran —
|
||||
// exactement comme une timeline Unity jouée au chargement d'une scène.
|
||||
// Deux types de clip : "animate_css" pose une classe de la bibliothèque
|
||||
// Animate.css (voir animate_css_catalog.py), "custom" joue des
|
||||
// images-clés propres au jeu (voir injectCustomKeyframes ci-dessous). ----------
|
||||
var pendingAnimationTimers = [];
|
||||
|
||||
function injectCustomKeyframes() {
|
||||
// Construit UNE FOIS toutes les @keyframes "personnalisées" de tous les
|
||||
// écrans (les données sont déjà toutes chargées dans gameData) et les
|
||||
// pose dans une unique balise <style>, plutôt que d'en injecter une par
|
||||
// clip à chaque lecture — un nom de règle unique par clip (customAnim_id)
|
||||
// évite toute collision entre deux clips personnalisés différents.
|
||||
var rules = [];
|
||||
Object.keys(gameData.animations || {}).forEach(function(screenId) {
|
||||
(gameData.animations[screenId] || []).forEach(function(clip) {
|
||||
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
|
||||
var keyframes;
|
||||
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
|
||||
var steps = keyframes.map(function(k) {
|
||||
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
||||
return k.percent + '% { ' + props + ' }';
|
||||
}).join(' ');
|
||||
rules.push('@keyframes customAnim_' + clip.id + ' { ' + steps + ' }');
|
||||
});
|
||||
});
|
||||
var styleTag = document.getElementById('customAnimStyles');
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = 'customAnimStyles';
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
styleTag.textContent = rules.join('\n');
|
||||
}
|
||||
|
||||
function applyAnimationClip(clip) {
|
||||
// querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un
|
||||
// écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres
|
||||
// enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce
|
||||
// modèle (rechargé en direct depuis le même écran-modèle, voir
|
||||
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
|
||||
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
|
||||
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
|
||||
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
|
||||
if (!targets.length) return;
|
||||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||||
var iterationCount = clip.iteration_count || '1';
|
||||
var infinite = iterationCount === 'infinite';
|
||||
targets.forEach(function(target) {
|
||||
if (clip.kind === 'custom') {
|
||||
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
|
||||
' ' + delay + 's ' + iterationCount;
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.classList.remove('animate__animated', animClass);
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE
|
||||
// utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet
|
||||
// écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser
|
||||
// un autre modèle), avec une garde anti-boucle et un dédoublonnage par
|
||||
// écran (un même modèle posé plusieurs fois ne doit pas répéter ses
|
||||
// clips). Sans ça, une animation posée directement dans l'éditeur d'un
|
||||
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
|
||||
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
|
||||
function collectAnimationClips(screenId) {
|
||||
var visited = {};
|
||||
var clips = [];
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach(function(el) {
|
||||
if (!el.element_type_id) return;
|
||||
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
visit(screenId);
|
||||
return clips;
|
||||
}
|
||||
|
||||
function runAnimationTimeline(screenId) {
|
||||
// Repart de zéro à chaque affichage de cet écran (comme
|
||||
// runScreenShowTriggers) : on annule d'abord tout clip encore en
|
||||
// attente d'un affichage précédent, pour ne jamais empiler des
|
||||
// déclenchements en double si le joueur revient plusieurs fois de
|
||||
// suite sur le même écran avant la fin de sa timeline.
|
||||
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
|
||||
pendingAnimationTimers = [];
|
||||
var clips = collectAnimationClips(screenId);
|
||||
clips.forEach(function(clip) {
|
||||
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
pendingAnimationTimers.push(t);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// ---------- Déclencheurs : recherche des nœuds + attache des écouteurs ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation).
|
||||
|
||||
// Déclenche tous les nœuds "À l'affichage de l'écran" de CET écran — pas
|
||||
// besoin d'avoir cliqué sur quoi que ce soit : ça couvre le premier
|
||||
// affichage, un retour en arrière, un changement d'écran (ecran_suivant/
|
||||
// ecran_precedent/aller_a/ouvrir_ligne appellent tous showScreen). C'est
|
||||
// ce qui permet à une condition ("SI outil débloqué = faux") + une action
|
||||
// "Modifier un élément → Visibilité" de refléter l'état de la partie sans
|
||||
// attendre un clic (voir 1.2 dans la doc de cadrage Forge Engine).
|
||||
function runScreenShowTriggers(screenId) {
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'affichage'; })
|
||||
.forEach(function(n){ runFlowFrom(n.id); });
|
||||
}
|
||||
|
||||
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
|
||||
// TOUS les nœuds Déclencheur qui lui sont rattachés — ce sont les points
|
||||
// d'entrée des graphes à exécuter. Renvoie un TABLEAU (jamais un seul
|
||||
// nœud) : rien n'empêche de poser plusieurs déclencheurs indépendants
|
||||
// "Au clic" sur le MÊME élément (ex. l'un ouvre une ligne de Répéteur,
|
||||
// l'autre éteint une surbrillance) — un seul clic doit alors exécuter
|
||||
// CHACUN de ces graphes, pas seulement le premier trouvé (régression
|
||||
// vécue : un second déclencheur posé sur un élément qui en avait déjà un
|
||||
// restait silencieusement inerte, aucune erreur, juste jamais exécuté).
|
||||
//
|
||||
// On cherche dans TOUS les écrans (pas seulement l'écran courant) :
|
||||
// bindClicks() attache les gestionnaires une seule fois, sur tous les
|
||||
// éléments de tous les écrans (déjà tous présents dans le DOM, seuls les
|
||||
// écrans non actifs sont masqués) — chaque identifiant d'élément est
|
||||
// unique dans tout le jeu, donc pas d'ambiguïté possible. Cette recherche
|
||||
// globale couvre aussi les écrans-MODÈLES (un élément de jeu réutilisable,
|
||||
// ex. "mail content", posé sur une scène) : un déclencheur "Au clic"/"Au
|
||||
// survol" posé dans l'éditeur du modèle, sur un de SES PROPRES enfants,
|
||||
// est donc retrouvé même quand ce modèle est utilisé ailleurs — d'où le
|
||||
// besoin de renvoyer aussi l'écran d'origine de CHAQUE nœud (screenIdKey),
|
||||
// pas seulement le nœud : runFlowFrom() doit exécuter le graphe dans CET
|
||||
// écran-là (celui du modèle), jamais dans celui réellement affiché, sans
|
||||
// quoi le nœud trouvé n'existerait pas dans le graphe utilisé et l'action
|
||||
// ne se déclencherait jamais.
|
||||
function findTriggerNodess(elementId, event) {
|
||||
const found = [];
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes.forEach(function(n) {
|
||||
if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
|
||||
found.push({ node: n, screenId: screenIdKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Ré-attache les gestionnaires de clic sur chaque élément — appelé au
|
||||
// chargement, puis à nouveau après un rafraîchissement des données (le
|
||||
// DOM d'un écran est reconstruit après une action "Modifier une donnée",
|
||||
// les nœuds précédents et leurs écouteurs disparaissent avec).
|
||||
// 3.1 (Confort) — interactions au survol : échange le texte affiché
|
||||
// contre data-hover-text pendant que la souris survole l'élément, puis le
|
||||
// restaure au départ de la souris. On ignore volontairement les éléments
|
||||
// qui ont des enfants (ex. un conteneur) pour ne jamais écraser une mise
|
||||
// en page imbriquée avec du texte brut — cette fonctionnalité vise les
|
||||
// éléments de texte simples (nom, lien...), pas les conteneurs.
|
||||
function bindHoverTexts() {
|
||||
document.querySelectorAll('[data-hover-text]').forEach(function(el) {
|
||||
if (el.dataset.hoverBound) return;
|
||||
if (el.children.length > 0) return;
|
||||
el.dataset.hoverBound = '1';
|
||||
var original = el.textContent;
|
||||
el.addEventListener('mouseenter', function(){ el.textContent = el.dataset.hoverText; });
|
||||
el.addEventListener('mouseleave', function(){ el.textContent = original; });
|
||||
});
|
||||
}
|
||||
|
||||
function bindClicks() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const triggers = findTriggerNodess(elementId, 'clic');
|
||||
if (triggers.length) {
|
||||
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
||||
// après chaque changement de donnée, mais un élément qui n'est ni un
|
||||
// Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement
|
||||
// à l'autre (seul son innerHTML change pour ceux-là, voir
|
||||
// refreshRuntimeData()) — sans ce garde-fou, addEventListener('click')
|
||||
// s'empilerait à chaque rafraîchissement et un seul clic sur un
|
||||
// bouton "+10 points" finirait par déclencher l'action 2, 3, 10 fois
|
||||
// d'affilée (d'où les sauts de +100/-20/0 constatés au lieu de ±10).
|
||||
if (elDiv.dataset.clickBound) return;
|
||||
elDiv.dataset.clickBound = '1';
|
||||
elDiv.style.cursor = 'pointer';
|
||||
elDiv.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
// Si l'élément cliqué (ou un de ses parents, jusqu'au Répéteur
|
||||
// qui reçoit le gestionnaire) est en fait une LIGNE de Répéteur,
|
||||
// on retrouve son vrai id de donnée (data-row-id, posé par
|
||||
// render_repeater.py) — c'est ce qui permet à l'action "Ouvrir la
|
||||
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
|
||||
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
||||
const rowEl = ev.target.closest('.repeaterItem');
|
||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Déclencheurs "Au survol" / "Fin du survol" — même principe que
|
||||
// bindClicks() (garde-fou anti-doublon compris), mais sur mouseenter/
|
||||
// mouseleave plutôt que click, et les deux événements cherchés
|
||||
// séparément puisque ce sont deux déclencheurs distincts et indépendants
|
||||
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
|
||||
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
|
||||
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
|
||||
function bindHoverTriggers() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
if (elDiv.dataset.hoverTriggerBound) return;
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
||||
if (!enterTriggers.length && !leaveTriggers.length) return;
|
||||
elDiv.dataset.hoverTriggerBound = '1';
|
||||
// Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
|
||||
// le commentaire de findTriggerNodess (plusieurs déclencheurs
|
||||
// indépendants sur le même élément/événement doivent tous s'exécuter).
|
||||
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
|
||||
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
|
||||
});
|
||||
}
|
||||
+19
-973
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,26 @@ def game(client):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
|
||||
_PLAY_JS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js", "play")
|
||||
|
||||
|
||||
def play_js_bundle():
|
||||
"""Concatène tout le JS du moteur jouable (voir le plan de
|
||||
modularisation — static/js/play/, un fichier par responsabilité,
|
||||
extrait de l'ancien <script> unique de templates/play.html). Plusieurs
|
||||
tests vérifient que "le JS livré au joueur" contient telle fonction/
|
||||
telle chaîne, sans dépendre d'un navigateur — avant le découpage, ça
|
||||
revenait à chercher dans le HTML de /game/<slug>/play (tout était
|
||||
inline) ; maintenant la logique vit dans ces fichiers statiques."""
|
||||
parts = []
|
||||
for name in sorted(os.listdir(_PLAY_JS_DIR)):
|
||||
path = os.path.join(_PLAY_JS_DIR, name)
|
||||
if os.path.isfile(path) and name.endswith(".js"):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
parts.append(f.read())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mail_object(client, game):
|
||||
"""Un objet "Mail" avec deux champs texte (sujet, expediteur), pour les
|
||||
|
||||
@@ -4,6 +4,8 @@ et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -98,8 +100,9 @@ def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "animate.min.css" in html
|
||||
assert "runAnimationTimeline" in html
|
||||
assert "injectCustomKeyframes" in html
|
||||
js = play_js_bundle()
|
||||
assert "runAnimationTimeline" in js
|
||||
assert "injectCustomKeyframes" in js
|
||||
|
||||
|
||||
def test_play_page_payload_includes_clip(client, game):
|
||||
|
||||
@@ -10,6 +10,8 @@ vérifient les tests de non-régression ci-dessous, en plus des tests dédiés
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -49,10 +51,9 @@ def test_legacy_single_clause_condition_still_works_without_cond_clauses(client,
|
||||
)
|
||||
node = resp.get_json()
|
||||
assert node["cond_clauses"] is None
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "evaluateConditionClause" in html
|
||||
assert "cond_clauses" in html
|
||||
js = play_js_bundle()
|
||||
assert "evaluateConditionClause" in js
|
||||
assert "cond_clauses" in js
|
||||
|
||||
|
||||
def test_condition_node_persists_clauses_and_combinator(client, game):
|
||||
@@ -102,7 +103,6 @@ def test_condition_combinator_defaults_to_et_when_absent(client, game):
|
||||
|
||||
|
||||
def test_play_page_exposes_combinator_logic(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "cond_combinator" in html
|
||||
assert "'ou'" in html
|
||||
js = play_js_bundle()
|
||||
assert "cond_combinator" in js
|
||||
assert "'ou'" in js
|
||||
|
||||
+12
-14
@@ -5,6 +5,8 @@ d'un élément après décision."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -72,11 +74,10 @@ def test_survol_trigger_node_persists(client, game):
|
||||
def test_play_page_exposes_hover_trigger_runtime(client, game):
|
||||
"""bindHoverTriggers() (mouseenter/mouseleave -> runFlowFrom) doit être
|
||||
exposé et appelé, exactement comme bindClicks() pour "Au clic"."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "bindHoverTriggers" in html
|
||||
assert "'survol'" in html
|
||||
assert "'fin_survol'" in html
|
||||
js = play_js_bundle()
|
||||
assert "bindHoverTriggers" in js
|
||||
assert "'survol'" in js
|
||||
assert "'fin_survol'" in js
|
||||
|
||||
|
||||
def test_modifier_element_contenu_action_persists(client, game):
|
||||
@@ -107,9 +108,7 @@ def test_modifier_element_contenu_action_persists(client, game):
|
||||
|
||||
|
||||
def test_play_page_exposes_contenu_property_runtime(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'contenu'" in html
|
||||
assert "'contenu'" in play_js_bundle()
|
||||
|
||||
|
||||
# ---------- 3.2 — Séquences temporisées ----------
|
||||
@@ -128,10 +127,9 @@ def test_attendre_action_node_persists_delay(client, game):
|
||||
|
||||
|
||||
def test_play_page_exposes_wait_action_runtime(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'attendre'" in html
|
||||
assert "setTimeout" in html
|
||||
js = play_js_bundle()
|
||||
assert "'attendre'" in js
|
||||
assert "setTimeout" in js
|
||||
|
||||
|
||||
# ---------- 3.3 — Surbrillance générique dynamique ----------
|
||||
@@ -157,7 +155,7 @@ def test_play_page_exposes_highlight_runtime_and_css(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "forgeHighlight" in html
|
||||
assert "'surbrillance'" in html
|
||||
assert "'surbrillance'" in play_js_bundle()
|
||||
|
||||
|
||||
# ---------- 3.4 — Overlay / modale réutilisable ----------
|
||||
@@ -473,4 +471,4 @@ def test_play_page_exposes_lock_runtime_and_css(client, game):
|
||||
html = resp.data.decode()
|
||||
assert "forgeDisabled" in html
|
||||
assert "pointer-events:none" in html
|
||||
assert "'desactive'" in html
|
||||
assert "'desactive'" in play_js_bundle()
|
||||
|
||||
+9
-9
@@ -2,6 +2,8 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -143,11 +145,10 @@ def test_play_page_exposes_row_bindings_helpers(client, game):
|
||||
résoudre les {{champ}} de la ligne ouverte (voir README, section
|
||||
"Cliquer une ligne de Répéteur") — vérifie que le JS livré au joueur
|
||||
contient bien les briques attendues, sans dépendre d'un navigateur."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "applyOpenRowBindings" in html
|
||||
assert "lastClickedRowId" in html
|
||||
assert "'ouvrir_ligne'" in html
|
||||
js = play_js_bundle()
|
||||
assert "applyOpenRowBindings" in js
|
||||
assert "lastClickedRowId" in js
|
||||
assert "'ouvrir_ligne'" in js
|
||||
|
||||
|
||||
def test_repeater_filter_literal_value(client, game, mail_object):
|
||||
@@ -224,7 +225,6 @@ def test_play_page_exposes_screen_show_trigger_helper(client, game):
|
||||
"""Le runtime jouable doit exécuter les nœuds "affichage" à chaque fois
|
||||
qu'un écran est montré (premier affichage, retour en arrière, données
|
||||
modifiées) — voir README, section correspondante."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "runScreenShowTriggers" in html
|
||||
assert "'affichage'" in html
|
||||
js = play_js_bundle()
|
||||
assert "runScreenShowTriggers" in js
|
||||
assert "'affichage'" in js
|
||||
|
||||
+5
-4
@@ -3,6 +3,8 @@ import re
|
||||
|
||||
import db
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -265,7 +267,6 @@ def test_play_page_click_binding_guards_against_duplicate_listeners(client, game
|
||||
ré-attachement — un vrai test de non-régression comportementale (clics
|
||||
répétés via un navigateur réel) a été exécuté manuellement pour valider
|
||||
le correctif : 5 clics sur +10 -> exactement +50, jamais plus."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "clickBound" in html
|
||||
assert "if (elDiv.dataset.clickBound) return;" in html
|
||||
js = play_js_bundle()
|
||||
assert "clickBound" in js
|
||||
assert "if (elDiv.dataset.clickBound) return;" in js
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -36,7 +38,6 @@ def test_play_page_exposes_activer_onglet_runtime(client, game):
|
||||
"""Le runtime jouable doit savoir exécuter "activer_onglet" (montrer un
|
||||
élément, masquer ses frères) — vérifie que le JS livré au joueur
|
||||
contient bien cette logique, sans dépendre d'un navigateur."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'activer_onglet'" in html
|
||||
assert "parentKey" in html
|
||||
js = play_js_bundle()
|
||||
assert "'activer_onglet'" in js
|
||||
assert "parentKey" in js
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
ne se déforment pas selon la fenêtre du joueur."""
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -137,4 +139,4 @@ def test_play_payload_includes_preview_aspect_per_screen(client, game):
|
||||
def test_play_page_has_letterboxing_frame(client, game):
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert 'id="playFrame"' in html
|
||||
assert "applyScreenAspect" in html
|
||||
assert "applyScreenAspect" in play_js_bundle()
|
||||
|
||||
Reference in New Issue
Block a user