Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
@@ -8,33 +8,49 @@ require('../../conditions.js');
global.compareValues = require('../../conditions.js').compareValues;
const filterMod = require('../filter-repeater-rows.js');
Object.assign(global, filterMod);
const visibilityMod = require('../visibility-condition.js');
Object.assign(global, visibilityMod);
const computeMod = require('../compute-operation.js');
Object.assign(global, computeMod);
const applyMod = require('../apply-actions.js');
Object.assign(global, applyMod);
const renderElementMod = require('../render-element.js');
Object.assign(global, renderElementMod);
const jaugeMod = require('../render-jauge.js');
Object.assign(global, jaugeMod);
const overlayMod = require('../render-overlay.js');
Object.assign(global, overlayMod);
const repeaterMod = require('../render-repeater.js');
Object.assign(global, repeaterMod);
const specialMod = require('../render-special-widgets.js');
Object.assign(global, specialMod);
module.exports = Object.assign(
{}, filterMod, visibilityMod, computeMod, applyMod, renderElementMod,
jaugeMod, overlayMod, repeaterMod, specialMod
);
module.exports = {
...filterMod,
...visibilityMod,
...computeMod,
...applyMod,
...renderElementMod,
...jaugeMod,
...overlayMod,
...repeaterMod,
...specialMod,
};
@@ -30,12 +30,14 @@ function withOfflineFetchGuard(fn) {
}
}
test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
test('modifier_variable — mute gameData en mémoire sans fetch()', () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { variables: { score_var: { value: '10', type: 'nombre_entier' } } };
const node = {
action_type: 'modifier_variable',
target_variable: 'score_var', data_operation: 'incrementer', data_value: '5',
target_variable: 'score_var',
data_operation: 'incrementer',
data_value: '5',
};
runActionNode(node);
assert.equal(global.gameData.variables.score_var.value, '15');
@@ -43,7 +45,7 @@ test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
});
});
test("modifier_score — mute gameData.scoring sans fetch()", () => {
test('modifier_score — mute gameData.scoring sans fetch()', () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
const node = { action_type: 'modifier_score', data_operation: 'incrementer', data_value: '3' };
@@ -53,7 +55,7 @@ test("modifier_score — mute gameData.scoring sans fetch()", () => {
});
});
test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () => {
test('definir_statut_partie — mute gameData.scoring.status sans fetch()', () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
const node = { action_type: 'definir_statut_partie', data_value: 'reussi' };
@@ -63,9 +65,9 @@ test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () =
});
});
test("ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()", () => {
test('ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()', () => {
withOfflineFetchGuard((getRefreshCalls) => {
global.gameData = { data: { '1': [{ id: 5 }] }, definition_names: {}, fields_meta: { '1': [] } };
global.gameData = { data: { 1: [{ id: 5 }] }, definition_names: {}, fields_meta: { 1: [] } };
const node = { action_type: 'ajouter_ligne', target_definition_id: 1 };
runActionNode(node);
assert.equal(global.window.lastInsertedRowId, 6);
@@ -18,12 +18,16 @@ function baseGameData() {
},
scoring: { score: 0, status: 'non_commence' },
data: {
'1': [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
1: [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
},
fields_meta: {
'1': [
{ name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10 },
{ name: 'nom', type: 'texte', min_value: null, max_value: null },
1: [
{
name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10,
},
{
name: 'nom', type: 'texte', min_value: null, max_value: null,
},
],
},
};
@@ -65,21 +69,30 @@ test('definir_statut_partie — rejette un statut inconnu', () => {
test('modifier_donnee — bornage min/max comme apply_data_action.py', () => {
const gd = baseGameData();
forgeApplyDataActionOffline(gd, {
target_definition_id: 1, target_row_id: 1, target_field: 'points',
data_operation: 'incrementer', data_value: '100',
target_definition_id: 1,
target_row_id: 1,
target_field: 'points',
data_operation: 'incrementer',
data_value: '100',
});
assert.equal(gd.data['1'][0].points, 10); // borné à max_value=10, pas 105
});
test('modifier_donnee — ligne/champ/objet inconnu échoue proprement', () => {
const gd = baseGameData();
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1' }), false);
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1' }), false);
assert.equal(forgeApplyDataActionOffline(gd, {
target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1',
}), false);
assert.equal(forgeApplyDataActionOffline(gd, {
target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1',
}), false);
});
test('ajouter_ligne — ligne vide, id auto-incrémenté, booléen à 0', () => {
const gd = baseGameData();
gd.fields_meta['1'].push({ name: 'actif', type: 'booleen', min_value: null, max_value: null });
gd.fields_meta['1'].push({
name: 'actif', type: 'booleen', min_value: null, max_value: null,
});
const newId = forgeApplyAddRowActionOffline(gd, { target_definition_id: 1 });
assert.equal(newId, 3); // max existant (2) + 1
const row = gd.data['1'][2];
@@ -15,18 +15,18 @@ function baseGameData() {
niveau: { value: '3', type: 'nombre_entier' },
perso: { value: JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }), type: 'objet' },
},
definition_names: { '1': 'Progression', '2': 'Ennemis' },
definition_names: { 1: 'Progression', 2: 'Ennemis' },
fields_meta: {
// "boss" (relation) : gameData.data (voir full_game_payload.py) est
// déjà indexé par le nom D'AFFICHAGE du champ ("boss"), JAMAIS par sa
// colonne SQL réelle ("boss_id") — contrairement à côté serveur
// (filter_repeater_rows.py::_field_column), qui lit la base fraîche.
'1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
'2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
1: [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
2: [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
},
data: {
'1': [{ id: 1, niveau_actuel: 4, boss: 7 }],
'2': [
1: [{ id: 1, niveau_actuel: 4, boss: 7 }],
2: [
{ id: 1, nom: 'Slime', pv: 10 },
{ id: 2, nom: 'Dragon', pv: 100 },
],
@@ -85,20 +85,20 @@ test('forgeFilterRowsByClauses — ET (toutes vraies) vs OU (au moins une)', ()
{ champ: 'nom', operateur: 'egal', valeur: 'Dragon' },
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
];
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map(r => r.id), [2]);
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map((r) => r.id), [2]);
const orClauses = [
{ champ: 'nom', operateur: 'egal', valeur: 'Slime' },
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
];
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map(r => r.id), [1, 2]);
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map((r) => r.id), [1, 2]);
});
test('forgeFilterRepeaterRows — ancien format 2 filtres fixes (repli)', () => {
const gd = baseGameData();
const attrs = { _filtre_champ: 'nom', _filtre_operateur: 'egal', _filtre_valeur: 'Slime' };
const result = forgeFilterRepeaterRows(gd, '2', gd.data['2'], attrs);
assert.deepEqual(result.map(r => r.id), [1]);
assert.deepEqual(result.map((r) => r.id), [1]);
});
test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', () => {
@@ -17,16 +17,26 @@ function baseGameData() {
element_type_templates: {},
screens: [
{
id: 1, kind: 'document', preview_aspect: '9/16',
id: 1,
kind: 'document',
preview_aspect: '9/16',
elements: [
{
id: 10, widget: 'texte', content: 'Score : x', attributes: {}, style: {}, parent_id: null,
id: 10,
widget: 'texte',
content: 'Score : x',
attributes: {},
style: {},
parent_id: null,
rendered_html: '<p>ancien rendu</p>',
},
],
},
{
id: 2, kind: 'jeu_2d', scene_width: 960, scene_height: 540,
id: 2,
kind: 'jeu_2d',
scene_width: 960,
scene_height: 540,
elements: [{ id: 20, kind: 'decor', rendered_html: '<img data-object-id="20">' }],
},
],
@@ -40,7 +50,7 @@ test('recalcule rendered_html pour un écran document', () => {
assert.ok(gd.screens[0].elements[0].rendered_html.startsWith('<p '));
});
test("ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)", () => {
test('ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)', () => {
const gd = baseGameData();
forgeRecomputeFullPayloadOffline(gd);
assert.equal(gd.screens[1].elements[0].rendered_html, '<img data-object-id="20">');
@@ -53,7 +63,7 @@ test('un élément imbriqué reçoit aussi son PROPRE rendered_html (pas seuleme
});
gd.screens[0].elements[0].widget = 'conteneur'; // devient le parent
forgeRecomputeFullPayloadOffline(gd);
const child = gd.screens[0].elements.find(e => e.id === 11);
const child = gd.screens[0].elements.find((e) => e.id === 11);
assert.ok(child.rendered_html.includes('enfant'));
});
@@ -62,8 +72,10 @@ test('reflète une mutation déjà appliquée (score/variable) via la Donnée li
gd.variables.niveau = { value: '5', type: 'nombre_entier' };
gd.screens[0].elements[0].content = 'Visible';
gd.screens[0].elements[0].attributes = {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '5',
_visibilite_cond_mode: 'variable',
_visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur_egal',
_visibilite_cond_valeur: '5',
};
forgeRecomputeFullPayloadOffline(gd);
assert.ok(gd.screens[0].elements[0].rendered_html.includes('Visible'));
@@ -9,11 +9,15 @@ const assert = require('node:assert/strict');
const { forgeRenderElementHtml, forgeStyleString, forgeAttrString } = require('../render-element.js');
function baseGameData() {
return { variables: {}, definition_names: {}, fields_meta: {}, data: {} };
return {
variables: {}, definition_names: {}, fields_meta: {}, data: {},
};
}
test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
const el = { id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {} };
const el = {
id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<p '));
assert.ok(html.includes('a &lt; b<br>ligne 2'));
@@ -21,14 +25,18 @@ test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
});
test('titre — balise dynamique h1/h2/h3 selon _niveau', () => {
const el = { id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {} };
const el = {
id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<h1 '));
assert.ok(html.includes('class="title"'));
});
test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () => {
const el = { id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {} };
const el = {
id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<button '));
assert.ok(html.includes('class="button"'));
@@ -36,7 +44,9 @@ test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () =
});
test('image — self-closing, pas de contenu ni enfants', () => {
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
const el = {
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.equal(html.indexOf('</img>'), -1);
assert.ok(html.startsWith('<img '));
@@ -44,26 +54,36 @@ test('image — self-closing, pas de contenu ni enfants', () => {
});
test('image — alt="" par défaut (RGAA 1.1.1, décoratif faute de champ de description dédié)', () => {
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
const el = {
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('alt=""'));
});
test('image — un alt déjà posé par l\'auteur n\'est jamais écrasé', () => {
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {} };
const el = {
id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('alt="Un chat noir"'));
});
test('liste — une <li> par ligne de contenu, échappée', () => {
const el = { id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {} };
const el = {
id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('<li>Pomme</li><li>Poire &lt; Kiwi</li>'));
});
test('conteneur avec enfants — rendu récursif', () => {
const parent = { id: 10, widget: 'conteneur', content: '', attributes: {}, style: {} };
const child = { id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10 };
const parent = {
id: 10, widget: 'conteneur', content: '', attributes: {}, style: {},
};
const child = {
id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10,
};
const html = forgeRenderElementHtml(parent, baseGameData(), { 10: [child] }, {});
assert.ok(html.includes('>enfant<'));
});
@@ -72,10 +92,15 @@ test('condition de visibilité fausse en mode jouable -> placeholder masqué', (
const gd = baseGameData();
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
const el = {
id: 6, widget: 'texte', content: 'secret', style: {},
id: 6,
widget: 'texte',
content: 'secret',
style: {},
attributes: {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
_visibilite_cond_mode: 'variable',
_visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'egal',
_visibilite_cond_valeur: '99',
},
};
const html = forgeRenderElementHtml(el, gd, {}, { _forge_play_mode: true });
@@ -88,10 +113,15 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
const gd = baseGameData();
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
const el = {
id: 7, widget: 'texte', content: 'visible en édition', style: {},
id: 7,
widget: 'texte',
content: 'visible en édition',
style: {},
attributes: {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
_visibilite_cond_mode: 'variable',
_visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'egal',
_visibilite_cond_valeur: '99',
},
};
const html = forgeRenderElementHtml(el, gd, {}, {}); // pas de _forge_play_mode
@@ -100,7 +130,9 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
});
test('{{champ}} substitué dans le contenu (contexte de Répéteur)', () => {
const el = { id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {} };
const el = {
id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, { nom: 'Alice' });
assert.ok(html.includes('Bonjour Alice'));
});
@@ -113,7 +145,9 @@ test('forgeStyleString — largeur par défaut selon axe flex du parent (enfant)
});
test('forgeAttrString — booléen True = attribut sans valeur, False/null omis', () => {
const s = forgeAttrString({ required: true, disabled: false, placeholder: null, name: 'x' }, 'color:red;');
const s = forgeAttrString({
required: true, disabled: false, placeholder: null, name: 'x',
}, 'color:red;');
assert.ok(s.includes('style="color:red;"'));
assert.ok(s.includes('required'));
assert.ok(!s.includes('disabled'));
@@ -8,35 +8,43 @@ const { forgeRenderElementHtml } = require('./_load_offline_runtime.js');
function baseGameData() {
return {
variables: {},
definition_names: { '1': 'Scores' },
fields_meta: { '1': [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
data: { '1': [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
personnage_animations: { '5': { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
definition_names: { 1: 'Scores' },
fields_meta: { 1: [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
data: { 1: [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
personnage_animations: { 5: { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
element_type_templates: {},
};
}
test('case à cocher — <label class="checkbox"><input type="checkbox">', () => {
const el = { id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {} };
const el = {
id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
// style toujours avant les autres attributs (voir forgeAttrString /
// attr_string.py — le même ordre côté serveur).
assert.ok(html.startsWith('<label style='));
assert.ok(html.includes('class="checkbox"'));
assert.ok(html.includes('type="checkbox"'));
assert.ok(html.includes("J&#x27;accepte")); // échappé, comme html.escape() côté serveur
assert.ok(html.includes('J&#x27;accepte')); // échappé, comme html.escape() côté serveur
});
test('liste déroulante — une <option> par ligne de contenu', () => {
const el = { id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {} };
const el = {
id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('<div class="select is-fullwidth">'));
assert.equal((html.match(/<option/g) || []).length, 3);
});
test('groupe de champs — <fieldset><legend> + enfants', () => {
const parent = { id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {} };
const child = { id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3 };
const parent = {
id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {},
};
const child = {
id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3,
};
const html = forgeRenderElementHtml(parent, baseGameData(), { 3: [child] }, {});
assert.ok(html.startsWith('<fieldset '));
assert.ok(html.includes('<legend>Coordonnées</legend>'));
@@ -44,22 +52,28 @@ test('groupe de champs — <fieldset><legend> + enfants', () => {
});
test('tableau — une ligne par ligne de contenu, cellules séparées par ";"', () => {
const el = { id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {} };
const el = {
id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('<th>Nom</th><th>Score</th>'));
assert.ok(html.includes('<td>Alice</td><td>42</td>'));
});
test('icône — repli sur "star" si _icon_slug absent', () => {
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
const el = {
id: 6, widget: 'icone', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
// le style entier passe par forgeHtmlEscape (comme html.escape() côté
// serveur) : l'apostrophe de url('...') devient &#x27;.
assert.ok(html.includes("--icon-url:url(&#x27;icons/star.svg&#x27;)"));
assert.ok(html.includes('--icon-url:url(&#x27;icons/star.svg&#x27;)'));
});
test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute de description)', () => {
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
const el = {
id: 6, widget: 'icone', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('aria-hidden="true"'));
});
@@ -72,17 +86,25 @@ test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute d
// l'export, voir build_scorm_package.py::_build_icon_data_uris) contourne
// entièrement le problème : aucune requête réseau séparée pour une data: URI.
test('icône — utilise gameData.icon_data_uris quand pré-encodée (contourne le blocage CORS de Firefox sous file://)', () => {
const el = { id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {} };
const el = {
id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {},
};
const gd = baseGameData();
gd.icon_data_uris = { envelope: 'data:image/svg+xml;base64,QUJD' };
const html = forgeRenderElementHtml(el, gd, {}, {});
assert.ok(html.includes("--icon-url:url(&#x27;data:image/svg+xml;base64,QUJD&#x27;)"));
assert.ok(html.includes('--icon-url:url(&#x27;data:image/svg+xml;base64,QUJD&#x27;)'));
});
test('onglets — un panneau actif, les autres masqués', () => {
const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} };
const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
const tab2 = { id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
const parent = {
id: 7, widget: 'onglets', attributes: {}, style: {},
};
const tab1 = {
id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
};
const tab2 = {
id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
};
const html = forgeRenderElementHtml(parent, baseGameData(), { 7: [tab1, tab2] }, {});
assert.ok(html.includes('is-active'));
assert.ok(html.includes('tabpanel-8'));
@@ -90,7 +112,9 @@ test('onglets — un panneau actif, les autres masqués', () => {
});
test('personnage — lit gameData.personnage_animations (idle en premier)', () => {
const el = { id: 5, widget: 'personnage', attributes: {}, style: {} };
const el = {
id: 5, widget: 'personnage', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.startsWith('<img '));
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
@@ -100,8 +124,12 @@ test('personnage — lit gameData.personnage_animations (idle en premier)', () =
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
const el = {
id: 10, widget: 'jauge', style: {},
attributes: { _definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff' },
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff',
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('value="42"')); // ligne la plus récente = Alice (id 1, points=42)
@@ -110,8 +138,11 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
const el = {
id: 11, widget: 'repeteur_donnees', content: '{{nom}} : {{points}} pts',
attributes: { _definition_id: '1' }, style: {},
id: 11,
widget: 'repeteur_donnees',
content: '{{nom}} : {{points}} pts',
attributes: { _definition_id: '1' },
style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.equal((html.match(/repeaterItem/g) || []).length, 2);
@@ -121,14 +152,19 @@ test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
});
test('répéteur — objet introuvable, message explicite plutôt qu\'un plantage', () => {
const el = { id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {} };
const el = {
id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('Objet introuvable'));
});
test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', () => {
const el = {
id: 13, widget: 'texte', content: 'Score actuel : {{points}}', style: {},
id: 13,
widget: 'texte',
content: 'Score actuel : {{points}}',
style: {},
attributes: {
_data_definition_id: '1',
_data_filtres_json: JSON.stringify([{ champ: 'nom', operateur: 'egal', valeur: 'Bob' }]),
@@ -140,7 +176,9 @@ test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', ()
});
test('superposition — voile plein écran seulement en mode jouable', () => {
const el = { id: 14, widget: 'superposition', content: '', attributes: {}, style: {} };
const el = {
id: 14, widget: 'superposition', content: '', attributes: {}, style: {},
};
const editorHtml = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.equal(editorHtml.indexOf('position:fixed'), -1);
const playHtml = forgeRenderElementHtml(el, baseGameData(), {}, { _forge_play_mode: true });
@@ -151,10 +189,14 @@ test('superposition — voile plein écran seulement en mode jouable', () => {
test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameData.element_type_templates', () => {
const gd = baseGameData();
gd.element_type_templates['20'] = {
top: [{ id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root' }],
top: [{
id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root',
}],
children_map: {},
};
const el = { id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {} };
const el = {
id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, gd, {}, {});
assert.ok(html.includes('>Modèle<'));
});
@@ -162,10 +204,14 @@ test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameDa
test('élément de jeu = superposition seule -> enveloppe transparente sans classe "box"', () => {
const gd = baseGameData();
gd.element_type_templates['21'] = {
top: [{ id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root' }],
top: [{
id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root',
}],
children_map: {},
};
const el = { id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {} };
const el = {
id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {},
};
const html = forgeRenderElementHtml(el, gd, {}, {});
assert.equal(html.indexOf('class="box"'), -1);
assert.ok(html.startsWith('<div '));
@@ -24,8 +24,11 @@ function fakeApi() {
test('FORGE_TO_SCORM_STATUS — vocabulaire figé cmi.core.lesson_status', () => {
assert.deepEqual(FORGE_TO_SCORM_STATUS, {
non_commence: 'not attempted', en_cours: 'incomplete', termine: 'completed',
reussi: 'passed', echoue: 'failed',
non_commence: 'not attempted',
en_cours: 'incomplete',
termine: 'completed',
reussi: 'passed',
echoue: 'failed',
});
});
@@ -43,7 +46,7 @@ test('forgeScormInit ne pose PAS cmi.core.score.min/max si le jeu n\'a aucune qu
global.window.FORGE_MAX_QUIZ_SCORE = 0;
try {
forgeScormInit();
assert.ok(!api.calls.some(c => c[1] === 'cmi.core.score.max'));
assert.ok(!api.calls.some((c) => c[1] === 'cmi.core.score.max'));
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
@@ -54,8 +57,8 @@ test('forgeScormInit pose cmi.core.score.min/max une fois si le jeu a des questi
try {
forgeScormInit();
assert.deepEqual(
api.calls.filter(c => c[0] === 'LMSSetValue').map(c => [c[1], c[2]]),
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']]
api.calls.filter((c) => c[0] === 'LMSSetValue').map((c) => [c[1], c[2]]),
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']],
);
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
@@ -68,8 +71,8 @@ test('forgeScormSyncScore envoie score.raw/lesson_status et Commit, une seule fo
forgeScormInit();
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
const callsAfterFirst = api.calls.length;
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
assert.equal(api.calls.length, callsAfterFirst);
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
@@ -20,7 +20,7 @@ function fakeApi(interactionsCount) {
calls,
Initialize: () => calls.push(['Initialize']),
SetValue: (key, value) => calls.push(['SetValue', key, value]),
GetValue: key => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
GetValue: (key) => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
Commit: () => calls.push(['Commit']),
};
}
@@ -52,8 +52,8 @@ test('forgeScorm2004Init pose cmi.score.min/max une fois si le jeu a des questio
try {
forgeScorm2004Init();
assert.deepEqual(
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
[['cmi.score.min', '0'], ['cmi.score.max', '20']]
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
[['cmi.score.min', '0'], ['cmi.score.max', '20']],
);
} finally { delete global.window.API_1484_11; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
@@ -66,10 +66,10 @@ test('forgeScorm2004SyncScore — "echoue" envoie completion_status="completed"
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'echoue' } });
assert.deepEqual(
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']]
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']],
);
assert.ok(api.calls.some(c => c[0] === 'Commit'));
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
} finally { delete global.window.API_1484_11; }
});
@@ -97,7 +97,7 @@ test('forgeScorm2004SyncScore — ajoute cmi.score.scaled UNIQUEMENT si des ques
try {
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'reussi' } });
const scaledCall = api.calls.find(c => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
const scaledCall = api.calls.find((c) => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
assert.equal(scaledCall[2], String(3 / 4));
} finally {
delete global.window.API_1484_11;
@@ -117,7 +117,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
try {
forgeScorm2004Init();
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
assert.equal(sets['cmi.interactions.2.type'], 'choice');
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
@@ -125,7 +125,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
assert.equal(sets['cmi.interactions.2.learner_response'], '0');
assert.equal(sets['cmi.interactions.2.result'], 'correct');
assert.equal(sets['cmi.interactions.2.correct_responses.0.pattern'], '0');
assert.ok(api.calls.some(c => c[0] === 'Commit'));
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
} finally { delete global.window.API_1484_11; }
});
@@ -135,7 +135,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
try {
forgeScorm2004Init();
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
assert.equal(sets['cmi.interactions.0.correct_responses.0.pattern'], '0');
@@ -7,9 +7,9 @@ const { forgeIsElementVisibleOffline } = require('../visibility-condition.js');
function baseGameData() {
return {
variables: { niveau: { value: '3', type: 'nombre_entier' } },
definition_names: { '1': 'Progression' },
fields_meta: { '1': [{ name: 'debloque', type: 'booleen' }] },
data: { '1': [{ id: 1, debloque: 1 }] },
definition_names: { 1: 'Progression' },
fields_meta: { 1: [{ name: 'debloque', type: 'booleen' }] },
data: { 1: [{ id: 1, debloque: 1 }] },
};
}
@@ -21,28 +21,37 @@ test('sans réglage de condition, toujours visible', () => {
test('mode variable — égal/supérieur', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '3',
_visibilite_cond_mode: 'variable',
_visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur_egal',
_visibilite_cond_valeur: '3',
}), true);
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur', _visibilite_cond_valeur: '3',
_visibilite_cond_mode: 'variable',
_visibilite_cond_variable: 'niveau',
_visibilite_cond_operateur: 'superieur',
_visibilite_cond_valeur: '3',
}), false);
});
test('mode variable — variable supprimée depuis : ne bloque jamais', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'disparue',
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '1',
_visibilite_cond_mode: 'variable',
_visibilite_cond_variable: 'disparue',
_visibilite_cond_operateur: 'egal',
_visibilite_cond_valeur: '1',
}), true);
});
test('mode objet — lit la ligne la plus récente hors répéteur', () => {
const gd = baseGameData();
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
_visibilite_cond_mode: 'objet',
_visibilite_cond_definition_id: '1',
_visibilite_cond_champ: 'debloque',
_visibilite_cond_operateur: 'egal',
_visibilite_cond_valeur: 'oui',
}), true);
});
@@ -50,7 +59,10 @@ test('mode objet — utilise ctx (ligne du Répéteur) plutôt que la ligne la p
const gd = baseGameData();
const ctx = { debloque: 0 };
assert.equal(forgeIsElementVisibleOffline(gd, {
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
_visibilite_cond_mode: 'objet',
_visibilite_cond_definition_id: '1',
_visibilite_cond_champ: 'debloque',
_visibilite_cond_operateur: 'egal',
_visibilite_cond_valeur: 'oui',
}, ctx), false);
});
@@ -7,7 +7,7 @@ const assert = require('node:assert/strict');
global.window = global.window || {};
global.window.addEventListener = global.window.addEventListener || function () {};
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
global.btoa = global.btoa || ((str) => Buffer.from(str).toString('base64'));
const {
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
@@ -16,8 +16,11 @@ const {
test.beforeEach(() => {
global.window.FORGE_XAPI_CONFIG = {
endpoint: 'https://lrs.example.com/xapi', login: 'user', password: 'pass',
activity_id: 'urn:forge-engine:game:test', activity_name: 'Mon jeu',
endpoint: 'https://lrs.example.com/xapi',
login: 'user',
password: 'pass',
activity_id: 'urn:forge-engine:game:test',
activity_name: 'Mon jeu',
};
});
@@ -38,7 +41,7 @@ test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant l
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed'); // NOSONAR S5332
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
assert.equal(body.result.completion, true);
});
@@ -48,7 +51,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered'); // NOSONAR S5332
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
assert.equal(body.result.success, true);
assert.equal(body.result.score.raw, 5);
+56 -51
View File
@@ -18,24 +18,24 @@
// full_game_payload.py) pour que conditions.js continue de la lire
// exactement comme aujourd'hui.
function forgeApplyVariableActionOffline(gameData, action) {
var varName = action.target_variable;
var operation = action.data_operation;
var rawValue = action.data_value;
const varName = action.target_variable;
const operation = action.data_operation;
const rawValue = action.data_value;
if (!varName || !operation) return false;
var variable = gameData.variables && gameData.variables[varName];
const variable = gameData.variables && gameData.variables[varName];
if (!variable) return false;
var varType = variable.type;
var current = variable.value;
if (varType === "nombre_entier" || varType === "nombre_decimal") {
current = (current === null || current === undefined || current === "") ? 0 : parseFloat(current);
if (isNaN(current)) current = 0;
} else if (varType === "booleen") {
var normalized = String(current == null ? "" : current).trim().toLowerCase();
current = (normalized === "1" || normalized === "true" || normalized === "vrai") ? 1 : 0;
const varType = variable.type;
let current = variable.value;
if (varType === 'nombre_entier' || varType === 'nombre_decimal') {
current = (current === null || current === undefined || current === '') ? 0 : parseFloat(current);
if (Number.isNaN(current)) current = 0;
} else if (varType === 'booleen') {
const normalized = String(current == null ? '' : current).trim().toLowerCase();
current = (normalized === '1' || normalized === 'true' || normalized === 'vrai') ? 1 : 0;
}
var newValue;
let newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, varType === "nombre_decimal");
newValue = forgeComputeNewValue(operation, current, rawValue, varType === 'nombre_decimal');
} catch (e) {
return false;
}
@@ -47,15 +47,15 @@ function forgeApplyVariableActionOffline(gameData, action) {
// Port de apply_score_action.py/apply_status_action.py — voir db/scoring/
// pour le vocabulaire des statuts (db.SCORE_STATUS_LABELS côté serveur,
// dupliqué ici en dur : ces 5 clés sont figées, voir db/constants.py).
var FORGE_SCORE_STATUS_KEYS = ["non_commence", "en_cours", "termine", "reussi", "echoue"];
const FORGE_SCORE_STATUS_KEYS = ['non_commence', 'en_cours', 'termine', 'reussi', 'echoue'];
function forgeApplyScoreActionOffline(gameData, action) {
var operation = action.data_operation;
var rawValue = action.data_value;
const operation = action.data_operation;
const rawValue = action.data_value;
if (!operation) return false;
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
var current = gameData.scoring.score || 0;
var newValue;
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
const current = gameData.scoring.score || 0;
let newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, true);
} catch (e) {
@@ -65,16 +65,16 @@ function forgeApplyScoreActionOffline(gameData, action) {
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
// ou réglage xAPI jamais configuré pour ce jeu).
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(newValue);
return true;
}
function forgeApplyStatusActionOffline(gameData, action) {
var status = action.data_value;
const status = action.data_value;
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
gameData.scoring.status = status;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(status);
return true;
}
@@ -86,45 +86,45 @@ function forgeApplyStatusActionOffline(gameData, action) {
// {name, type, min_value, max_value} — même forme que le payload en
// ligne (screens/payload/full_game_payload.py).
function forgeFindFieldMeta(gameData, definitionId, fieldName) {
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fields) return null;
for (var i = 0; i < fields.length; i++) {
for (let i = 0; i < fields.length; i++) {
if (fields[i].name === fieldName) return fields[i];
}
return null;
}
function forgeClampToFieldBounds(value, fieldMeta) {
if (fieldMeta.type !== "nombre_entier" && fieldMeta.type !== "nombre_decimal") return value;
if (fieldMeta.type !== 'nombre_entier' && fieldMeta.type !== 'nombre_decimal') return value;
if (fieldMeta.min_value !== null && fieldMeta.min_value !== undefined && value < fieldMeta.min_value) {
value = fieldMeta.min_value;
}
if (fieldMeta.max_value !== null && fieldMeta.max_value !== undefined && value > fieldMeta.max_value) {
value = fieldMeta.max_value;
}
return fieldMeta.type === "nombre_decimal" ? value : Math.trunc(value);
return fieldMeta.type === 'nombre_decimal' ? value : Math.trunc(value);
}
function forgeApplyDataActionOffline(gameData, action) {
var definitionId = String(action.target_definition_id || "");
var rowId = action.target_row_id;
var fieldName = action.target_field;
var operation = action.data_operation;
var rawValue = action.data_value;
const definitionId = String(action.target_definition_id || '');
const rowId = action.target_row_id;
const fieldName = action.target_field;
const operation = action.data_operation;
const rawValue = action.data_value;
if (!(definitionId && rowId && fieldName && operation)) return false;
var fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
const fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
if (!fieldMeta) return false;
var rows = gameData.data && gameData.data[definitionId];
const rows = gameData.data && gameData.data[definitionId];
if (!rows) return false;
var row = null;
for (var i = 0; i < rows.length; i++) {
let row = null;
for (let i = 0; i < rows.length; i++) {
if (rows[i].id === rowId) { row = rows[i]; break; }
}
if (!row) return false;
var current = row[fieldName];
var newValue;
const current = row[fieldName];
let newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === "nombre_decimal");
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === 'nombre_decimal');
} catch (e) {
return false;
}
@@ -140,17 +140,17 @@ function forgeApplyDataActionOffline(gameData, action) {
// tout le reste -> null). L'id est un simple auto-incrément local (pas
// de vraie base : aucune ligne d'un autre joueur à ignorer hors ligne).
function forgeApplyAddRowActionOffline(gameData, action) {
var definitionId = String(action.target_definition_id || "");
const definitionId = String(action.target_definition_id || '');
if (!definitionId) return null;
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fields) return null;
if (!gameData.data[definitionId]) gameData.data[definitionId] = [];
var rows = gameData.data[definitionId];
var maxId = 0;
rows.forEach(function (r) { if (r.id > maxId) maxId = r.id; });
var newRow = { id: maxId + 1 };
fields.forEach(function (f) {
newRow[f.name] = f.type === "booleen" ? 0 : null;
const rows = gameData.data[definitionId];
let maxId = 0;
rows.forEach((r) => { if (r.id > maxId) maxId = r.id; });
const newRow = { id: maxId + 1 };
fields.forEach((f) => {
newRow[f.name] = f.type === 'booleen' ? 0 : null;
});
rows.push(newRow);
return newRow.id;
@@ -158,9 +158,14 @@ function forgeApplyAddRowActionOffline(gameData, action) {
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline,
forgeApplyDataActionOffline, forgeApplyAddRowActionOffline,
forgeFindFieldMeta, forgeClampToFieldBounds, FORGE_SCORE_STATUS_KEYS,
forgeApplyVariableActionOffline,
forgeApplyScoreActionOffline,
forgeApplyStatusActionOffline,
forgeApplyDataActionOffline,
forgeApplyAddRowActionOffline,
forgeFindFieldMeta,
forgeClampToFieldBounds,
FORGE_SCORE_STATUS_KEYS,
};
// node:test charge ce fichier isolément (voir __tests__/) — forgeComputeNewValue
// vit dans compute-operation.js, jamais un <script> séparé en test.
+35 -33
View File
@@ -19,50 +19,52 @@ function forgePyMod(a, b) {
}
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
var round = function (v) { return isDecimal ? v : forgePyInt(v); };
if (operation === "definir_texte") return rawValue || "";
if (operation === "definir_nombre") {
var n = parseFloat(rawValue);
return isNaN(n) ? 0 : round(n);
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
if (operation === 'definir_texte') return rawValue || '';
if (operation === 'definir_nombre') {
const n = parseFloat(rawValue);
return Number.isNaN(n) ? 0 : round(n);
}
if (operation === "definir_bool_vrai") return 1;
if (operation === "definir_bool_faux") return 0;
if (operation === "basculer_bool") return current ? 0 : 1;
if (operation === "incrementer" || operation === "decrementer") {
var amount = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
if (isNaN(amount)) amount = 0;
if (operation === "decrementer") amount = -amount;
if (operation === 'definir_bool_vrai') return 1;
if (operation === 'definir_bool_faux') return 0;
if (operation === 'basculer_bool') return current ? 0 : 1;
if (operation === 'incrementer' || operation === 'decrementer') {
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
if (Number.isNaN(amount)) amount = 0;
if (operation === 'decrementer') amount = -amount;
return round((current || 0) + amount);
}
if (operation === "multiplier") {
var factor = (rawValue === null || rawValue === undefined || rawValue === "") ? 1 : parseFloat(rawValue);
if (isNaN(factor)) factor = 1;
if (operation === 'multiplier') {
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : parseFloat(rawValue);
if (Number.isNaN(factor)) factor = 1;
return round((current || 0) * factor);
}
if (operation === "diviser" || operation === "modulo") {
var divisor = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
if (isNaN(divisor)) divisor = 0;
if (operation === 'diviser' || operation === 'modulo') {
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
if (Number.isNaN(divisor)) divisor = 0;
if (divisor === 0) return current;
var result = operation === "diviser" ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
return round(result);
}
if (operation === "minimum" || operation === "maximum") {
var bound = parseFloat(rawValue);
if (isNaN(bound)) return current;
var res = operation === "minimum" ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
if (operation === 'minimum' || operation === 'maximum') {
const bound = parseFloat(rawValue);
if (Number.isNaN(bound)) return current;
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
return round(res);
}
if (operation === "alea") {
var parts = String(rawValue || "").split(",");
var lo = parseFloat(parts[0]), hi = parseFloat(parts[1]);
if (isNaN(lo)) lo = 0;
if (isNaN(hi)) hi = 0;
if (lo > hi) { var tmp = lo; lo = hi; hi = tmp; }
if (isDecimal) return Math.random() * (hi - lo) + lo;
var iLo = Math.trunc(lo), iHi = Math.trunc(hi);
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo;
if (operation === 'alea') {
const parts = String(rawValue || '').split(',');
let lo = parseFloat(parts[0]); let
hi = parseFloat(parts[1]);
if (Number.isNaN(lo)) lo = 0;
if (Number.isNaN(hi)) hi = 0;
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
const iLo = Math.trunc(lo); const
iHi = Math.trunc(hi);
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
}
throw new Error("opération inconnue : " + operation);
throw new Error(`opération inconnue : ${operation}`);
}
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
+41 -39
View File
@@ -7,36 +7,36 @@
// _compare() côté Python (chargé avant ce fichier dans templates/
// play.html), pas de troisième copie de cette logique.
var FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
var FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
var FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
// l'ancien format à 2 filtres fixes si l'attribut JSON est absent, même
// convention que côté serveur (aucune migration de données nécessaire).
function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
var raw = attrs[clausesAttr];
var clauses = null;
const raw = attrs[clausesAttr];
let clauses = null;
if (raw) {
try {
var parsed = JSON.parse(raw);
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) clauses = parsed;
} catch (e) { clauses = null; }
}
if (clauses === null && legacyPrefix) {
clauses = [];
["", "2"].forEach(function (suffix) {
var champ = (attrs[legacyPrefix + suffix + "_champ"] || "").trim();
['', '2'].forEach((suffix) => {
const champ = (attrs[`${legacyPrefix + suffix}_champ`] || '').trim();
if (champ) {
clauses.push({
champ: champ,
operateur: attrs[legacyPrefix + suffix + "_operateur"] || "egal",
valeur: attrs[legacyPrefix + suffix + "_valeur"] || "",
champ,
operateur: attrs[`${legacyPrefix + suffix}_operateur`] || 'egal',
valeur: attrs[`${legacyPrefix + suffix}_valeur`] || '',
});
}
});
}
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || "et" };
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || 'et' };
}
// Port de _resolve_variable_path — navigue ".champ"/"[index]" dans une
@@ -45,16 +45,17 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
// planter).
function forgeResolveVariablePath(rawValue, path) {
if (!path) return rawValue;
var current;
let current;
try {
current = rawValue ? JSON.parse(rawValue) : null;
} catch (e) {
return null;
}
var m;
let m;
FORGE_PATH_SEGMENT_RE.lastIndex = 0;
while ((m = FORGE_PATH_SEGMENT_RE.exec(path)) !== null) {
var key = m[1], index = m[2];
const key = m[1]; const
index = m[2];
try {
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
if (current === undefined) return null;
@@ -86,22 +87,23 @@ function forgeResolveVariablePath(rawValue, path) {
// définition visée par son nom affiché.
function forgeResolveFilterValue(gameData, rawValue) {
if (!rawValue) return rawValue;
var stripped = String(rawValue).trim();
const stripped = String(rawValue).trim();
var varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
if (varMatch) {
var variable = gameData.variables && gameData.variables[varMatch[1].trim()];
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
return forgeResolveVariablePath(variable.value, varMatch[2]);
}
var m = FORGE_REF_PATTERN.exec(stripped);
const m = FORGE_REF_PATTERN.exec(stripped);
if (!m) return rawValue;
var objectName = m[1].trim(), fieldName = m[2].trim();
var names = gameData.definition_names || {};
for (var defId in names) {
const objectName = m[1].trim(); const
fieldName = m[2].trim();
const names = gameData.definition_names || {};
for (const defId in names) {
if (names[defId] === objectName) {
var rows = gameData.data && gameData.data[defId];
const rows = gameData.data && gameData.data[defId];
if (rows && rows.length) {
return rows[0][fieldName];
}
@@ -113,41 +115,41 @@ function forgeResolveFilterValue(gameData, rawValue) {
// Port de _clause_matches.
function forgeClauseMatches(gameData, definitionId, row, clause) {
var fieldName = (clause.champ || "").trim();
const fieldName = (clause.champ || '').trim();
if (!fieldName) return true;
var operator = clause.operateur || "egal";
var expected = forgeResolveFilterValue(gameData, clause.valeur || "");
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; });
var fieldType = field ? field.type : "texte";
const operator = clause.operateur || 'egal';
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
const fieldType = field ? field.type : 'texte';
return compareValues(row[fieldName], operator, expected, fieldType);
}
// Port de filter_rows_by_clauses.
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
if (!clauses || !clauses.length) return rows;
if (combinator === "ou") {
return rows.filter(function (r) {
return clauses.some(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
});
if (combinator === 'ou') {
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
}
return rows.filter(function (r) {
return clauses.every(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
});
return rows.filter((r) => clauses.every((c) => forgeClauseMatches(gameData, definitionId, r, c)));
}
// Port de filter_repeater_rows — filtre appliqué par le widget Répéteur
// lui-même (attrs._filtres_json/_filtre_combinateur, avec repli sur
// l'ancien format à 2 filtres fixes _filtre(2)_champ/...).
function forgeFilterRepeaterRows(gameData, definitionId, rows, attrs) {
var decoded = forgeDecodeClauses(attrs, "_filtres_json", "_filtre_combinateur", "_filtre");
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
return forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeDecodeClauses, forgeResolveVariablePath,
forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows,
forgeDecodeClauses,
forgeResolveVariablePath,
forgeResolveFilterValue,
forgeClauseMatches,
forgeFilterRowsByClauses,
forgeFilterRepeaterRows,
};
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
}
+7 -7
View File
@@ -15,23 +15,23 @@
// static/js/play/actions.js, appelée par l'action "Modifier un objet de
// scène" — comportement identique en ligne et hors ligne, rien à porter).
function forgeRecomputeElementsForScreenOffline(gameData, screen) {
var elements = screen.elements || [];
var childrenMap = {};
elements.forEach(function (el) {
const elements = screen.elements || [];
const childrenMap = {};
elements.forEach((el) => {
if (el.parent_id) {
if (!childrenMap[el.parent_id]) childrenMap[el.parent_id] = [];
childrenMap[el.parent_id].push(el);
}
});
var playCtx = { _forge_play_mode: true };
elements.forEach(function (el) {
const playCtx = { _forge_play_mode: true };
elements.forEach((el) => {
el.rendered_html = forgeRenderElementHtml(el, gameData, childrenMap, playCtx);
});
}
function forgeRecomputeFullPayloadOffline(gameData) {
(gameData.screens || []).forEach(function (s) {
if (s.kind !== "jeu_2d") forgeRecomputeElementsForScreenOffline(gameData, s);
(gameData.screens || []).forEach((s) => {
if (s.kind !== 'jeu_2d') forgeRecomputeElementsForScreenOffline(gameData, s);
});
return gameData;
}
+169 -147
View File
@@ -15,68 +15,83 @@
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
// via forgeSpecialRenderer().
var FORGE_LAYOUT_CAPABLE_WIDGETS = ["conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"];
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
// l'éditeur, qui ne concerne que le port serveur).
var FORGE_SIMPLE_WIDGETS = {
texte: { tag: "p", hasContent: true },
titre: { tag: null, hasContent: true, fixedAttrs: { class: "title" } },
bouton: { tag: "button", hasContent: true, fixedAttrs: { class: "button" } },
lien: { tag: "a", hasContent: true },
image: { tag: "img", hasContent: false, selfClosing: true },
video: { tag: "video", hasContent: false },
champ_texte: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "text", class: "input" } },
champ_email: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "email", class: "input" } },
champ_mot_de_passe: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "password", class: "input" } },
zone_texte: { tag: "textarea", hasContent: false, fixedAttrs: { class: "textarea" } },
liste: { tag: "ul", hasContent: true },
liste_numerotee: { tag: "ol", hasContent: true },
separateur: { tag: "hr", hasContent: false, selfClosing: true },
conteneur: { tag: "div", hasContent: true, fixedAttrs: { class: "box" } },
const FORGE_SIMPLE_WIDGETS = {
texte: { tag: 'p', hasContent: true },
titre: { tag: null, hasContent: true, fixedAttrs: { class: 'title' } },
bouton: { tag: 'button', hasContent: true, fixedAttrs: { class: 'button' } },
lien: { tag: 'a', hasContent: true },
image: { tag: 'img', hasContent: false, selfClosing: true },
video: { tag: 'video', hasContent: false },
champ_texte: {
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'text', class: 'input' },
},
champ_email: {
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'email', class: 'input' },
},
champ_mot_de_passe: {
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'password', class: 'input' },
},
zone_texte: { tag: 'textarea', hasContent: false, fixedAttrs: { class: 'textarea' } },
liste: { tag: 'ul', hasContent: true },
liste_numerotee: { tag: 'ol', hasContent: true },
separateur: { tag: 'hr', hasContent: false, selfClosing: true },
conteneur: { tag: 'div', hasContent: true, fixedAttrs: { class: 'box' } },
};
// widget -> clé de special_render (voir screens/widgets/registry.py).
var FORGE_SPECIAL_RENDER_BY_WIDGET = {
case_a_cocher: "checkbox_label", bouton_radio: "checkbox_label", liste_deroulante: "select_options",
groupe_champs: "fieldset_legend", tableau: "table", repeteur_donnees: "repeater",
jauge: "jauge", superposition: "overlay", onglets: "onglets", icone: "icone", personnage: "personnage",
const FORGE_SPECIAL_RENDER_BY_WIDGET = {
case_a_cocher: 'checkbox_label',
bouton_radio: 'checkbox_label',
liste_deroulante: 'select_options',
groupe_champs: 'fieldset_legend',
tableau: 'table',
repeteur_donnees: 'repeater',
jauge: 'jauge',
superposition: 'overlay',
onglets: 'onglets',
icone: 'icone',
personnage: 'personnage',
};
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
var FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: "table" } };
const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
function forgeHtmlEscape(s) {
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
function forgeApplyCtx(text, ctx) {
if (!ctx || !text) return text;
var result = text;
Object.keys(ctx).forEach(function (k) {
if (k === "_forge_play_mode") return; // indicateur interne, pas un {{champ}}
result = result.split("{{" + k + "}}").join(ctx[k]);
let result = text;
Object.keys(ctx).forEach((k) => {
if (k === '_forge_play_mode') return; // indicateur interne, pas un {{champ}}
result = result.split(`{{${k}}}`).join(ctx[k]);
});
return result;
}
function forgeAutoId(el) {
return "elt-" + el.id;
return `elt-${el.id}`;
}
// Port de parent_flex_direction.py.
function forgeParentFlexDirection(parentEl) {
var style = (parentEl && parentEl.style) || {};
if (style.display !== "flex") return null;
return style["flex-direction"] || "row";
const style = (parentEl && parentEl.style) || {};
if (style.display !== 'flex') return null;
return style['flex-direction'] || 'row';
}
// Port de style_string.py — même logique de taille par défaut (100% hors
@@ -84,34 +99,34 @@ function forgeParentFlexDirection(parentEl) {
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
function forgeStyleString(el, parentFlexDirection, widgetKey) {
var style = Object.assign({}, el.style || {});
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !("display" in style)) {
style.display = "flex";
if (!("flex-direction" in style)) style["flex-direction"] = "column";
const style = { ...el.style || {} };
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
style.display = 'flex';
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
}
var isChild = !!el.parent_id;
var baseStyle;
const isChild = !!el.parent_id;
let baseStyle;
if (isChild) {
var widthDefault = (parentFlexDirection === "row" || parentFlexDirection === "row-reverse") ? "auto" : "100%";
baseStyle = "width:" + widthDefault + "; height:auto; box-sizing:border-box; margin:0;";
const widthDefault = (parentFlexDirection === 'row' || parentFlexDirection === 'row-reverse') ? 'auto' : '100%';
baseStyle = `width:${widthDefault}; height:auto; box-sizing:border-box; margin:0;`;
} else {
baseStyle = "width:100%; height:100%; box-sizing:border-box; margin:0;";
baseStyle = 'width:100%; height:100%; box-sizing:border-box; margin:0;';
}
var hasSizeOverride = false;
var parts = [];
Object.keys(style).forEach(function (k) {
var v = style[k];
if (v === null || v === undefined || v === "") return;
if (typeof v === "object") return; // filet de sécurité, voir style_string.py
if (k === "transform" && !isChild) return;
var strV = String(v).trim();
if ((k === "width" || k === "height") && (strV === "0px" || strV === "0")) return;
if (k === "width" || k === "height") hasSizeOverride = true;
parts.push(k + ":" + v);
let hasSizeOverride = false;
const parts = [];
Object.keys(style).forEach((k) => {
const v = style[k];
if (v === null || v === undefined || v === '') return;
if (typeof v === 'object') return; // filet de sécurité, voir style_string.py
if (k === 'transform' && !isChild) return;
const strV = String(v).trim();
if ((k === 'width' || k === 'height') && (strV === '0px' || strV === '0')) return;
if (k === 'width' || k === 'height') hasSizeOverride = true;
parts.push(`${k}:${v}`);
});
if (hasSizeOverride) parts.push("flex-shrink:0");
var userStyle = parts.join("; ");
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
if (hasSizeOverride) parts.push('flex-shrink:0');
const userStyle = parts.join('; ');
return baseStyle + (userStyle ? ` ${userStyle};` : '');
}
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
@@ -120,69 +135,67 @@ function forgeStyleString(el, parentFlexDirection, widgetKey) {
// lecteur d'écran) — tous les autres attributs vides sont omis comme
// avant (une valeur vide y équivaut à "non réglé").
function forgeAttrString(attrs, extraStyle) {
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
Object.keys(attrs).forEach(function (k) {
var v = attrs[k];
const parts = [`style="${forgeHtmlEscape(extraStyle)}"`];
Object.keys(attrs).forEach((k) => {
const v = attrs[k];
if (v === null || v === undefined || v === false) return;
if (v === "" && k !== "alt") return;
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
if (v === '' && k !== 'alt') return;
parts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
});
return parts.join(" ");
return parts.join(' ');
}
// Port de visible_attrs.py.
function forgeVisibleAttrs(el, meta, ctx) {
var rawAttrs = el.attributes || {};
var attrs = {};
Object.keys(rawAttrs).forEach(function (k) {
if (k.indexOf("_") === 0) return;
var v = rawAttrs[k];
attrs[k] = typeof v === "string" ? forgeApplyCtx(v, ctx) : v;
const rawAttrs = el.attributes || {};
const attrs = {};
Object.keys(rawAttrs).forEach((k) => {
if (k.indexOf('_') === 0) return;
const v = rawAttrs[k];
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
});
Object.assign(attrs, meta.fixedAttrs || {});
var extraClasses = Object.keys(rawAttrs)
.filter(function (k) { return k.indexOf("_class_") === 0 && rawAttrs[k]; })
.map(function (k) { return rawAttrs[k]; });
const extraClasses = Object.keys(rawAttrs)
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
.map((k) => rawAttrs[k]);
if (extraClasses.length) {
var baseClass = attrs.class || "";
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(" ");
const baseClass = attrs.class || '';
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(' ');
}
if (!("id" in attrs)) attrs.id = forgeAutoId(el);
attrs["data-element-id"] = el.id;
if (el.parent_id) attrs["data-anim-target"] = el.id;
if (!('id' in attrs)) attrs.id = forgeAutoId(el);
attrs['data-element-id'] = el.id;
if (el.parent_id) attrs['data-anim-target'] = el.id;
return attrs;
}
// Port de render_children.py.
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
var kids = (childrenMap && childrenMap[el.id]) || [];
var flexDir = forgeParentFlexDirection(el);
return kids.map(function (c) {
return forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir);
}).join("");
const kids = (childrenMap && childrenMap[el.id]) || [];
const flexDir = forgeParentFlexDirection(el);
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
}
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
function forgeResolveBoundRowCtx(gameData, attrs) {
var definitionId = attrs._data_definition_id;
let definitionId = attrs._data_definition_id;
if (!definitionId) return null;
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) return null;
var rows = (gameData.data && gameData.data[definitionId]) || [];
var decoded = forgeDecodeClauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", "_data_filtre");
let rows = (gameData.data && gameData.data[definitionId]) || [];
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
if (!rows.length) return null;
var row = rows[0];
var ctx = {};
fieldsMetaForDef.forEach(function (f) {
const row = rows[0];
const ctx = {};
fieldsMetaForDef.forEach((f) => {
// row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE
// du champ (voir full_game_payload.py), jamais par sa colonne SQL —
// voir filter-repeater-rows.js pour le détail de ce piège.
var v = row[f.name];
ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v);
const v = row[f.name];
ctx[f.name] = (v === null || v === undefined || v === '') ? '' : String(v);
});
return ctx;
}
@@ -193,21 +206,19 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
// d'éventuels enfants copiés en base.
function forgeRenderElementTypeChildren(el, gameData, ctx) {
var templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
if (!templateTree || !templateTree.top.length) return [null, false];
var isOverlayOnly = templateTree.top.every(function (c) { return c.widget === "superposition"; });
var top = templateTree.top;
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
let { top } = templateTree;
if (isOverlayOnly) {
var overlayRoot = Object.assign({}, top[0]);
var newStyle = {};
Object.keys(overlayRoot.style || {}).forEach(function (k) { if (k !== "display") newStyle[k] = overlayRoot.style[k]; });
const overlayRoot = { ...top[0] };
const newStyle = {};
Object.keys(overlayRoot.style || {}).forEach((k) => { if (k !== 'display') newStyle[k] = overlayRoot.style[k]; });
overlayRoot.style = newStyle;
top = top.slice();
top[0] = overlayRoot;
}
var html = top.map(function (c) {
return forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx);
}).join("");
const html = top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx)).join('');
return [html, isOverlayOnly];
}
@@ -220,16 +231,16 @@ function forgeRenderElementTypeChildren(el, gameData, ctx) {
// enfants/onglets/superposition).
function forgeSpecialRenderer(specialKey) {
switch (specialKey) {
case "checkbox_label": return forgeRenderCheckboxOrRadio;
case "select_options": return forgeRenderSelect;
case "fieldset_legend": return forgeRenderFieldset;
case "table": return forgeRenderTable;
case "repeater": return forgeRenderRepeater;
case "jauge": return forgeRenderJauge;
case "overlay": return forgeRenderOverlay;
case "onglets": return forgeRenderOnglets;
case "icone": return forgeRenderIcone;
case "personnage": return forgeRenderPersonnage;
case 'checkbox_label': return forgeRenderCheckboxOrRadio;
case 'select_options': return forgeRenderSelect;
case 'fieldset_legend': return forgeRenderFieldset;
case 'table': return forgeRenderTable;
case 'repeater': return forgeRenderRepeater;
case 'jauge': return forgeRenderJauge;
case 'overlay': return forgeRenderOverlay;
case 'onglets': return forgeRenderOnglets;
case 'icone': return forgeRenderIcone;
case 'personnage': return forgeRenderPersonnage;
default: return null;
}
}
@@ -238,77 +249,78 @@ function forgeSpecialRenderer(specialKey) {
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
var widgetKey = el.widget;
var simple = FORGE_SIMPLE_WIDGETS[widgetKey];
var specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
var attrsRaw = el.attributes || {};
const widgetKey = el.widget;
const simple = FORGE_SIMPLE_WIDGETS[widgetKey];
const specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
const attrsRaw = el.attributes || {};
var hasVisibilityCondition = attrsRaw._visibilite_cond_mode === "variable" || attrsRaw._visibilite_cond_mode === "objet";
var playMode = !!((ctx || {})._forge_play_mode);
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
const playMode = !!((ctx || {})._forge_play_mode);
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
return '<div class="visibilityGated" data-element-id="' + el.id + '" style="display:none;"></div>';
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
}
var hasDataBinding = !!attrsRaw._data_definition_id;
var mark = function (htmlStr) {
var markers = "";
if (hasVisibilityCondition) markers += "<!--visibilityGated-->";
if (hasDataBinding) markers += "<!--dataBound-->";
const hasDataBinding = !!attrsRaw._data_definition_id;
const mark = function (htmlStr) {
let markers = '';
if (hasVisibilityCondition) markers += '<!--visibilityGated-->';
if (hasDataBinding) markers += '<!--dataBound-->';
return htmlStr + markers;
};
var boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
if (boundCtx) ctx = Object.assign({}, ctx || {}, boundCtx);
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
// meta d'un widget à special_render : fixedAttrs propre (voir
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
var meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
const meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
if (specialKey) {
var renderer = forgeSpecialRenderer(specialKey);
const renderer = forgeSpecialRenderer(specialKey);
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
}
var tag = meta.tag;
let { tag } = meta;
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
var niveau = attrsRaw._niveau || "2";
tag = "h" + niveau;
const niveau = attrsRaw._niveau || '2';
tag = `h${niveau}`;
}
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style = forgeStyleString(el, parentFlexDirection, widgetKey);
const attrs = forgeVisibleAttrs(el, meta, ctx);
const style = forgeStyleString(el, parentFlexDirection, widgetKey);
var hoverText = attrsRaw._survol_texte;
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
const hoverText = attrsRaw._survol_texte;
if (hoverText) attrs['data-hover-text'] = forgeApplyCtx(hoverText, ctx);
// alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de
// description dédié dans l'éditeur — alt="" (décoratif) par défaut
// plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par
// défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through
// via forgeVisibleAttrs, si l'auteur en règle un un jour).
if (tag === "img" && !("alt" in attrs)) attrs.alt = "";
if (tag === 'img' && !('alt' in attrs)) attrs.alt = '';
if (meta.selfClosing) {
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
return mark(`<${tag} ${forgeAttrString(attrs, style)}>`);
}
var content = forgeApplyCtx(el.content || "", ctx);
var childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
var childHtml = childResult[0], isOverlayTemplate = childResult[1];
const content = forgeApplyCtx(el.content || '', ctx);
const childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
let childHtml = childResult[0]; const
isOverlayTemplate = childResult[1];
if (childHtml === null) {
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
} else if (isOverlayTemplate) {
delete attrs.class;
return mark("<div " + forgeAttrString(attrs, style) + ">" + childHtml + "</div>");
return mark(`<div ${forgeAttrString(attrs, style)}>${childHtml}</div>`);
}
if (tag === "ul" || tag === "ol") {
var items = content.split("\n").map(function (l) { return l.trim(); }).filter(Boolean);
var inner = items.map(function (item) { return "<li>" + forgeHtmlEscape(item) + "</li>"; }).join("") + childHtml;
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + inner + "</" + tag + ">");
if (tag === 'ul' || tag === 'ol') {
const items = content.split('\n').map((l) => l.trim()).filter(Boolean);
const inner = items.map((item) => `<li>${forgeHtmlEscape(item)}</li>`).join('') + childHtml;
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
}
var safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, "<br>") : "";
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + safeContent + childHtml + "</" + tag + ">");
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
}
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
@@ -324,14 +336,24 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString,
forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml,
forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer,
FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET,
forgeHtmlEscape,
forgeApplyCtx,
forgeAutoId,
forgeParentFlexDirection,
forgeStyleString,
forgeAttrString,
forgeVisibleAttrs,
forgeRenderChildren,
forgeRenderElementHtml,
forgeResolveBoundRowCtx,
forgeRenderElementTypeChildren,
forgeSpecialRenderer,
FORGE_SIMPLE_WIDGETS,
FORGE_SPECIAL_RENDER_BY_WIDGET,
};
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
var filterMod = require('./filter-repeater-rows.js');
const filterMod = require('./filter-repeater-rows.js');
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
+65 -68
View File
@@ -4,16 +4,17 @@
// au lieu d'une requête SQL (voir _read_field_value côté serveur).
function forgeHexToRgb(hexColor) {
var hex = (hexColor || "#5b8cff").replace(/^#/, "");
if (hex.length !== 6) hex = "5b8cff";
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
if (hex.length !== 6) hex = '5b8cff';
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
}
function forgeInterpolateColor(lowHex, highHex, ratio) {
var lo = forgeHexToRgb(lowHex), hi = forgeHexToRgb(highHex);
var mixed = lo.map(function (c, i) { return Math.round(c + (hi[i] - c) * ratio); });
var toHex = function (n) { return n.toString(16).padStart(2, "0"); };
return "#" + mixed.map(toHex).join("");
const lo = forgeHexToRgb(lowHex); const
hi = forgeHexToRgb(highHex);
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
const toHex = function (n) { return n.toString(16).padStart(2, '0'); };
return `#${mixed.map(toHex).join('')}`;
}
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
@@ -22,88 +23,84 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
if (!definitionId || !fieldName) return null;
definitionId = String(definitionId);
var rows = gameData.data && gameData.data[definitionId];
const rows = gameData.data && gameData.data[definitionId];
if (!rows || !rows.length) return null;
var row = null;
if (rowId) row = rows.find(function (r) { return String(r.id) === String(rowId); });
let row = null;
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
if (!row) row = rows[0];
return row[fieldName];
}
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = el.attributes || {};
var nomPosition = attrs._nom_position || "dessus";
var nomAlignement = attrs._nom_alignement || "centre";
var nomPolice = attrs._nom_police || "inherit";
var nomTaille = attrs._nom_taille || "12";
var nomGras = attrs._nom_gras === "1";
var nomItalique = attrs._nom_italique === "1";
var flexDir = nomPosition === "cote" ? "row" : "column";
var alignItems = nomPosition === "cote" ? "center" : "stretch";
var style = forgeStyleString(
el, parentFlexDirection, el.widget
) + " display:flex; flex-direction:" + flexDir + "; align-items:" + alignItems + "; justify-content:center; gap:4px;";
const attrs = el.attributes || {};
const nomPosition = attrs._nom_position || 'dessus';
const nomAlignement = attrs._nom_alignement || 'centre';
const nomPolice = attrs._nom_police || 'inherit';
const nomTaille = attrs._nom_taille || '12';
const nomGras = attrs._nom_gras === '1';
const nomItalique = attrs._nom_italique === '1';
const flexDir = nomPosition === 'cote' ? 'row' : 'column';
const alignItems = nomPosition === 'cote' ? 'center' : 'stretch';
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:${flexDir}; align-items:${alignItems}; justify-content:center; gap:4px;`;
var definitionId = attrs._definition_id;
var rowId = attrs._row_id;
var fieldName = (attrs._champ || "").trim();
var fieldNameNom = (attrs._champ_nom || "").trim();
var minV = parseFloat(attrs._min); if (isNaN(minV)) minV = 0;
var maxV = parseFloat(attrs._max); if (isNaN(maxV)) maxV = 100;
var couleurBasse = attrs._couleur_basse || "#e05252";
var couleurHaute = attrs._couleur_haute || "#4caf50";
var afficherValeur = attrs._afficher_valeur === "1";
const definitionId = attrs._definition_id;
const rowId = attrs._row_id;
const fieldName = (attrs._champ || '').trim();
const fieldNameNom = (attrs._champ_nom || '').trim();
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
const couleurBasse = attrs._couleur_basse || '#e05252';
const couleurHaute = attrs._couleur_haute || '#4caf50';
const afficherValeur = attrs._afficher_valeur === '1';
var rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
var value = parseFloat(rawValue);
if (isNaN(value)) value = minV;
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
let value = parseFloat(rawValue);
if (Number.isNaN(value)) value = minV;
var span = (maxV - minV) || 1;
var ratio = Math.max(0, Math.min(1, (value - minV) / span));
var percent = Math.round(ratio * 1000) / 10;
var fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
const span = (maxV - minV) || 1;
const ratio = Math.max(0, Math.min(1, (value - minV) / span));
const percent = Math.round(ratio * 1000) / 10;
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
var labelHtml = "";
let labelHtml = '';
if (afficherValeur) {
labelHtml =
'<div style="position:absolute; inset:0; display:flex; align-items:center; ' +
'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; ' +
'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">' +
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : "—") + '</div>';
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
}
var nameHtml = "";
let nameHtml = '';
if (fieldNameNom) {
var nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
if (nomValue !== null && nomValue !== undefined && nomValue !== "") {
var textAlign = (nomPosition === "dessus" && nomAlignement === "gauche") ? "left" : "center";
nameHtml =
'<div style="flex:0 0 auto; font-family:' + forgeHtmlEscape(nomPolice) + '; ' +
'font-size:' + forgeHtmlEscape(String(nomTaille)) + 'px; ' +
'font-weight:' + (nomGras ? "700" : "400") + '; ' +
'font-style:' + (nomItalique ? "italic" : "normal") + '; ' +
'color:#e8eaf0; text-align:' + textAlign + ';">' + forgeHtmlEscape(String(nomValue)) + '</div>';
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
nameHtml = `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
+ `font-weight:${nomGras ? '700' : '400'}; `
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
}
}
var extraClasses = Object.keys(attrs)
.filter(function (k) { return k.indexOf("_class_") === 0 && attrs[k]; })
.map(function (k) { return attrs[k]; }).join(" ");
var barClass = forgeHtmlEscape(("progress jaugeBar " + extraClasses).trim());
var bar =
'<progress class="' + barClass + '" value="' + percent + '" max="100" ' +
'style="--bulma-progress-value-background-color:' + fillColor + '; --bulma-progress-bar-background-color:#1c2130; ' +
'margin:0; border-radius:6px; transition:background-color .3s ease;">' + percent + '%</progress>';
var barWrap =
'<div style="position:relative; display:flex; align-items:center; ' +
'flex:1 1 auto; min-height:14px;">' + bar + labelHtml + '</div>';
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) + '">' +
nameHtml + barWrap + '</div>';
const extraClasses = Object.keys(attrs)
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
.map((k) => attrs[k]).join(' ');
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
+ `style="--bulma-progress-value-background-color:${fillColor}; --bulma-progress-bar-background-color:#1c2130; `
+ `margin:0; border-radius:6px; transition:background-color .3s ease;">${percent}%</progress>`;
const barWrap = '<div style="position:relative; display:flex; align-items:center; '
+ `flex:1 1 auto; min-height:14px;">${bar}${labelHtml}</div>`;
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)}">${
nameHtml}${barWrap}</div>`;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue };
var renderMod = require('./render-element.js');
module.exports = {
forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue,
};
const renderMod = require('./render-element.js');
global.forgeStyleString = global.forgeStyleString || renderMod.forgeStyleString;
global.forgeHtmlEscape = global.forgeHtmlEscape || renderMod.forgeHtmlEscape;
global.forgeAutoId = global.forgeAutoId || renderMod.forgeAutoId;
+26 -27
View File
@@ -4,47 +4,46 @@
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
// mais le port reste fidèle pour ne pas diverger inutilement).
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var isPlayMode = !!(ctx && ctx._forge_play_mode);
var attrsRaw = el.attributes || {};
var boxColor = attrsRaw._couleur_boite || "#1f2430";
var radius = attrsRaw._arrondi || "12";
const isPlayMode = !!(ctx && ctx._forge_play_mode);
const attrsRaw = el.attributes || {};
const boxColor = attrsRaw._couleur_boite || '#1f2430';
const radius = attrsRaw._arrondi || '12';
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style, wrapperClass;
const attrs = forgeVisibleAttrs(el, meta, ctx);
let style; let
wrapperClass;
if (isPlayMode) {
var baseOverlayStyle =
"position:fixed; inset:0; z-index:9999; display:flex; " +
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);";
style = baseOverlayStyle + " " + forgeStyleString(el, parentFlexDirection, el.widget);
wrapperClass = "modal is-active";
const baseOverlayStyle = 'position:fixed; inset:0; z-index:9999; display:flex; '
+ 'align-items:center; justify-content:center; background:rgba(0,0,0,0.6);';
style = `${baseOverlayStyle} ${forgeStyleString(el, parentFlexDirection, el.widget)}`;
wrapperClass = 'modal is-active';
} else {
style = forgeStyleString(el, parentFlexDirection, el.widget);
wrapperClass = "";
wrapperClass = '';
}
var attrParts = ['style="' + forgeHtmlEscape(style) + '"'];
Object.keys(attrs).forEach(function (k) {
var v = attrs[k];
if (v === null || v === undefined || v === "" || v === false) return;
attrParts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
const attrParts = [`style="${forgeHtmlEscape(style)}"`];
Object.keys(attrs).forEach((k) => {
const v = attrs[k];
if (v === null || v === undefined || v === '' || v === false) return;
attrParts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
});
var boxStyle =
"background:" + forgeHtmlEscape(String(boxColor)) + "; border-radius:" + forgeHtmlEscape(String(radius)) + "px; " +
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;";
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
var wrapperClassAttr = wrapperClass ? 'class="' + wrapperClass + '" ' : "";
const boxStyle = `background:${forgeHtmlEscape(String(boxColor))}; border-radius:${forgeHtmlEscape(String(radius))}px; `
+ 'color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;';
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
const wrapperClassAttr = wrapperClass ? `class="${wrapperClass}" ` : '';
return (
'<div ' + wrapperClassAttr + attrParts.join(" ") + '>' +
'<div class="box forgeOverlayBox" style="' + forgeHtmlEscape(boxStyle) + '">' + childHtml + '</div>' +
'</div>'
`<div ${wrapperClassAttr}${attrParts.join(' ')}>`
+ `<div class="box forgeOverlayBox" style="${forgeHtmlEscape(boxStyle)}">${childHtml}</div>`
+ '</div>'
);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRenderOverlay };
var renderMod = require('./render-element.js');
["forgeVisibleAttrs", "forgeStyleString", "forgeHtmlEscape", "forgeRenderChildren"].forEach(function (name) {
const renderMod = require('./render-element.js');
['forgeVisibleAttrs', 'forgeStyleString', 'forgeHtmlEscape', 'forgeRenderChildren'].forEach((name) => {
global[name] = global[name] || renderMod[name];
});
}
+34 -38
View File
@@ -4,75 +4,71 @@
// d'un écran-modèle d'élément de jeu réutilisable, précalculé une fois à
// l'export) au lieu de screens/element_types/load_template_tree.py.
function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " overflow:auto;";
var attrsRaw = el.attributes || {};
var definitionId = attrsRaw._definition_id;
var definitionAttr = definitionId ? ' data-definition-id="' + forgeHtmlEscape(String(definitionId)) + '"' : "";
var templateTypeId = attrsRaw._template_type_id;
var template = forgeApplyCtx(el.content || "", ctx);
var children = (childrenMap && childrenMap[el.id]) || [];
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} overflow:auto;`;
const attrsRaw = el.attributes || {};
let definitionId = attrsRaw._definition_id;
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
const templateTypeId = attrsRaw._template_type_id;
const template = forgeApplyCtx(el.content || '', ctx);
const children = (childrenMap && childrenMap[el.id]) || [];
if (!definitionId) {
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
'"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>';
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
}
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) {
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
'"><em>Objet introuvable — a-t-il été supprimé ?</em></div>';
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
}
var rows = gameData.data[definitionId] || [];
let rows = gameData.data[definitionId] || [];
rows = forgeFilterRepeaterRows(gameData, definitionId, rows, attrsRaw);
var templateTree = null;
let templateTree = null;
if (templateTypeId) {
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
}
var items = rows.map(function (row) {
var rowCtx = Object.assign({}, ctx || {});
const items = rows.map((row) => {
const rowCtx = { ...ctx || {} };
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
// donc directement la bonne valeur, y compris pour un champ "relation"
// (voir filter-repeater-rows.js pour le détail de ce piège).
fieldsMetaForDef.forEach(function (f) {
var v = row[f.name];
rowCtx[f.name] = v === null || v === undefined ? "" : String(v);
fieldsMetaForDef.forEach((f) => {
const v = row[f.name];
rowCtx[f.name] = v === null || v === undefined ? '' : String(v);
});
var inner;
let inner;
if (templateTree) {
inner = templateTree.top.map(function (c) {
return forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx);
}).join("");
inner = templateTree.top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx)).join('');
} else if (children.length) {
inner = children.map(function (c) {
return forgeRenderElementHtml(c, gameData, childrenMap, rowCtx);
}).join("");
inner = children.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, rowCtx)).join('');
} else {
var line = template;
Object.keys(rowCtx).forEach(function (fname) {
if (fname === "_forge_play_mode") return;
line = line.split("{{" + fname + "}}").join(rowCtx[fname]);
let line = template;
Object.keys(rowCtx).forEach((fname) => {
if (fname === '_forge_play_mode') return;
line = line.split(`{{${fname}}}`).join(rowCtx[fname]);
});
inner = forgeHtmlEscape(line);
}
return '<div class="repeaterItem" data-row-id="' + row.id + '">' + inner + '</div>';
return `<div class="repeaterItem" data-row-id="${row.id}">${inner}</div>`;
});
var emptyMsg = attrsRaw._filtre_champ ? "Aucune ligne ne correspond au filtre réglé." : "Aucune donnée dans cet objet pour l'instant.";
var innerAll = items.length ? items.join("") : "<em>" + emptyMsg + "</em>";
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '"' + definitionAttr +
' style="' + forgeHtmlEscape(style) + '">' + innerAll + '</div>';
const emptyMsg = attrsRaw._filtre_champ ? 'Aucune ligne ne correspond au filtre réglé.' : "Aucune donnée dans cet objet pour l'instant.";
const innerAll = items.length ? items.join('') : `<em>${emptyMsg}</em>`;
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}"${definitionAttr
} style="${forgeHtmlEscape(style)}">${innerAll}</div>`;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeRenderRepeater };
var renderMod = require('./render-element.js');
["forgeStyleString", "forgeHtmlEscape", "forgeAutoId", "forgeApplyCtx", "forgeRenderElementHtml"].forEach(function (name) {
const renderMod = require('./render-element.js');
['forgeStyleString', 'forgeHtmlEscape', 'forgeAutoId', 'forgeApplyCtx', 'forgeRenderElementHtml'].forEach((name) => {
global[name] = global[name] || renderMod[name];
});
var filterMod = require('./filter-repeater-rows.js');
const filterMod = require('./filter-repeater-rows.js');
global.forgeFilterRepeaterRows = global.forgeFilterRepeaterRows || filterMod.forgeFilterRepeaterRows;
}
@@ -9,57 +9,56 @@
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var inputType = el.widget === "case_a_cocher" ? "checkbox" : "radio";
var attrs = forgeVisibleAttrs(el, meta, ctx);
const inputType = el.widget === 'case_a_cocher' ? 'checkbox' : 'radio';
const attrs = forgeVisibleAttrs(el, meta, ctx);
attrs.type = inputType;
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; align-items:center; gap:8px;";
var content = forgeApplyCtx(el.content || "", ctx);
return '<label ' + forgeAttrString({ class: inputType }, style) + '><input ' + forgeAttrString(attrs, "") + '> ' + forgeHtmlEscape(content) + '</label>';
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; align-items:center; gap:8px;`;
const content = forgeApplyCtx(el.content || '', ctx);
return `<label ${forgeAttrString({ class: inputType }, style)}><input ${forgeAttrString(attrs, '')}> ${forgeHtmlEscape(content)}</label>`;
}
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var outerAttrs = forgeVisibleAttrs(el, meta, ctx);
var bulmaClasses = outerAttrs.class || "";
var selectAttrs = {}, wrapperAttrs = {};
Object.keys(outerAttrs).forEach(function (k) {
if (k === "id" || k === "data-element-id" || k === "data-anim-target") wrapperAttrs[k] = outerAttrs[k];
else if (k !== "class") selectAttrs[k] = outerAttrs[k];
const outerAttrs = forgeVisibleAttrs(el, meta, ctx);
const bulmaClasses = outerAttrs.class || '';
const selectAttrs = {}; const
wrapperAttrs = {};
Object.keys(outerAttrs).forEach((k) => {
if (k === 'id' || k === 'data-element-id' || k === 'data-anim-target') wrapperAttrs[k] = outerAttrs[k];
else if (k !== 'class') selectAttrs[k] = outerAttrs[k];
});
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex;";
var content = forgeApplyCtx(el.content || "", ctx);
var options = content.split("\n").map(function (o) { return o.trim(); }).filter(Boolean);
var optsHtml = options.map(function (o) {
var esc = forgeHtmlEscape(o);
return '<option value="' + esc + '">' + esc + '</option>';
}).join("");
var selectHtml = '<select ' + forgeAttrString(selectAttrs, "") + '>' + optsHtml + '</select>';
var selectWrapClass = forgeHtmlEscape(("select is-fullwidth " + bulmaClasses).trim());
return '<div ' + forgeAttrString(wrapperAttrs, style) + '><div class="' + selectWrapClass + '">' + selectHtml + '</div></div>';
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex;`;
const content = forgeApplyCtx(el.content || '', ctx);
const options = content.split('\n').map((o) => o.trim()).filter(Boolean);
const optsHtml = options.map((o) => {
const esc = forgeHtmlEscape(o);
return `<option value="${esc}">${esc}</option>`;
}).join('');
const selectHtml = `<select ${forgeAttrString(selectAttrs, '')}>${optsHtml}</select>`;
const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim());
return `<div ${forgeAttrString(wrapperAttrs, style)}><div class="${selectWrapClass}">${selectHtml}</div></div>`;
}
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = forgeVisibleAttrs(el, meta, ctx);
var style = forgeStyleString(el, parentFlexDirection, el.widget);
var content = forgeApplyCtx(el.content || "", ctx) || "Groupe de champs";
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
return '<fieldset ' + forgeAttrString(attrs, style) + '><legend>' + forgeHtmlEscape(content) + '</legend>' + childHtml + '</fieldset>';
const attrs = forgeVisibleAttrs(el, meta, ctx);
const style = forgeStyleString(el, parentFlexDirection, el.widget);
const content = forgeApplyCtx(el.content || '', ctx) || 'Groupe de champs';
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
return `<fieldset ${forgeAttrString(attrs, style)}><legend>${forgeHtmlEscape(content)}</legend>${childHtml}</fieldset>`;
}
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = forgeVisibleAttrs(el, meta, ctx);
var headerRow = ["1", "true", "on"].indexOf((el.attributes || {})._header_row) !== -1;
var content = forgeApplyCtx(el.content || "", ctx);
var style = forgeStyleString(el, parentFlexDirection, el.widget);
var rows = content.split("\n").filter(function (r) { return r.trim(); });
var htmlRows = rows.map(function (r, i) {
var cells = r.split(";");
var cellTag = (headerRow && i === 0) ? "th" : "td";
var cellsHtml = cells.map(function (c) {
return "<" + cellTag + ">" + forgeHtmlEscape(c.trim()) + "</" + cellTag + ">";
}).join("");
return "<tr>" + cellsHtml + "</tr>";
}).join("");
return '<table ' + forgeAttrString(attrs, style) + '>' + htmlRows + '</table>';
const attrs = forgeVisibleAttrs(el, meta, ctx);
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
const content = forgeApplyCtx(el.content || '', ctx);
const style = forgeStyleString(el, parentFlexDirection, el.widget);
const rows = content.split('\n').filter((r) => r.trim());
const htmlRows = rows.map((r, i) => {
const cells = r.split(';');
const cellTag = (headerRow && i === 0) ? 'th' : 'td';
const cellsHtml = cells.map((c) => `<${cellTag}>${forgeHtmlEscape(c.trim())}</${cellTag}>`).join('');
return `<tr>${cellsHtml}</tr>`;
}).join('');
return `<table ${forgeAttrString(attrs, style)}>${htmlRows}</table>`;
}
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
@@ -67,12 +66,12 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
var rawAttrs = el.attributes || {};
var iconSlug = rawAttrs._icon_slug || "star";
var attrs = forgeVisibleAttrs(el, meta, ctx);
var existingClass = attrs.class || "";
const rawAttrs = el.attributes || {};
const iconSlug = rawAttrs._icon_slug || 'star';
const attrs = forgeVisibleAttrs(el, meta, ctx);
const existingClass = attrs.class || '';
delete attrs.class;
attrs.class = (existingClass + " icon-svg").trim();
attrs.class = (`${existingClass} icon-svg`).trim();
// data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
// publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
// chemin de fichier, même relatif : Firefox traite chaque ressource
@@ -84,49 +83,50 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
// "icone", posés directement ou nichés dans un élément de jeu
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg");
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
// description dédié dans l'éditeur) est traitée comme purement
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
if (!("aria-hidden" in attrs)) attrs["aria-hidden"] = "true";
return "<i " + forgeAttrString(attrs, style) + "></i>";
if (!('aria-hidden' in attrs)) attrs['aria-hidden'] = 'true';
return `<i ${forgeAttrString(attrs, style)}></i>`;
}
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var tabs = (childrenMap && childrenMap[el.id]) || [];
var attrs = forgeVisibleAttrs(el, meta, ctx);
var tabsExtraClasses = attrs.class || "";
const tabs = (childrenMap && childrenMap[el.id]) || [];
const attrs = forgeVisibleAttrs(el, meta, ctx);
const tabsExtraClasses = attrs.class || '';
delete attrs.class;
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; flex-direction:column;";
var groupId = forgeAutoId(el);
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:column;`;
const groupId = forgeAutoId(el);
if (!tabs.length) {
return '<div ' + forgeAttrString(attrs, style) + '><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>';
return `<div ${forgeAttrString(attrs, style)}><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>`;
}
var flexDir = forgeParentFlexDirection(el);
var barItems = [], panels = [];
tabs.forEach(function (tab, i) {
var label = tab.name || ("Onglet " + (i + 1));
var active = i === 0;
var tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
var targetId = "tabpanel-" + tab.id;
const flexDir = forgeParentFlexDirection(el);
const barItems = []; const
panels = [];
tabs.forEach((tab, i) => {
const label = tab.name || (`Onglet ${i + 1}`);
const active = i === 0;
const tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
const targetId = `tabpanel-${tab.id}`;
barItems.push(
'<li class="' + (active ? "is-active" : "") + '" data-tab-btn="' + groupId + '" data-tab-target="' + targetId +
'" onclick="forgeShowTab(this)"><a>' + forgeHtmlEscape(String(label)) + '</a></li>'
`<li class="${active ? 'is-active' : ''}" data-tab-btn="${groupId}" data-tab-target="${targetId
}" onclick="forgeShowTab(this)"><a>${forgeHtmlEscape(String(label))}</a></li>`,
);
var hiddenStyle = active ? "" : ' style="display:none;"';
panels.push('<div class="forgeTabPanel" id="' + targetId + '" data-tab-group="' + groupId + '"' + hiddenStyle + '>' + tabHtml + '</div>');
const hiddenStyle = active ? '' : ' style="display:none;"';
panels.push(`<div class="forgeTabPanel" id="${targetId}" data-tab-group="${groupId}"${hiddenStyle}>${tabHtml}</div>`);
});
var tabsClass = forgeHtmlEscape(("tabs " + tabsExtraClasses).trim());
const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim());
return (
'<div ' + forgeAttrString(attrs, style) + '>' +
'<div class="' + tabsClass + '"><ul>' + barItems.join("") + '</ul></div>' +
'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">' + panels.join("") + '</div>' +
'</div>'
`<div ${forgeAttrString(attrs, style)}>`
+ `<div class="${tabsClass}"><ul>${barItems.join('')}</ul></div>`
+ `<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">${panels.join('')}</div>`
+ '</div>'
);
}
@@ -136,27 +136,32 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
// rendu côté client.
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
var attrs = forgeVisibleAttrs(el, meta, ctx);
attrs["data-personnage"] = "1";
var animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
var idleFrame = frames && frames.length ? frames[0] : "";
const attrs = forgeVisibleAttrs(el, meta, ctx);
attrs['data-personnage'] = '1';
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
const idleFrame = frames && frames.length ? frames[0] : '';
if (idleFrame) attrs.src = idleFrame;
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
// décoratif sur le widget "image" générique.
if (!("alt" in attrs)) attrs.alt = "";
var style = forgeStyleString(el, parentFlexDirection, el.widget);
return "<img " + forgeAttrString(attrs, style) + ">";
if (!('alt' in attrs)) attrs.alt = '';
const style = forgeStyleString(el, parentFlexDirection, el.widget);
return `<img ${forgeAttrString(attrs, style)}>`;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable,
forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage,
forgeRenderCheckboxOrRadio,
forgeRenderSelect,
forgeRenderFieldset,
forgeRenderTable,
forgeRenderIcone,
forgeRenderOnglets,
forgeRenderPersonnage,
};
var renderMod = require('./render-element.js');
["forgeVisibleAttrs", "forgeStyleString", "forgeAttrString", "forgeApplyCtx", "forgeHtmlEscape",
"forgeAutoId", "forgeParentFlexDirection", "forgeRenderChildren", "forgeRenderElementHtml"].forEach(function (name) {
global[name] = global[name] || renderMod[name];
});
const renderMod = require('./render-element.js');
['forgeVisibleAttrs', 'forgeStyleString', 'forgeAttrString', 'forgeApplyCtx', 'forgeHtmlEscape',
'forgeAutoId', 'forgeParentFlexDirection', 'forgeRenderChildren', 'forgeRenderElementHtml'].forEach((name) => {
global[name] = global[name] || renderMod[name];
});
}
+29 -24
View File
@@ -16,68 +16,73 @@ function forgeScormFindApi(win, triesLeft) {
return null;
}
var forgeScormApi = null;
var forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
let forgeScormApi = null;
let forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
function forgeScormInit() {
forgeScormApi = forgeScormFindApi(window, 10);
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScormApi.LMSInitialize("");
forgeScormApi.LMSInitialize('');
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
// Omis si le jeu n'a aucune question de quiz (max=0, rien à barémer).
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
if (maxScore) {
forgeScormApi.LMSSetValue("cmi.core.score.min", "0");
forgeScormApi.LMSSetValue("cmi.core.score.max", String(maxScore));
forgeScormApi.LMSSetValue('cmi.core.score.min', '0');
forgeScormApi.LMSSetValue('cmi.core.score.max', String(maxScore));
}
}
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> vocabulaire
// figé cmi.core.lesson_status (SCORM 1.2) : pas de mapping 1-pour-1 pour
// "en_cours" (SCORM n'a pas d'équivalent "en cours" hors "incomplete").
var FORGE_TO_SCORM_STATUS = {
non_commence: "not attempted", en_cours: "incomplete", termine: "completed",
reussi: "passed", echoue: "failed",
const FORGE_TO_SCORM_STATUS = {
non_commence: 'not attempted',
en_cours: 'incomplete',
termine: 'completed',
reussi: 'passed',
echoue: 'failed',
};
function forgeScormSyncScore(gameData) {
if (!forgeScormApi || !gameData || !gameData.scoring) return;
var score = gameData.scoring.score || 0;
var status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || "incomplete";
var key = score + "|" + status;
const score = gameData.scoring.score || 0;
const status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || 'incomplete';
const key = `${score}|${status}`;
if (key === forgeScormLastSync) return;
forgeScormLastSync = key;
forgeScormApi.LMSSetValue("cmi.core.score.raw", String(score));
forgeScormApi.LMSSetValue("cmi.core.lesson_status", status);
forgeScormApi.LMSCommit("");
forgeScormApi.LMSSetValue('cmi.core.score.raw', String(score));
forgeScormApi.LMSSetValue('cmi.core.lesson_status', status);
forgeScormApi.LMSCommit('');
}
function forgeScormFinish() {
if (!forgeScormApi) return;
forgeScormApi.LMSFinish("");
forgeScormApi.LMSFinish('');
}
if (typeof window !== "undefined") {
window.addEventListener("load", forgeScormInit);
window.addEventListener("beforeunload", forgeScormFinish);
if (typeof window !== 'undefined') {
window.addEventListener('load', forgeScormInit);
window.addEventListener('beforeunload', forgeScormFinish);
// gameData est déjà en mémoire (window.FORGE_OFFLINE) — pas de fetch à
// attendre, un intervalle court suffit à refléter score/statut sans
// devoir faire dépendre bindings.js de ce fichier optionnel.
var forgeScormSyncTimer = setInterval(function () {
if (typeof gameData !== "undefined") forgeScormSyncScore(gameData);
const forgeScormSyncTimer = setInterval(() => {
if (typeof gameData !== 'undefined') forgeScormSyncScore(gameData);
}, 2000);
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais.
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === "function") {
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === 'function') {
forgeScormSyncTimer.unref();
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS };
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS,
};
}
+58 -45
View File
@@ -24,21 +24,21 @@ function forgeScorm2004FindApi(win, triesLeft) {
return null;
}
var forgeScorm2004Api = null;
var forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
let forgeScorm2004Api = null;
let forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
function forgeScorm2004Init() {
forgeScorm2004Api = forgeScorm2004FindApi(window, 10);
if (!forgeScorm2004Api) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScorm2004Api.Initialize("");
forgeScorm2004Api.Initialize('');
// cmi.score.min/max (voir static/js/play/offline/scorm-api.js pour le
// même réglage côté 1.2) : cmi.score.scaled (forgeScorm2004SyncScore)
// couvre déjà le pourcentage correctement, mais min/max reste utile
// pour tout LMS qui affiche plutôt raw/max.
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
if (maxScore) {
forgeScorm2004Api.SetValue("cmi.score.min", "0");
forgeScorm2004Api.SetValue("cmi.score.max", String(maxScore));
forgeScorm2004Api.SetValue('cmi.score.min', '0');
forgeScorm2004Api.SetValue('cmi.score.max', String(maxScore));
}
}
@@ -46,33 +46,33 @@ function forgeScorm2004Init() {
// (completion_status, success_status) SCORM 2004 — contrairement à la
// 1.2, "echoue" reste "completed" (l'apprenant a fini la formation) avec
// success_status="failed", au lieu de retomber en "incomplete".
var FORGE_TO_SCORM2004_STATUS = {
non_commence: { completion: "not attempted", success: "unknown" },
en_cours: { completion: "incomplete", success: "unknown" },
termine: { completion: "completed", success: "unknown" },
reussi: { completion: "completed", success: "passed" },
echoue: { completion: "completed", success: "failed" },
const FORGE_TO_SCORM2004_STATUS = {
non_commence: { completion: 'not attempted', success: 'unknown' },
en_cours: { completion: 'incomplete', success: 'unknown' },
termine: { completion: 'completed', success: 'unknown' },
reussi: { completion: 'completed', success: 'passed' },
echoue: { completion: 'completed', success: 'failed' },
};
function forgeScorm2004SyncScore(gameData) {
if (!forgeScorm2004Api || !gameData || !gameData.scoring) return;
var score = gameData.scoring.score || 0;
var statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
var key = score + "|" + statuses.completion + "|" + statuses.success;
const score = gameData.scoring.score || 0;
const statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
const key = `${score}|${statuses.completion}|${statuses.success}`;
if (key === forgeScorm2004LastSync) return;
forgeScorm2004LastSync = key;
forgeScorm2004Api.SetValue("cmi.score.raw", String(score));
forgeScorm2004Api.SetValue('cmi.score.raw', String(score));
// cmi.score.scaled (-1..1, ici toujours 0..1) : uniquement dérivable du
// taux de bonnes réponses au quiz (voir dialogue-box-controller.js),
// jamais de gameData.scoring.score qui n'est qu'un total de points sans
// maximum connu — omis si aucune question n'a encore été répondue.
if (typeof window !== "undefined" && window.forgeQuizTotalAnswered) {
var scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
forgeScorm2004Api.SetValue("cmi.score.scaled", String(scaled));
if (typeof window !== 'undefined' && window.forgeQuizTotalAnswered) {
const scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
forgeScorm2004Api.SetValue('cmi.score.scaled', String(scaled));
}
forgeScorm2004Api.SetValue("cmi.completion_status", statuses.completion);
forgeScorm2004Api.SetValue("cmi.success_status", statuses.success);
forgeScorm2004Api.Commit("");
forgeScorm2004Api.SetValue('cmi.completion_status', statuses.completion);
forgeScorm2004Api.SetValue('cmi.success_status', statuses.success);
forgeScorm2004Api.Commit('');
}
// cmi.interactions.n.* (voir dialogue-box-controller.js::forgeQuizBoxAnswer,
@@ -90,47 +90,60 @@ function forgeScorm2004SyncScore(gameData) {
// silencieusement (SetValue échoue sans erreur visible : le champ reste
// vide). "…T19:49:39" (sans les millisecondes ni le "Z") passe partout.
function forgeScorm2004Timestamp() {
return new Date().toISOString().replace(/\.\d+Z$/, "");
return new Date().toISOString().replace(/\.\d+Z$/, '');
}
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
function forgeScorm2004NotifyQuestionAnswered(
dialogueId,
objectName,
questionIndex,
questionText,
correct,
choiceIndex,
correctIndex,
) {
if (!forgeScorm2004Api) return;
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
if (isNaN(n)) n = 0;
var prefix = "cmi.interactions." + n + ".";
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
forgeScorm2004Api.SetValue(prefix + "type", "choice");
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
forgeScorm2004Api.SetValue(prefix + "learner_response", String(choiceIndex));
forgeScorm2004Api.SetValue(prefix + "result", correct ? "correct" : "incorrect");
forgeScorm2004Api.SetValue(prefix + "correct_responses.0.pattern", String(correctIndex));
forgeScorm2004Api.Commit("");
let n = parseInt(forgeScorm2004Api.GetValue('cmi.interactions._count'), 10);
if (Number.isNaN(n)) n = 0;
const prefix = `cmi.interactions.${n}.`;
forgeScorm2004Api.SetValue(`${prefix}id`, `dialogue:${dialogueId}:question:${questionIndex}`);
forgeScorm2004Api.SetValue(`${prefix}type`, 'choice');
forgeScorm2004Api.SetValue(`${prefix}timestamp`, forgeScorm2004Timestamp());
forgeScorm2004Api.SetValue(`${prefix}description`, `{fr-FR}${questionText || ''}`);
forgeScorm2004Api.SetValue(`${prefix}learner_response`, String(choiceIndex));
forgeScorm2004Api.SetValue(`${prefix}result`, correct ? 'correct' : 'incorrect');
forgeScorm2004Api.SetValue(`${prefix}correct_responses.0.pattern`, String(correctIndex));
forgeScorm2004Api.Commit('');
}
function forgeScorm2004Finish() {
if (!forgeScorm2004Api) return;
forgeScorm2004Api.Terminate("");
forgeScorm2004Api.Terminate('');
}
if (typeof window !== "undefined") {
window.addEventListener("load", forgeScorm2004Init);
window.addEventListener("beforeunload", forgeScorm2004Finish);
var forgeScorm2004SyncTimer = setInterval(function () {
if (typeof gameData !== "undefined") forgeScorm2004SyncScore(gameData);
if (typeof window !== 'undefined') {
window.addEventListener('load', forgeScorm2004Init);
window.addEventListener('beforeunload', forgeScorm2004Finish);
const forgeScorm2004SyncTimer = setInterval(() => {
if (typeof gameData !== 'undefined') forgeScorm2004SyncScore(gameData);
}, 2000);
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais
// (l'intervalle garde le process vivant indéfiniment).
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === "function") {
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === 'function') {
forgeScorm2004SyncTimer.unref();
}
}
if (typeof module !== "undefined" && module.exports) {
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore, forgeScorm2004Finish,
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
forgeScorm2004FindApi,
forgeScorm2004Init,
forgeScorm2004SyncScore,
forgeScorm2004Finish,
forgeScorm2004NotifyQuestionAnswered,
forgeScorm2004Timestamp,
FORGE_TO_SCORM2004_STATUS,
};
}
+18 -18
View File
@@ -11,38 +11,38 @@
// serveur : sans ça, tous les exemplaires d'un même modèle de ligne
// évalueraient la MÊME ligne au lieu de chacun la sienne).
function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
var mode = attrs._visibilite_cond_mode;
if (mode !== "variable" && mode !== "objet") return true;
var operator = attrs._visibilite_cond_operateur || "egal";
var expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || "");
const mode = attrs._visibilite_cond_mode;
if (mode !== 'variable' && mode !== 'objet') return true;
const operator = attrs._visibilite_cond_operateur || 'egal';
const expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || '');
if (mode === "variable") {
var varName = attrs._visibilite_cond_variable;
if (mode === 'variable') {
const varName = attrs._visibilite_cond_variable;
if (!varName) return true;
var variable = gameData.variables && gameData.variables[varName];
const variable = gameData.variables && gameData.variables[varName];
if (!variable) return true; // variable supprimée depuis : ne bloque jamais l'affichage
var value = variable.value;
var path = attrs._visibilite_cond_variable_chemin;
let { value } = variable;
const path = attrs._visibilite_cond_variable_chemin;
if (path) value = forgeResolveVariablePath(value, path);
return compareValues(value, operator, expected, variable.type || "texte");
return compareValues(value, operator, expected, variable.type || 'texte');
}
var definitionId = attrs._visibilite_cond_definition_id;
var fieldName = attrs._visibilite_cond_champ;
let definitionId = attrs._visibilite_cond_definition_id;
const fieldName = attrs._visibilite_cond_champ;
if (!definitionId || !fieldName) return true;
definitionId = String(definitionId);
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) return true;
var field = fieldsMetaForDef.find(function (f) { return f.name === fieldName; });
var fieldType = field ? field.type : "texte";
const field = fieldsMetaForDef.find((f) => f.name === fieldName);
const fieldType = field ? field.type : 'texte';
if (ctx && Object.prototype.hasOwnProperty.call(ctx, fieldName)) {
return compareValues(ctx[fieldName], operator, expected, fieldType);
}
var rows = gameData.data && gameData.data[definitionId];
const rows = gameData.data && gameData.data[definitionId];
if (!rows || !rows.length) return true; // objet sans donnée pour l'instant : ne bloque pas
var row = rows[0]; // convention "état de partie" : une seule ligne
const row = rows[0]; // convention "état de partie" : une seule ligne
// row[fieldName] : gameData.data est déjà indexé par le nom D'AFFICHAGE
// du champ (voir full_game_payload.py), jamais par sa colonne SQL.
return compareValues(row[fieldName], operator, expected, fieldType);
@@ -50,7 +50,7 @@ function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
if (typeof module !== 'undefined' && module.exports) {
module.exports = { forgeIsElementVisibleOffline };
var filterMod = require('./filter-repeater-rows.js');
const filterMod = require('./filter-repeater-rows.js');
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
+77 -60
View File
@@ -15,15 +15,25 @@
//
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
// bloquer ni ralentir la partie du joueur.
//
// NOSONAR S5332 (toutes occurrences "http://adlnet.gov/..." de ce fichier
// + les 2 assertions de __tests__/xapi-client.test.js) : identifiants du
// vocabulaire xAPI standard ADL, jamais dérérencés/appelés en réseau (de
// simples chaînes comparées/embarquées) — le "http://" fait partie de la
// chaîne fixée par la spec, jamais "https://" (changer casserait
// l'interopérabilité avec n'importe quel LRS). Le VRAI endpoint réseau
// (voir forgeXapiSendStatement plus bas) est toujours `config.endpoint`,
// saisi par le créateur (routes/publish/xapi_settings.py), jamais un
// littéral de ce fichier.
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
// génèrent rien ici (bruit inutile pour un suivi volontairement léger,
// voir le plan).
var FORGE_TO_XAPI_VERB = {
termine: { id: "http://adlnet.gov/expapi/verbs/completed" },
reussi: { id: "http://adlnet.gov/expapi/verbs/passed" },
echoue: { id: "http://adlnet.gov/expapi/verbs/failed" },
const FORGE_TO_XAPI_VERB = {
termine: { id: 'http://adlnet.gov/expapi/verbs/completed' }, // NOSONAR S5332
reussi: { id: 'http://adlnet.gov/expapi/verbs/passed' }, // NOSONAR S5332
echoue: { id: 'http://adlnet.gov/expapi/verbs/failed' }, // NOSONAR S5332
};
// Libellé humain par verbe — l'id xAPI (http://adlnet.gov/expapi/verbs/...)
@@ -33,18 +43,18 @@ var FORGE_TO_XAPI_VERB = {
// français ?" — un LRS qui préfère le français (ou n'importe quelle
// langue ici) affichera la bonne entrée au lieu de rester bloqué sur
// l'anglais.
var FORGE_XAPI_VERB_DISPLAY = {
"http://adlnet.gov/expapi/verbs/initialized": { "en-US": "initialized", "fr-FR": "initialisé" },
"http://adlnet.gov/expapi/verbs/terminated": { "en-US": "terminated", "fr-FR": "quitté" },
"http://adlnet.gov/expapi/verbs/completed": { "en-US": "completed", "fr-FR": "terminé" },
"http://adlnet.gov/expapi/verbs/passed": { "en-US": "passed", "fr-FR": "réussi" },
"http://adlnet.gov/expapi/verbs/failed": { "en-US": "failed", "fr-FR": "échoué" },
"http://adlnet.gov/expapi/verbs/scored": { "en-US": "scored", "fr-FR": "noté" },
"http://adlnet.gov/expapi/verbs/attempted": { "en-US": "attempted", "fr-FR": "commencé" },
"http://adlnet.gov/expapi/verbs/answered": { "en-US": "answered", "fr-FR": "répondu" },
const FORGE_XAPI_VERB_DISPLAY = {
'http://adlnet.gov/expapi/verbs/initialized': { 'en-US': 'initialized', 'fr-FR': 'initialisé' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/terminated': { 'en-US': 'terminated', 'fr-FR': 'quitté' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/completed': { 'en-US': 'completed', 'fr-FR': 'terminé' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/passed': { 'en-US': 'passed', 'fr-FR': 'réussi' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/failed': { 'en-US': 'failed', 'fr-FR': 'échoué' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/scored': { 'en-US': 'scored', 'fr-FR': 'noté' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/attempted': { 'en-US': 'attempted', 'fr-FR': 'commencé' }, // NOSONAR S5332
'http://adlnet.gov/expapi/verbs/answered': { 'en-US': 'answered', 'fr-FR': 'répondu' }, // NOSONAR S5332
};
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
let forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
@@ -52,23 +62,23 @@ var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
function forgeXapiActor() {
try {
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
if (typeof forgeScormApi !== 'undefined' && forgeScormApi) {
const name = forgeScormApi.LMSGetValue('cmi.core.student_name');
const id = forgeScormApi.LMSGetValue('cmi.core.student_id');
if (id) {
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
}
}
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
}
function forgeXapiObject() {
var config = window.FORGE_XAPI_CONFIG;
const config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
objectType: 'Activity',
id: config.activity_id,
definition: { name: { "fr-FR": config.activity_name } },
definition: { name: { 'fr-FR': config.activity_name } },
};
}
@@ -80,24 +90,24 @@ function forgeXapiObject() {
// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de
// "quête" pour le porter.
function forgeXapiDialogueObject(dialogueId, objectName) {
var config = window.FORGE_XAPI_CONFIG;
const config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
id: config.activity_id + ":dialogue:" + dialogueId,
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
objectType: 'Activity',
id: `${config.activity_id}:dialogue:${dialogueId}`,
definition: { name: { 'fr-FR': objectName || 'Dialogue' }, type: 'http://adlnet.gov/expapi/activities/simulation' }, // NOSONAR S5332
};
}
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
var config = window.FORGE_XAPI_CONFIG;
const config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
objectType: 'Activity',
id: `${config.activity_id}:dialogue:${dialogueId}:question:${questionIndex}`,
definition: {
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
description: { "fr-FR": questionText || "" },
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
interactionType: "choice",
name: { 'fr-FR': `Question ${questionIndex + 1} — ${objectName || 'Dialogue'}` },
description: { 'fr-FR': questionText || '' },
type: 'http://adlnet.gov/expapi/activities/cmi.interaction', // NOSONAR S5332
interactionType: 'choice',
},
};
}
@@ -110,27 +120,27 @@ function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, question
// (forgeXapiObject), ou une sous-activité quête/question
// (forgeXapiQuestObject/forgeXapiQuestionObject) pour un suivi plus fin.
function forgeXapiSendStatement(verbId, result, object) {
var config = window.FORGE_XAPI_CONFIG;
const config = window.FORGE_XAPI_CONFIG;
if (!config || !config.endpoint) return;
var statement = {
const statement = {
actor: forgeXapiActor(),
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { "en-US": verbId } },
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { 'en-US': verbId } },
object: object || forgeXapiObject(),
timestamp: new Date().toISOString(),
};
if (result) statement.result = result;
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`;
try {
fetch(endpoint, {
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
"X-Experience-API-Version": "1.0.3",
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
'Content-Type': 'application/json',
'X-Experience-API-Version': '1.0.3',
Authorization: `Basic ${btoa(`${config.login}:${config.password}`)}`,
},
body: JSON.stringify(statement),
}).catch(function (err) {
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
}).catch((err) => {
console.warn('xAPI: envoi du statement échoué (LRS injoignable ?)', err);
});
} catch (err) {
console.warn("xAPI: impossible d'envoyer le statement", err);
@@ -143,8 +153,8 @@ function forgeXapiSendStatement(verbId, result, object) {
function forgeXapiNotifyScoreChanged(newScore) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/scored",
{ score: { raw: newScore } }
'http://adlnet.gov/expapi/verbs/scored', // NOSONAR S5332
{ score: { raw: newScore } },
);
}
@@ -156,44 +166,51 @@ function forgeXapiNotifyScoreChanged(newScore) {
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/completed",
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
'http://adlnet.gov/expapi/verbs/completed', // NOSONAR S5332
{ completion: true },
forgeXapiDialogueObject(dialogueId, objectName),
);
}
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/answered",
'http://adlnet.gov/expapi/verbs/answered', // NOSONAR S5332
{ success: correct, score: { raw: scoreAwarded || 0 } },
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText),
);
}
function forgeXapiNotifyStatusChanged(newStatus) {
if (!window.FORGE_XAPI_CONFIG) return;
var verb = FORGE_TO_XAPI_VERB[newStatus];
const verb = FORGE_TO_XAPI_VERB[newStatus];
if (!verb || newStatus === forgeXapiLastStatusSent) return;
forgeXapiLastStatusSent = newStatus;
forgeXapiSendStatement(verb.id, {
completion: true,
success: newStatus !== "echoue",
success: newStatus !== 'echoue',
});
}
if (typeof window !== "undefined") {
window.addEventListener("load", function () {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized");
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/initialized'); // NOSONAR S5332
});
window.addEventListener("beforeunload", function () {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated");
window.addEventListener('beforeunload', () => {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/terminated'); // NOSONAR S5332
});
}
if (typeof module !== "undefined" && module.exports) {
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
forgeXapiSendStatement,
forgeXapiNotifyScoreChanged,
forgeXapiNotifyStatusChanged,
FORGE_TO_XAPI_VERB,
forgeXapiNotifyDialogueCompleted,
forgeXapiNotifyQuestionAnswered,
forgeXapiDialogueObject,
forgeXapiQuestionObject,
FORGE_XAPI_VERB_DISPLAY,
};
}