Files
williamandClaude Sonnet 5 c57420c8c9 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>
2026-09-15 16:06:15 +02:00

174 lines
7.6 KiB
JavaScript

// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port fidèle des 5 handlers d'action qui, en ligne,
// passent par le serveur (voir static/js/play/actions.js) : ici, ils
// mutent gameData EN PLACE, sans aucun réseau. N'existe que pour
// window.FORGE_OFFLINE === true (paquet exporté) ; le runtime en ligne
// garde son comportement actuel, inchangé.
//
// Un score/statut de partie n'a PAS de notion per-player hors ligne (un
// paquet exporté est joué par UNE seule personne à la fois, sur SON
// poste) : gameData.scoring = {score, status}, pas une carte par joueur
// comme db/scoring/ côté serveur (voir db.PLAYER_SHARED).
// ---------- Modifier une variable globale ----------
// Port de screens/data_actions/apply_variable_action.py — même
// coercion de type, même vocabulaire d'opérations (compute-operation.js),
// la valeur reste stockée en CHAÎNE (gameData.variables[name].value est
// toujours une chaîne côté serveur, colonne SQLite TEXT — voir
// full_game_payload.py) pour que conditions.js continue de la lire
// exactement comme aujourd'hui.
function forgeApplyVariableActionOffline(gameData, action) {
const varName = action.target_variable;
const operation = action.data_operation;
const rawValue = action.data_value;
if (!varName || !operation) return false;
const variable = gameData.variables && gameData.variables[varName];
if (!variable) return false;
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;
}
let newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, varType === 'nombre_decimal');
} catch (e) {
return false;
}
variable.value = String(newValue);
return true;
}
// ---------- Modifier le score / Définir le statut de la partie ----------
// 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).
const FORGE_SCORE_STATUS_KEYS = ['non_commence', 'en_cours', 'termine', 'reussi', 'echoue'];
function forgeApplyScoreActionOffline(gameData, action) {
const operation = action.data_operation;
const rawValue = action.data_value;
if (!operation) return false;
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) {
return false;
}
gameData.scoring.score = newValue;
// 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);
return true;
}
function forgeApplyStatusActionOffline(gameData, action) {
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' };
gameData.scoring.status = status;
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(status);
return true;
}
// ---------- Modifier une donnée d'un objet ----------
// Port de apply_data_action.py — même bornage min_value/max_value (voir
// full_game_payload.py, étendu pour transmettre ces deux bornes au
// client). gameData.data[definitionId] est un tableau de lignes
// {champ: valeur, id}, gameData.fields_meta[definitionId] la liste
// {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) {
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fields) return null;
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.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);
}
function forgeApplyDataActionOffline(gameData, action) {
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;
const fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
if (!fieldMeta) return false;
const rows = gameData.data && gameData.data[definitionId];
if (!rows) return false;
let row = null;
for (let i = 0; i < rows.length; i++) {
if (rows[i].id === rowId) { row = rows[i]; break; }
}
if (!row) return false;
const current = row[fieldName];
let newValue;
try {
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === 'nombre_decimal');
} catch (e) {
return false;
}
row[fieldName] = forgeClampToFieldBounds(newValue, fieldMeta);
return true;
}
// ---------- Ajouter une ligne à un objet ----------
// Port de apply_add_row_action.py/db/rows/insert_row.py : une ligne VIDE
// (aucun champ renseigné — c'est à un ou plusieurs nœuds "Modifier une
// donnée" enchaînés, ciblant LAST_INSERTED_ROW_ID, de la remplir ensuite),
// même défauts par type que row_columns_and_values.py (booléen -> 0,
// 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) {
const definitionId = String(action.target_definition_id || '');
if (!definitionId) return null;
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fields) return null;
if (!gameData.data[definitionId]) gameData.data[definitionId] = [];
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;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
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.
global.forgeComputeNewValue = global.forgeComputeNewValue || require('./compute-operation.js').forgeComputeNewValue;
}