Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c504ace167
commit
d664ed5637
@@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0);
|
||||
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]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'quest:5:question:1');
|
||||
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}$/);
|
||||
assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?');
|
||||
@@ -134,7 +134,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0);
|
||||
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]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
@@ -147,5 +147,5 @@ test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004
|
||||
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
|
||||
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
|
||||
// ceci lèverait en tentant d'appeler .GetValue sur null.
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0));
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered('d_1', 'x', 0, 'x', true, 0, 0));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la
|
||||
// granularité par quête/question demandée par l'utilisateur (quête
|
||||
// acceptée/terminée, réponse à une question), en plus du score/statut
|
||||
// global déjà couvert par dialogue-box-controller.test.js.
|
||||
// granularité par dialogue/question demandée par l'utilisateur (dialogue
|
||||
// terminé, réponse à une question), en plus du score/statut global déjà
|
||||
// couvert par dialogue-box-controller.test.js.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -10,8 +10,8 @@ global.window.addEventListener = global.window.addEventListener || function () {
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject,
|
||||
} = require('../xapi-client.js');
|
||||
|
||||
test.beforeEach(() => {
|
||||
@@ -21,45 +21,35 @@ test.beforeEach(() => {
|
||||
};
|
||||
});
|
||||
|
||||
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
|
||||
const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
|
||||
test('forgeXapiDialogueObject construit un id de sous-activité dérivé de l\'activité du jeu, nommé d\'après l\'objet déclencheur', () => {
|
||||
const obj = forgeXapiDialogueObject('d_5', 'Le sorcier');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'Le sorcier');
|
||||
});
|
||||
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2');
|
||||
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => {
|
||||
test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant le dialogue (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(calls.length, 1);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' });
|
||||
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.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 1, 'Qui es-tu ?', true, 5);
|
||||
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.object.id, 'urn:forge-engine:game:test:quest:5:question:1');
|
||||
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);
|
||||
});
|
||||
@@ -67,7 +57,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 0);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.result.success, false);
|
||||
assert.equal(body.result.score.raw, 0);
|
||||
@@ -76,18 +66,17 @@ test('une mauvaise réponse envoie quand même un statement, avec success=false
|
||||
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.display['en-US'], 'attempted');
|
||||
assert.equal(body.verb.display['fr-FR'], 'commencé');
|
||||
assert.equal(body.verb.display['en-US'], 'completed');
|
||||
assert.equal(body.verb.display['fr-FR'], 'terminé');
|
||||
});
|
||||
|
||||
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
|
||||
global.window.FORGE_XAPI_CONFIG = null;
|
||||
let called = false;
|
||||
global.fetch = () => { called = true; return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'x');
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function forgeScormInit() {
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/quests/reward_budget.py) : sans eux, le LMS n'a que le score BRUT
|
||||
// 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.
|
||||
|
||||
@@ -93,12 +93,12 @@ function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(quest, 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", "quest:" + quest.id + ":question:" + questionIndex);
|
||||
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 || ""));
|
||||
|
||||
@@ -73,26 +73,28 @@ function forgeXapiObject() {
|
||||
}
|
||||
|
||||
// Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) :
|
||||
// une quête/question n'est PAS l'activité de premier niveau du jeu
|
||||
// un dialogue/question n'est PAS l'activité de premier niveau du jeu
|
||||
// (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un
|
||||
// id dérivé — assez pour que le LRS les distingue sans configuration
|
||||
// supplémentaire côté créateur du jeu.
|
||||
function forgeXapiQuestObject(quest) {
|
||||
// supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet
|
||||
// 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;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id,
|
||||
definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(quest, questionIndex, questionText) {
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex,
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") },
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
@@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
||||
);
|
||||
}
|
||||
|
||||
// Granularité quête/quiz (voir dialogue-box-controller.js) : demande
|
||||
// explicite "il faut savoir quand une quête a été acceptée, à telle
|
||||
// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ
|
||||
// par évènement, distinct du score/statut global (forgeXapiNotify
|
||||
// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de
|
||||
// premier niveau).
|
||||
function forgeXapiNotifyQuestAccepted(quest) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
// "attempted" (ADL standard) : pas de verbe dédié "accepted" dans le
|
||||
// vocabulaire ADL de base — attempted rend fidèlement "le joueur s'est
|
||||
// engagé sur cette quête".
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/attempted",
|
||||
null, forgeXapiQuestObject(quest)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestCompleted(quest) {
|
||||
// Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande
|
||||
// explicite "il faut savoir [...] à telle question quel point obtenu,
|
||||
// bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du
|
||||
// score/statut global (forgeXapiNotifyScoreChanged/StatusChanged
|
||||
// ci-dessus, qui restent l'activité de premier niveau).
|
||||
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/completed",
|
||||
{ completion: true }, forgeXapiQuestObject(quest)
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) {
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/answered",
|
||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
||||
forgeXapiQuestionObject(quest, questionIndex, questionText)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +193,7 @@ if (typeof window !== "undefined") {
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user