// 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