Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
260 lines
12 KiB
JavaScript
260 lines
12 KiB
JavaScript
// ---------- É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...), donc la plus utile à tester (voir
|
|
// static/js/play/__tests__/conditions.test.js). SEULE EXCEPTION :
|
|
// elementsOverlap() (Phase 5, condition de collision) touche forcément le
|
|
// DOM (getBoundingClientRect) — isolée dans sa propre fonction, non
|
|
// testée par node:test (voir son commentaire), le reste du fichier reste
|
|
// pur et testable.
|
|
|
|
// 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') {
|
|
// Bug corrigé : une variable booléenne est stockée en TEXTE
|
|
// ("vrai"/"faux", voir gameData.variables) — `actual ? 1 : 0` prenait
|
|
// n'importe quelle chaîne non vide pour vraie, y compris la chaîne
|
|
// "faux" elle-même ! Les deux côtés doivent passer par la MÊME
|
|
// normalisation texte->booléen, pas seulement le côté "expected".
|
|
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
|
// partout ailleurs pour un champ booléen — une valeur tapée "Oui"
|
|
// doit donc être reconnue vraie ici aussi, pas seulement "1"/"true"
|
|
// (et insensible à la casse, aligné avec _compare() côté Python).
|
|
const normalizeBool = function (v) {
|
|
if (v === true) return 1;
|
|
if (v === false || v === null || v === undefined) return 0;
|
|
return ['1', 'true', 'vrai', 'oui'].includes(String(v).trim().toLowerCase()) ? 1 : 0;
|
|
};
|
|
const a = normalizeBool(actual);
|
|
const e = normalizeBool(expected);
|
|
return operator === 'different' ? a !== e : a === e;
|
|
}
|
|
const an = Number.parseFloat(actual); const
|
|
en = Number.parseFloat(expected);
|
|
const numeric = !Number.isNaN(an) && !Number.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;
|
|
default: break;
|
|
}
|
|
}
|
|
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
|
const es = (expected === undefined || expected === null) ? '' : String(expected);
|
|
// "egal"/"different" insensibles à la casse (bug corrigé : une valeur de
|
|
// comparaison texte tapée avec une casse différente de la valeur réelle
|
|
// — ex. "Vrai" vs "vrai" — était jugée différente à tort, alors que le
|
|
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
|
|
// superieur/inferieur restent une comparaison lexicographique brute
|
|
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
|
|
const asLower = as.toLowerCase(); const
|
|
esLower = es.toLowerCase();
|
|
switch (operator) {
|
|
case 'egal': return asLower === esLower;
|
|
case 'different': return asLower !== esLower;
|
|
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[Number.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).
|
|
// Boîte de collision (voir screens/rendering/collision_settings.py) :
|
|
// centrée par défaut sur le rectangle VISUEL (getBoundingClientRect),
|
|
// réduite/décalée si réglée dans le panneau de propriétés — un sprite
|
|
// très paddé (ex. les frames CraftPix) peut ainsi avoir une silhouette de
|
|
// collision bien plus petite que son canevas. `collision` absent (élément
|
|
// de l'éditeur document, ou objet de scène posé avant l'ajout de ce
|
|
// réglage) => le rectangle visuel EXACT, comportement inchangé. Fonction
|
|
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
|
|
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
|
function forgeCollisionRectFromBox(box, collision) {
|
|
const width = collision?.width || box.width;
|
|
const height = collision?.height || box.height;
|
|
const offsetX = collision?.offset_x || 0;
|
|
const offsetY = collision?.offset_y || 0;
|
|
const left = box.left + (box.width - width) / 2 + offsetX;
|
|
const top = box.top + (box.height - height) / 2 + offsetY;
|
|
const shape = collision?.shape || 'rectangle';
|
|
return {
|
|
left, top, right: left + width, bottom: top + height, width, height, shape,
|
|
};
|
|
}
|
|
|
|
function forgeRectanglesOverlap(a, b) {
|
|
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
|
}
|
|
|
|
// Cercle-cercle uniquement (rayon = le plus petit des deux côtés/2) — une
|
|
// paire rectangle/cercle retombe sur un chevauchement de rectangles (repli
|
|
// volontairement approximatif plutôt qu'une vraie intersection cercle-
|
|
// rectangle, hors scope de ce premier réglage).
|
|
function forgeCirclesOverlap(a, b) {
|
|
const acx = a.left + a.width / 2; const acy = a.top + a.height / 2; const
|
|
ar = Math.min(a.width, a.height) / 2;
|
|
const bcx = b.left + b.width / 2; const bcy = b.top + b.height / 2; const
|
|
br = Math.min(b.width, b.height) / 2;
|
|
const dx = acx - bcx; const
|
|
dy = acy - bcy;
|
|
return Math.hypot(dx, dy) < (ar + br);
|
|
}
|
|
|
|
// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX
|
|
// boîtes (voir forgeCollisionRectFromBox, qui pose `shape` dessus) —
|
|
// réutilisée aussi bien par elementsOverlap() ci-dessous (condition de
|
|
// flow) que par forgeWouldCollide() (personnage-controller.js, blocage
|
|
// physique du déplacement au clavier) : UNE seule règle de "qu'est-ce qui
|
|
// se touche", jamais deux implémentations qui pourraient diverger.
|
|
function forgeShapesOverlap(a, b) {
|
|
if (a.shape === 'cercle' && b.shape === 'cercle') return forgeCirclesOverlap(a, b);
|
|
return forgeRectanglesOverlap(a, b);
|
|
}
|
|
|
|
// Phase 5 — condition de collision : chevauchement des BOÎTES DE
|
|
// COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments,
|
|
// positionnées sur leur rectangle RÉEL à l'écran (getBoundingClientRect —
|
|
// reflète les déplacements posés via "Modifier un élément"/"Modifier un
|
|
// objet de scène", voir static/js/play/actions.js, ET la caméra qui suit
|
|
// le personnage, voir personnage-controller.js : un décalage de caméra
|
|
// est un simple transform CSS, déjà reflété par getBoundingClientRect).
|
|
// Aucun élément trouvé (id invalide, élément masqué avec display:none —
|
|
// getBoundingClientRect renvoie alors une boîte de largeur/hauteur
|
|
// nulles), ou collision explicitement désactivée => pas de chevauchement
|
|
// plutôt que de planter.
|
|
function elementsOverlap(elementIdA, elementIdB) {
|
|
const a = document.querySelector(`[data-element-id="${elementIdA}"]`);
|
|
const b = document.querySelector(`[data-element-id="${elementIdB}"]`);
|
|
if (!a || !b) return false;
|
|
const screenData = (typeof screensData !== 'undefined' && typeof window !== 'undefined')
|
|
&& screensData.find((s) => s.id === window.currentScreenId);
|
|
const objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
|
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
|
if (
|
|
objA?.collision?.enabled === false
|
|
|| objB?.collision?.enabled === false
|
|
) {
|
|
return false;
|
|
}
|
|
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA?.collision);
|
|
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB?.collision);
|
|
return forgeShapesOverlap(rectA, rectB);
|
|
}
|
|
|
|
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 === 'collision') {
|
|
// Pas d'opérateur/valeur à comparer (contrairement à "objet"/
|
|
// "variable") : le chevauchement EST directement le booléen vrai/faux
|
|
// du nœud — pour "ne se touchent pas", le créateur relie simplement
|
|
// le port "Faux" plutôt que "Vrai" dans le graphe, comme pour
|
|
// n'importe quelle autre condition.
|
|
return elementsOverlap(clause.element_a ?? clause.cond_element_a, clause.element_b ?? clause.cond_element_b);
|
|
}
|
|
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?.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,
|
|
forgeCollisionRectFromBox,
|
|
forgeRectanglesOverlap,
|
|
forgeCirclesOverlap,
|
|
forgeShapesOverlap,
|
|
};
|
|
}
|