@lessonkit/core 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -23,12 +23,16 @@ __export(index_exports, {
23
23
  ACCORDION_FORBIDDEN_CHILD_TYPES: () => ACCORDION_FORBIDDEN_CHILD_TYPES,
24
24
  ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES: () => ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
25
25
  BLOCKS_14_PAGE_SLIDE: () => BLOCKS_14_PAGE_SLIDE,
26
+ BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES: () => BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
27
+ BRANCH_NODE_ALLOWED_CHILD_TYPES: () => BRANCH_NODE_ALLOWED_CHILD_TYPES,
26
28
  COMPOUND_MAX_NESTING_DEPTH: () => COMPOUND_MAX_NESTING_DEPTH,
27
29
  COMPOUND_RESUME_SCHEMA_VERSION: () => COMPOUND_RESUME_SCHEMA_VERSION,
30
+ GAME_MAP_ALLOWED_CHILD_TYPES: () => GAME_MAP_ALLOWED_CHILD_TYPES,
28
31
  ID_MAX_LENGTH: () => ID_MAX_LENGTH,
29
32
  ID_PATTERN: () => ID_PATTERN,
30
33
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: () => INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
31
34
  INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES: () => INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
35
+ MAP_STAGE_ALLOWED_CHILD_TYPES: () => MAP_STAGE_ALLOWED_CHILD_TYPES,
32
36
  PAGE_ALLOWED_CHILD_TYPES: () => PAGE_ALLOWED_CHILD_TYPES,
33
37
  SESSION_STORAGE_KEY: () => SESSION_STORAGE_KEY,
34
38
  SLIDE_ALLOWED_CHILD_TYPES: () => SLIDE_ALLOWED_CHILD_TYPES,
@@ -72,11 +76,14 @@ __export(index_exports, {
72
76
  hasCourseStarted: () => hasCourseStarted,
73
77
  hasCourseStartedEmittedToTracking: () => hasCourseStartedEmittedToTracking,
74
78
  hasCourseStartedPipelineDelivered: () => hasCourseStartedPipelineDelivered,
79
+ hasCourseStartedXapiSent: () => hasCourseStartedXapiSent,
75
80
  isChildTypeAllowed: () => isChildTypeAllowed,
81
+ isLifecycleTelemetryEvent: () => isLifecycleTelemetryEvent,
76
82
  loadCompoundState: () => loadCompoundState,
77
83
  markCourseStarted: () => markCourseStarted,
78
84
  markCourseStartedEmittedToTracking: () => markCourseStartedEmittedToTracking,
79
85
  markCourseStartedPipelineDelivered: () => markCourseStartedPipelineDelivered,
86
+ markCourseStartedXapiSent: () => markCourseStartedXapiSent,
80
87
  migrateCourseStartedMark: () => migrateCourseStartedMark,
81
88
  nowIso: () => nowIso,
82
89
  parseBlockId: () => parseBlockId,
@@ -95,6 +102,7 @@ __export(index_exports, {
95
102
  telemetryCatalogVersion: () => telemetryCatalogVersion,
96
103
  tryBuildTelemetryEvent: () => tryBuildTelemetryEvent,
97
104
  tryEmitCourseStarted: () => tryEmitCourseStarted,
105
+ validateBranchGraph: () => validateBranchGraph,
98
106
  validateId: () => validateId
99
107
  });
100
108
  module.exports = __toCommonJS(index_exports);
@@ -172,12 +180,26 @@ function uniqueFallbackId(input, usedIds) {
172
180
  const hash = shortHash(input);
173
181
  for (let n = 0; n < 100; n++) {
174
182
  const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
175
- const validated2 = validateId(candidate);
176
- if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
183
+ const validated = validateId(candidate);
184
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
185
+ }
186
+ for (let attempt = 0; attempt < 100; attempt++) {
187
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
188
+ const candidate = `id-${hash}-${randomSuffix}`.slice(0, 64);
189
+ const validated = validateId(candidate);
190
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
177
191
  }
178
192
  const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
179
- const validated = validateId(timed);
180
- return validated.ok ? validated.id : `id-${hash}`;
193
+ const timedValidated = validateId(timed);
194
+ if (timedValidated.ok && !usedIds.has(timedValidated.id)) return timedValidated.id;
195
+ const cryptoApi = globalThis.crypto;
196
+ for (let attempt = 0; attempt < 1e3; attempt++) {
197
+ const suffix = typeof cryptoApi?.randomUUID === "function" ? cryptoApi.randomUUID().replace(/-/g, "").slice(0, 12) : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
198
+ const candidate = `id-${hash}-${suffix}`.slice(0, 64);
199
+ const validated = validateId(candidate);
200
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
201
+ }
202
+ throw new Error(`[lessonkit] unable to derive unique id for input: ${input.slice(0, 32)}`);
181
203
  }
182
204
  function slugifyId(input) {
183
205
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
@@ -219,6 +241,13 @@ function buildLessonkitUrn(parts) {
219
241
  }
220
242
  urn += `:block:${blockId}`;
221
243
  }
244
+ if (parts.nodeId !== void 0) {
245
+ const nodeId = assertValidId(parts.nodeId, "nodeId");
246
+ if (parts.blockId === void 0) {
247
+ throw new Error("buildLessonkitUrn: nodeId requires blockId");
248
+ }
249
+ urn += `:node:${nodeId}`;
250
+ }
222
251
  return urn;
223
252
  }
224
253
 
@@ -263,23 +292,31 @@ function isPlainSerializableChildState(value) {
263
292
  (entry) => isValidChildResumeValue(entry)
264
293
  );
265
294
  }
266
- function parseCompoundResumeState(raw) {
295
+ function parseCompoundResumeState(raw, opts) {
267
296
  if (!raw || typeof raw !== "object") return null;
268
297
  const obj = raw;
269
298
  if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
270
299
  if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
271
300
  const childStates = {};
301
+ const droppedChildKeys = [];
272
302
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
273
303
  for (const [key, value] of Object.entries(obj.childStates)) {
274
304
  if (isPlainSerializableChildState(value)) {
275
305
  childStates[key] = value;
306
+ } else {
307
+ droppedChildKeys.push(key);
276
308
  }
277
309
  }
278
310
  }
311
+ if (droppedChildKeys.length > 0) {
312
+ opts?.onDroppedChildKeys?.(droppedChildKeys);
313
+ }
279
314
  const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
315
+ const rawPageIndex = Math.max(0, Math.floor(obj.activePageIndex));
316
+ const activePageIndex = typeof opts?.pageCount === "number" && opts.pageCount > 0 ? clampCompoundPageIndex(rawPageIndex, opts.pageCount) : rawPageIndex;
280
317
  return {
281
318
  schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
282
- activePageIndex: Math.max(0, Math.floor(obj.activePageIndex)),
319
+ activePageIndex,
283
320
  ...activeChapterIndex !== void 0 ? { activeChapterIndex: Math.max(0, Math.floor(activeChapterIndex)) } : {},
284
321
  childStates
285
322
  };
@@ -300,17 +337,21 @@ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
300
337
  function compoundStateStorageKey(courseId, compoundId) {
301
338
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
302
339
  }
303
- function loadCompoundState(storage, courseId, compoundId) {
340
+ function loadCompoundState(storage, courseId, compoundId, opts) {
304
341
  const key = compoundStateStorageKey(courseId, compoundId);
305
342
  const raw = storage.getItem(key);
306
343
  if (!raw) return null;
307
344
  try {
308
- const parsed = parseCompoundResumeState(JSON.parse(raw));
309
- if (parsed === null && isDevEnvironment()) {
310
- console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
345
+ const parsed = parseCompoundResumeState(JSON.parse(raw), opts);
346
+ if (parsed === null) {
347
+ opts?.onCorrupt?.();
348
+ if (isDevEnvironment()) {
349
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
350
+ }
311
351
  }
312
352
  return parsed;
313
353
  } catch {
354
+ opts?.onCorrupt?.();
314
355
  if (isDevEnvironment()) {
315
356
  console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
316
357
  }
@@ -367,8 +408,110 @@ var PAGE_ALLOWED_CHILD_TYPES = [
367
408
  "FindHotspot",
368
409
  "FindMultipleHotspots",
369
410
  "ImageSlider",
411
+ "Embed",
412
+ "Chart",
413
+ "Table",
414
+ "ImageJuxtaposition",
415
+ "Timeline",
416
+ "ImageSequence",
417
+ "Collage",
418
+ "AudioRecorder",
419
+ "CombinationLock",
420
+ "QrContent",
421
+ "Crossword",
422
+ "AdventCalendar",
370
423
  "ProgressTracker"
371
424
  ];
425
+ var BRANCH_NODE_ALLOWED_CHILD_TYPES = [
426
+ "Text",
427
+ "Heading",
428
+ "Image",
429
+ "Video",
430
+ "Scenario",
431
+ "Reflection",
432
+ "Quiz",
433
+ "KnowledgeCheck",
434
+ "TrueFalse",
435
+ "FillInTheBlanks",
436
+ "DragAndDrop",
437
+ "DragTheWords",
438
+ "MarkTheWords",
439
+ "Summary",
440
+ "ImagePairing",
441
+ "ImageSequencing",
442
+ "MemoryGame",
443
+ "InformationWall",
444
+ "ParallaxSlideshow",
445
+ "Questionnaire",
446
+ "Essay",
447
+ "ArithmeticQuiz",
448
+ "Accordion",
449
+ "DialogCards",
450
+ "Flashcards",
451
+ "ImageHotspots",
452
+ "FindHotspot",
453
+ "FindMultipleHotspots",
454
+ "ImageSlider",
455
+ "Embed",
456
+ "Chart",
457
+ "Table",
458
+ "ImageJuxtaposition",
459
+ "Timeline",
460
+ "ImageSequence",
461
+ "Collage",
462
+ "AudioRecorder",
463
+ "CombinationLock",
464
+ "QrContent",
465
+ "Crossword",
466
+ "AdventCalendar",
467
+ "BranchChoice"
468
+ ];
469
+ var BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES = ["BranchNode"];
470
+ var GAME_MAP_ALLOWED_CHILD_TYPES = ["MapStage"];
471
+ var MAP_STAGE_ALLOWED_CHILD_TYPES = [
472
+ "Text",
473
+ "Heading",
474
+ "Image",
475
+ "Video",
476
+ "Scenario",
477
+ "Reflection",
478
+ "Quiz",
479
+ "KnowledgeCheck",
480
+ "TrueFalse",
481
+ "FillInTheBlanks",
482
+ "DragAndDrop",
483
+ "DragTheWords",
484
+ "MarkTheWords",
485
+ "Summary",
486
+ "ImagePairing",
487
+ "ImageSequencing",
488
+ "MemoryGame",
489
+ "InformationWall",
490
+ "ParallaxSlideshow",
491
+ "Questionnaire",
492
+ "Essay",
493
+ "ArithmeticQuiz",
494
+ "Accordion",
495
+ "DialogCards",
496
+ "Flashcards",
497
+ "ImageHotspots",
498
+ "FindHotspot",
499
+ "FindMultipleHotspots",
500
+ "ImageSlider",
501
+ "Embed",
502
+ "Chart",
503
+ "Table",
504
+ "ImageJuxtaposition",
505
+ "Timeline",
506
+ "ImageSequence",
507
+ "Collage",
508
+ "AudioRecorder",
509
+ "CombinationLock",
510
+ "QrContent",
511
+ "Crossword",
512
+ "AdventCalendar",
513
+ "MapExit"
514
+ ];
372
515
  var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
373
516
  var SLIDE_ALLOWED_CHILD_TYPES = [
374
517
  "Text",
@@ -399,7 +542,19 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
399
542
  "ImageHotspots",
400
543
  "FindHotspot",
401
544
  "FindMultipleHotspots",
402
- "ImageSlider"
545
+ "ImageSlider",
546
+ "Embed",
547
+ "Chart",
548
+ "Table",
549
+ "ImageJuxtaposition",
550
+ "Timeline",
551
+ "ImageSequence",
552
+ "Collage",
553
+ "AudioRecorder",
554
+ "CombinationLock",
555
+ "QrContent",
556
+ "Crossword",
557
+ "AdventCalendar"
403
558
  ];
404
559
  var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
405
560
  var TIMED_CUE_ALLOWED_CHILD_TYPES = [
@@ -441,7 +596,11 @@ var ALLOWLISTS = {
441
596
  SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
442
597
  TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
443
598
  InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
444
- AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
599
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
600
+ BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
601
+ BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES,
602
+ GameMap: GAME_MAP_ALLOWED_CHILD_TYPES,
603
+ MapStage: MAP_STAGE_ALLOWED_CHILD_TYPES
445
604
  };
446
605
  var COMPOUND_MAX_NESTING_DEPTH = {
447
606
  Page: 1,
@@ -450,7 +609,11 @@ var COMPOUND_MAX_NESTING_DEPTH = {
450
609
  SlideDeck: 2,
451
610
  TimedCue: 1,
452
611
  InteractiveVideo: 2,
453
- AssessmentSequence: 1
612
+ AssessmentSequence: 1,
613
+ BranchingScenario: 2,
614
+ BranchNode: 1,
615
+ GameMap: 2,
616
+ MapStage: 1
454
617
  };
455
618
  function getAllowedChildTypes(parent) {
456
619
  return ALLOWLISTS[parent];
@@ -461,6 +624,82 @@ function isChildTypeAllowed(parent, childType) {
461
624
  var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
462
625
  var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
463
626
 
627
+ // src/branchGraph.ts
628
+ function validateBranchGraph(startNodeId, nodes) {
629
+ const issues = [];
630
+ if (nodes.length === 0) {
631
+ issues.push({ code: "empty_graph", message: "Branch graph has no nodes" });
632
+ return { ok: false, issues, reachableNodeIds: [] };
633
+ }
634
+ const nodeIds = /* @__PURE__ */ new Set();
635
+ for (const node of nodes) {
636
+ if (nodeIds.has(node.nodeId)) {
637
+ issues.push({
638
+ code: "duplicate_node_id",
639
+ message: `Duplicate nodeId "${node.nodeId}"`,
640
+ nodeId: node.nodeId
641
+ });
642
+ }
643
+ nodeIds.add(node.nodeId);
644
+ }
645
+ if (!nodeIds.has(startNodeId)) {
646
+ issues.push({
647
+ code: "start_not_found",
648
+ message: `startNodeId "${startNodeId}" does not match any BranchNode`,
649
+ nodeId: startNodeId
650
+ });
651
+ }
652
+ if (nodes.length > 1 && nodeIds.has(startNodeId)) {
653
+ const startNode = nodes.find((n) => n.nodeId === startNodeId);
654
+ if (startNode && startNode.choices.length === 0) {
655
+ issues.push({
656
+ code: "start_no_choices",
657
+ message: `startNodeId "${startNodeId}" has no BranchChoice children in a multi-node scenario`,
658
+ nodeId: startNodeId
659
+ });
660
+ }
661
+ }
662
+ for (const node of nodes) {
663
+ for (const choice of node.choices) {
664
+ if (!nodeIds.has(choice.targetNodeId)) {
665
+ issues.push({
666
+ code: "unknown_target",
667
+ message: `Choice from "${node.nodeId}" references unknown target "${choice.targetNodeId}"`,
668
+ nodeId: node.nodeId
669
+ });
670
+ }
671
+ }
672
+ }
673
+ const reachable = /* @__PURE__ */ new Set();
674
+ if (nodeIds.has(startNodeId)) {
675
+ const queue = [startNodeId];
676
+ while (queue.length > 0) {
677
+ const current = queue.shift();
678
+ if (reachable.has(current)) continue;
679
+ reachable.add(current);
680
+ const node = nodes.find((n) => n.nodeId === current);
681
+ if (!node) continue;
682
+ for (const choice of node.choices) {
683
+ if (!reachable.has(choice.targetNodeId)) queue.push(choice.targetNodeId);
684
+ }
685
+ }
686
+ }
687
+ for (const nodeId of nodeIds) {
688
+ if (!reachable.has(nodeId)) {
689
+ issues.push({
690
+ code: "unreachable_node",
691
+ message: `Node "${nodeId}" is not reachable from startNodeId "${startNodeId}"`,
692
+ nodeId
693
+ });
694
+ }
695
+ }
696
+ return {
697
+ ok: issues.length === 0,
698
+ issues,
699
+ reachableNodeIds: [...reachable]
700
+ };
701
+ }
702
+
464
703
  // src/telemetryCatalog.ts
465
704
  var telemetryCatalogVersion = 1;
466
705
  var TELEMETRY_EVENT_CATALOG = [
@@ -663,6 +902,94 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
663
902
  dataFields: ["blockId", "fieldCount"],
664
903
  xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
665
904
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
905
+ },
906
+ {
907
+ name: "branch_node_viewed",
908
+ description: "Learner viewed a node in a BranchingScenario",
909
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
910
+ dataFields: ["blockId", "nodeId", "nodeIndex", "nodeTitle"],
911
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
912
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{nodeId}"
913
+ },
914
+ {
915
+ name: "branch_selected",
916
+ description: "Learner selected a branch choice in a BranchingScenario",
917
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
918
+ dataFields: ["blockId", "fromNodeId", "toNodeId", "label", "scoreWeight"],
919
+ xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
920
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{toNodeId}"
921
+ },
922
+ {
923
+ name: "image_juxtaposition_changed",
924
+ description: "Learner adjusted the before/after divider",
925
+ requiredFields: ["courseId", "sessionId", "timestamp"],
926
+ dataFields: ["blockId", "position"],
927
+ xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
928
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
929
+ },
930
+ {
931
+ name: "timeline_event_viewed",
932
+ description: "Learner focused a timeline event",
933
+ requiredFields: ["courseId", "sessionId", "timestamp"],
934
+ dataFields: ["blockId", "eventId"],
935
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
936
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
937
+ },
938
+ {
939
+ name: "image_sequence_changed",
940
+ description: "Learner changed the image sequence frame",
941
+ requiredFields: ["courseId", "sessionId", "timestamp"],
942
+ dataFields: ["blockId", "frameIndex"],
943
+ xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
944
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
945
+ },
946
+ {
947
+ name: "audio_recording_started",
948
+ description: "Learner started an audio recording",
949
+ requiredFields: ["courseId", "sessionId", "timestamp"],
950
+ dataFields: ["blockId"],
951
+ xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
952
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
953
+ },
954
+ {
955
+ name: "audio_recording_completed",
956
+ description: "Learner completed an audio recording",
957
+ requiredFields: ["courseId", "sessionId", "timestamp"],
958
+ dataFields: ["blockId"],
959
+ xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
960
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
961
+ },
962
+ {
963
+ name: "qr_content_revealed",
964
+ description: "Learner revealed QR hidden content",
965
+ requiredFields: ["courseId", "sessionId", "timestamp"],
966
+ dataFields: ["blockId"],
967
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
968
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
969
+ },
970
+ {
971
+ name: "advent_door_opened",
972
+ description: "Learner opened an advent calendar door",
973
+ requiredFields: ["courseId", "sessionId", "timestamp"],
974
+ dataFields: ["blockId", "doorId", "day"],
975
+ xapiVerb: "http://adlnet.gov/expapi/verbs/interacted",
976
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
977
+ },
978
+ {
979
+ name: "map_stage_viewed",
980
+ description: "Learner viewed a stage in a GameMap",
981
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
982
+ dataFields: ["blockId", "stageId", "stageIndex", "stageLabel"],
983
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
984
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:stage:{stageId}"
985
+ },
986
+ {
987
+ name: "map_exit_selected",
988
+ description: "Learner selected a map exit in a GameMap",
989
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
990
+ dataFields: ["blockId", "fromStageId", "toStageId", "label", "scoreWeight"],
991
+ xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
992
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:stage:{toStageId}"
666
993
  }
667
994
  ];
668
995
  function buildTelemetryCatalogV3() {
@@ -694,22 +1021,12 @@ function invokeTrackingSink(sink, event) {
694
1021
  void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
695
1022
  }
696
1023
  }
697
- function invokePipelineSink(sinkId, emit) {
698
- let result;
699
- try {
700
- result = emit();
701
- } catch (err) {
702
- warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
703
- return;
704
- }
705
- if (result != null && typeof result.catch === "function") {
706
- void result.catch(
707
- (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
708
- );
709
- }
710
- }
711
1024
 
712
1025
  // src/trackingClient.ts
1026
+ function eventDedupKey(event) {
1027
+ const id = event.id?.trim();
1028
+ return id || void 0;
1029
+ }
713
1030
  function createTrackingClient(opts) {
714
1031
  const sink = opts?.sink;
715
1032
  const batchSink = opts?.batchSink;
@@ -727,13 +1044,14 @@ function createTrackingClient(opts) {
727
1044
  let disposed2 = false;
728
1045
  return {
729
1046
  track: (event) => {
730
- if (disposed2) return;
1047
+ if (disposed2) return false;
731
1048
  if (sink) {
732
1049
  try {
733
1050
  invokeTrackingSink(sink, event);
734
1051
  } catch {
735
1052
  }
736
1053
  }
1054
+ return true;
737
1055
  },
738
1056
  deliver: async (event) => {
739
1057
  if (disposed2) return false;
@@ -746,19 +1064,32 @@ function createTrackingClient(opts) {
746
1064
  };
747
1065
  }
748
1066
  if (!sink && !batchSink) {
749
- return { track: () => {
750
- } };
1067
+ return { track: () => true };
751
1068
  }
752
1069
  const buffer = [];
1070
+ const pendingDeliverIds = /* @__PURE__ */ new Set();
753
1071
  let flushInFlight = null;
754
- let inflightExitBatch = null;
755
1072
  let disposed = false;
756
1073
  let disposing = false;
757
1074
  let intervalId;
1075
+ const clearPendingDeliverIds = (events) => {
1076
+ for (const event of events) {
1077
+ const key = eventDedupKey(event);
1078
+ if (key) pendingDeliverIds.delete(key);
1079
+ }
1080
+ };
1081
+ const isEventBuffered = (event) => {
1082
+ const key = eventDedupKey(event);
1083
+ if (!key) return false;
1084
+ return buffer.some((buffered) => eventDedupKey(buffered) === key);
1085
+ };
758
1086
  const runFlush = () => {
759
1087
  if (!buffer.length) return Promise.resolve(true);
760
1088
  const events = buffer.splice(0, buffer.length);
761
- inflightExitBatch = events;
1089
+ for (const event of events) {
1090
+ const key = eventDedupKey(event);
1091
+ if (key) pendingDeliverIds.add(key);
1092
+ }
762
1093
  let succeeded = false;
763
1094
  return Promise.resolve().then(async () => {
764
1095
  if (batchSink) {
@@ -768,7 +1099,7 @@ function createTrackingClient(opts) {
768
1099
  try {
769
1100
  await sink?.(events[i]);
770
1101
  } catch {
771
- buffer.unshift(...events.slice(i));
1102
+ buffer.unshift(...events);
772
1103
  return;
773
1104
  }
774
1105
  }
@@ -779,12 +1110,13 @@ function createTrackingClient(opts) {
779
1110
  buffer.unshift(...events);
780
1111
  }
781
1112
  }).then(async () => {
1113
+ if (succeeded) {
1114
+ clearPendingDeliverIds(events);
1115
+ }
782
1116
  if (succeeded && buffer.length > 0 && !disposed) {
783
1117
  return runFlush();
784
1118
  }
785
1119
  return succeeded;
786
- }).finally(() => {
787
- inflightExitBatch = null;
788
1120
  });
789
1121
  };
790
1122
  const flush = () => {
@@ -805,18 +1137,27 @@ function createTrackingClient(opts) {
805
1137
  if (!delivered) break;
806
1138
  }
807
1139
  if (buffer.length > 0) {
1140
+ const droppedCount = buffer.length;
808
1141
  if (isDevEnvironment()) {
809
1142
  console.warn(
810
- `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
1143
+ `[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
811
1144
  );
812
1145
  }
1146
+ for (let i = 0; i < droppedCount; i++) {
1147
+ opts?.onBufferDrop?.();
1148
+ }
813
1149
  buffer.length = 0;
1150
+ pendingDeliverIds.clear();
814
1151
  }
815
1152
  };
816
1153
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
817
1154
  intervalId?.unref?.();
818
1155
  const track = (event) => {
819
- if (disposed || disposing) return;
1156
+ if (disposed || disposing) return false;
1157
+ const key = eventDedupKey(event);
1158
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
1159
+ return true;
1160
+ }
820
1161
  if (buffer.length >= maxBufferSize) {
821
1162
  opts?.onBufferDrop?.();
822
1163
  if (!warnedBufferCap && isDevEnvironment()) {
@@ -825,29 +1166,37 @@ function createTrackingClient(opts) {
825
1166
  `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
826
1167
  );
827
1168
  }
828
- return;
1169
+ return false;
829
1170
  }
830
1171
  buffer.push(event);
831
1172
  if (buffer.length >= maxBatchSize) void flush();
1173
+ return true;
832
1174
  };
833
1175
  return {
834
1176
  track,
835
1177
  deliver: async (event) => {
836
- track(event);
1178
+ const key = eventDedupKey(event);
1179
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
1180
+ return flush();
1181
+ }
1182
+ if (!track(event)) return false;
1183
+ if (key) pendingDeliverIds.add(key);
837
1184
  return flush();
838
1185
  },
839
1186
  flush,
840
1187
  flushOnExit: opts?.exitBatchSink ? () => {
841
- const fromBuffer = buffer.splice(0, buffer.length);
842
- const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
843
- const events = [...fromInflight, ...fromBuffer];
1188
+ const events = buffer.splice(0, buffer.length);
844
1189
  if (!events.length) return;
845
1190
  try {
846
1191
  const result = opts.exitBatchSink(events);
847
1192
  if (result != null && typeof result.catch === "function") {
848
- void result.catch(() => {
1193
+ void result.then(() => {
1194
+ clearPendingDeliverIds(events);
1195
+ }).catch(() => {
849
1196
  buffer.unshift(...events);
850
1197
  });
1198
+ } else {
1199
+ clearPendingDeliverIds(events);
851
1200
  }
852
1201
  } catch {
853
1202
  buffer.unshift(...events);
@@ -869,10 +1218,24 @@ function createTrackingClient(opts) {
869
1218
  }
870
1219
 
871
1220
  // src/ids.ts
1221
+ function randomSessionIdFallback() {
1222
+ const g = globalThis;
1223
+ if (g.crypto?.getRandomValues) {
1224
+ const bytes = new Uint8Array(16);
1225
+ g.crypto.getRandomValues(bytes);
1226
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1227
+ return `s-${hex}`;
1228
+ }
1229
+ throw new Error(
1230
+ "[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
1231
+ );
1232
+ }
872
1233
  function createSessionId() {
873
1234
  const g = globalThis;
874
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
875
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
1235
+ if (g.crypto?.randomUUID) {
1236
+ return `s-${g.crypto.randomUUID().replace(/-/g, "")}`;
1237
+ }
1238
+ return randomSessionIdFallback();
876
1239
  }
877
1240
 
878
1241
  // src/time.ts
@@ -1151,6 +1514,118 @@ var TELEMETRY_EVENT_REGISTRY = {
1151
1514
  data: opts.data
1152
1515
  };
1153
1516
  }
1517
+ },
1518
+ branch_node_viewed: {
1519
+ requiresLessonId: true,
1520
+ build: (opts, base) => {
1521
+ if (opts.name !== "branch_node_viewed") throw new Error("unexpected event");
1522
+ const lessonId = opts.lessonId;
1523
+ if (!lessonId) throw new Error("branch_node_viewed requires active lessonId");
1524
+ return {
1525
+ name: "branch_node_viewed",
1526
+ ...base,
1527
+ lessonId,
1528
+ data: opts.data
1529
+ };
1530
+ }
1531
+ },
1532
+ branch_selected: {
1533
+ requiresLessonId: true,
1534
+ build: (opts, base) => {
1535
+ if (opts.name !== "branch_selected") throw new Error("unexpected event");
1536
+ const lessonId = opts.lessonId;
1537
+ if (!lessonId) throw new Error("branch_selected requires active lessonId");
1538
+ return {
1539
+ name: "branch_selected",
1540
+ ...base,
1541
+ lessonId,
1542
+ data: opts.data
1543
+ };
1544
+ }
1545
+ },
1546
+ image_juxtaposition_changed: {
1547
+ build: (opts, base) => ({
1548
+ name: "image_juxtaposition_changed",
1549
+ ...base,
1550
+ lessonId: opts.lessonId,
1551
+ data: opts.data
1552
+ })
1553
+ },
1554
+ timeline_event_viewed: {
1555
+ build: (opts, base) => ({
1556
+ name: "timeline_event_viewed",
1557
+ ...base,
1558
+ lessonId: opts.lessonId,
1559
+ data: opts.data
1560
+ })
1561
+ },
1562
+ image_sequence_changed: {
1563
+ build: (opts, base) => ({
1564
+ name: "image_sequence_changed",
1565
+ ...base,
1566
+ lessonId: opts.lessonId,
1567
+ data: opts.data
1568
+ })
1569
+ },
1570
+ audio_recording_started: {
1571
+ build: (opts, base) => ({
1572
+ name: "audio_recording_started",
1573
+ ...base,
1574
+ lessonId: opts.lessonId,
1575
+ data: opts.data
1576
+ })
1577
+ },
1578
+ audio_recording_completed: {
1579
+ build: (opts, base) => ({
1580
+ name: "audio_recording_completed",
1581
+ ...base,
1582
+ lessonId: opts.lessonId,
1583
+ data: opts.data
1584
+ })
1585
+ },
1586
+ qr_content_revealed: {
1587
+ build: (opts, base) => ({
1588
+ name: "qr_content_revealed",
1589
+ ...base,
1590
+ lessonId: opts.lessonId,
1591
+ data: opts.data
1592
+ })
1593
+ },
1594
+ advent_door_opened: {
1595
+ build: (opts, base) => ({
1596
+ name: "advent_door_opened",
1597
+ ...base,
1598
+ lessonId: opts.lessonId,
1599
+ data: opts.data
1600
+ })
1601
+ },
1602
+ map_stage_viewed: {
1603
+ requiresLessonId: true,
1604
+ build: (opts, base) => {
1605
+ if (opts.name !== "map_stage_viewed") throw new Error("unexpected event");
1606
+ const lessonId = opts.lessonId;
1607
+ if (!lessonId) throw new Error("map_stage_viewed requires active lessonId");
1608
+ return {
1609
+ name: "map_stage_viewed",
1610
+ ...base,
1611
+ lessonId,
1612
+ data: opts.data
1613
+ };
1614
+ }
1615
+ },
1616
+ map_exit_selected: {
1617
+ requiresLessonId: true,
1618
+ build: (opts, base) => {
1619
+ if (opts.name !== "map_exit_selected") throw new Error("unexpected event");
1620
+ const lessonId = opts.lessonId;
1621
+ if (!lessonId) throw new Error("map_exit_selected requires active lessonId");
1622
+ return {
1623
+ name: "map_exit_selected",
1624
+ ...base,
1625
+ lessonId,
1626
+ data: opts.data
1627
+ };
1628
+ }
1154
1629
  }
1155
1630
  };
1156
1631
  function buildTelemetryEventFromRegistry(opts) {
@@ -1183,8 +1658,8 @@ function buildTelemetryEvent(opts) {
1183
1658
  }
1184
1659
  function tryBuildTelemetryEvent(opts) {
1185
1660
  const entry = getTelemetryEventRegistryEntry(opts.name);
1186
- if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
1187
- if (isDevEnvironment()) {
1661
+ if (entry.requiresLessonId && !opts.lessonId) {
1662
+ if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
1188
1663
  if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
1189
1664
  warnedMissingQuizLesson = true;
1190
1665
  console.warn(
@@ -1204,21 +1679,44 @@ function tryBuildTelemetryEvent(opts) {
1204
1679
  }
1205
1680
 
1206
1681
  // src/telemetryPipeline.ts
1207
- function invokeSink(sink, event, emitCtx) {
1208
- invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
1682
+ var LIFECYCLE_TELEMETRY_EVENTS = /* @__PURE__ */ new Set([
1683
+ "course_started",
1684
+ "course_completed",
1685
+ "lesson_started",
1686
+ "lesson_completed",
1687
+ "lesson_time_on_task"
1688
+ ]);
1689
+ function isLifecycleTelemetryEvent(name) {
1690
+ return LIFECYCLE_TELEMETRY_EVENTS.has(name);
1691
+ }
1692
+ async function invokeSink(sink, event, emitCtx) {
1693
+ let result;
1694
+ try {
1695
+ result = sink.emit(event, emitCtx);
1696
+ } catch (err) {
1697
+ warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
1698
+ return;
1699
+ }
1700
+ if (result != null && typeof result.then === "function") {
1701
+ try {
1702
+ await result;
1703
+ } catch (err) {
1704
+ warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
1705
+ }
1706
+ }
1209
1707
  }
1210
1708
  function createTelemetryPipeline(sinks) {
1211
1709
  const list = [...sinks];
1212
1710
  return {
1213
1711
  sinks: list,
1214
- emit(event, ctx) {
1712
+ async emit(event, ctx) {
1215
1713
  const emitCtx = ctx ?? {
1216
1714
  courseId: event.courseId,
1217
1715
  sessionId: event.sessionId,
1218
1716
  attemptId: event.attemptId
1219
1717
  };
1220
1718
  for (const sink of list) {
1221
- invokeSink(sink, event, emitCtx);
1719
+ await invokeSink(sink, event, emitCtx);
1222
1720
  }
1223
1721
  }
1224
1722
  };
@@ -1240,14 +1738,44 @@ function createDefaultClock() {
1240
1738
  };
1241
1739
  }
1242
1740
  function createNoopStorage() {
1741
+ const memory = /* @__PURE__ */ new Map();
1243
1742
  return {
1244
- getItem: () => null,
1245
- setItem: () => true
1743
+ getItem: (key) => memory.get(key) ?? null,
1744
+ setItem: (key, value) => {
1745
+ memory.set(key, value);
1746
+ return true;
1747
+ },
1748
+ removeItem: (key) => {
1749
+ memory.delete(key);
1750
+ },
1751
+ resetForTests: () => {
1752
+ memory.clear();
1753
+ }
1246
1754
  };
1247
1755
  }
1248
1756
  function createMemoryBackedSessionStorage(session) {
1249
1757
  const memory = /* @__PURE__ */ new Map();
1758
+ const tombstones = /* @__PURE__ */ new Set();
1250
1759
  let warnedPersistFailure = false;
1760
+ const syncFromStorageEvent = (key, newValue) => {
1761
+ if (key === null) {
1762
+ memory.clear();
1763
+ tombstones.clear();
1764
+ return;
1765
+ }
1766
+ tombstones.delete(key);
1767
+ if (newValue === null) {
1768
+ memory.delete(key);
1769
+ } else {
1770
+ memory.set(key, newValue);
1771
+ }
1772
+ };
1773
+ if (typeof window !== "undefined") {
1774
+ window.addEventListener("storage", (event) => {
1775
+ if (event.storageArea !== sessionStorage) return;
1776
+ syncFromStorageEvent(event.key, event.newValue);
1777
+ });
1778
+ }
1251
1779
  const warnPersistFailure = () => {
1252
1780
  if (warnedPersistFailure) return;
1253
1781
  warnedPersistFailure = true;
@@ -1258,24 +1786,31 @@ function createMemoryBackedSessionStorage(session) {
1258
1786
  );
1259
1787
  }
1260
1788
  };
1789
+ const bypassCacheForKey = (key) => key === "lessonkit:sessionId" || key.startsWith("lessonkit:course_started");
1261
1790
  return {
1262
1791
  getItem: (key) => {
1263
- if (memory.has(key)) return memory.get(key);
1792
+ if (tombstones.has(key)) return null;
1793
+ if (!bypassCacheForKey(key) && memory.has(key)) return memory.get(key);
1264
1794
  try {
1265
1795
  const value = session.getItem(key);
1266
1796
  if (value !== null) memory.set(key, value);
1797
+ else if (bypassCacheForKey(key)) memory.delete(key);
1267
1798
  return value;
1268
1799
  } catch {
1269
1800
  return memory.get(key) ?? null;
1270
1801
  }
1271
1802
  },
1272
1803
  setItem: (key, value) => {
1273
- memory.set(key, value);
1804
+ tombstones.delete(key);
1274
1805
  try {
1275
1806
  session.setItem(key, value);
1807
+ memory.set(key, value);
1276
1808
  return true;
1277
1809
  } catch {
1278
1810
  warnPersistFailure();
1811
+ if (!bypassCacheForKey(key)) {
1812
+ memory.set(key, value);
1813
+ }
1279
1814
  return false;
1280
1815
  }
1281
1816
  },
@@ -1283,12 +1818,15 @@ function createMemoryBackedSessionStorage(session) {
1283
1818
  memory.delete(key);
1284
1819
  try {
1285
1820
  session.removeItem(key);
1821
+ tombstones.delete(key);
1286
1822
  } catch {
1287
1823
  warnPersistFailure();
1824
+ tombstones.add(key);
1288
1825
  }
1289
1826
  },
1290
1827
  resetForTests: () => {
1291
1828
  memory.clear();
1829
+ tombstones.clear();
1292
1830
  }
1293
1831
  };
1294
1832
  }
@@ -1360,6 +1898,11 @@ function createProgressController() {
1360
1898
  }
1361
1899
  return { didComplete: false };
1362
1900
  }
1901
+ if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
1902
+ console.warn(
1903
+ `[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
1904
+ );
1905
+ }
1363
1906
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
1364
1907
  if (activeLessonId === lessonId) {
1365
1908
  activeLessonId = void 0;
@@ -1380,7 +1923,6 @@ function createProgressController() {
1380
1923
  // src/session.ts
1381
1924
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
1382
1925
  var volatileSessionIds = /* @__PURE__ */ new WeakMap();
1383
- var sharedVolatileSessionId = null;
1384
1926
  function isDevEnvironment2() {
1385
1927
  const g = globalThis;
1386
1928
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -1391,39 +1933,62 @@ function getTabSessionId(storage) {
1391
1933
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
1392
1934
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
1393
1935
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
1936
+ var COURSE_STARTED_XAPI_PREFIX = "lessonkit:course_started_xapi:";
1937
+ function sessionKeySegment(sessionId) {
1938
+ const validated = validateId(sessionId);
1939
+ return validated.ok ? validated.id : encodeURIComponent(sessionId);
1940
+ }
1394
1941
  function resolveSessionId(storage, provided) {
1395
1942
  if (provided !== void 0) {
1396
1943
  const trimmed = provided.trim();
1397
- if (trimmed.length > 0) return trimmed;
1944
+ if (trimmed.length > 0) {
1945
+ const validated = validateId(trimmed);
1946
+ if (validated.ok) return validated.id;
1947
+ if (isDevEnvironment2()) {
1948
+ console.warn(
1949
+ `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
1950
+ );
1951
+ }
1952
+ }
1398
1953
  }
1399
1954
  const existing = storage.getItem(SESSION_STORAGE_KEY);
1400
- if (existing) return existing;
1955
+ if (existing) {
1956
+ const trimmedExisting = existing.trim();
1957
+ const validatedExisting = validateId(trimmedExisting);
1958
+ if (validatedExisting.ok) return validatedExisting.id;
1959
+ storage.removeItem?.(SESSION_STORAGE_KEY);
1960
+ if (isDevEnvironment2()) {
1961
+ console.warn(
1962
+ `[lessonkit] Invalid stored sessionId "${existing}"; generating a new id.`
1963
+ );
1964
+ }
1965
+ }
1401
1966
  const volatile = volatileSessionIds.get(storage);
1402
1967
  if (volatile) return volatile;
1403
1968
  const id = createSessionId();
1404
1969
  const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
1405
1970
  if (!persisted) {
1406
- if (!sharedVolatileSessionId) {
1407
- sharedVolatileSessionId = id;
1408
- }
1409
- volatileSessionIds.set(storage, sharedVolatileSessionId);
1971
+ volatileSessionIds.set(storage, id);
1410
1972
  if (isDevEnvironment2()) {
1411
1973
  console.warn(
1412
- "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
1974
+ "[lessonkit] session id could not be persisted; using in-memory id for this storage."
1413
1975
  );
1414
1976
  }
1415
- return sharedVolatileSessionId;
1977
+ return id;
1416
1978
  }
1417
1979
  return id;
1418
1980
  }
1419
1981
  function courseStartedStorageKey(sessionId, courseId) {
1420
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
1982
+ return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1421
1983
  }
1422
1984
  function courseStartedTrackingStorageKey(sessionId, courseId) {
1423
- return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
1985
+ return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1424
1986
  }
1425
1987
  function courseStartedPipelineStorageKey(sessionId, courseId) {
1426
- return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
1988
+ return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1989
+ }
1990
+ function courseStartedXapiStorageKey(sessionId, courseId) {
1991
+ return `${COURSE_STARTED_XAPI_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1427
1992
  }
1428
1993
  function hasCourseStarted(storage, sessionId, courseId) {
1429
1994
  if (!courseId) return false;
@@ -1449,49 +2014,93 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1449
2014
  if (!courseId) return false;
1450
2015
  return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
1451
2016
  }
2017
+ function hasCourseStartedXapiSent(storage, sessionId, courseId) {
2018
+ if (!courseId) return false;
2019
+ return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
2020
+ }
2021
+ function markCourseStartedXapiSent(storage, sessionId, courseId) {
2022
+ if (!courseId) return false;
2023
+ return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
2024
+ }
1452
2025
  function resetSharedVolatileSessionIdForTests() {
1453
- sharedVolatileSessionId = null;
2026
+ }
2027
+ function migrateStorageMark(storage, fromKey, toKey, hasMark) {
2028
+ if (!hasMark) return;
2029
+ if (storage.setItem(toKey, "1")) {
2030
+ storage.removeItem?.(fromKey);
2031
+ }
1454
2032
  }
1455
2033
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
1456
2034
  if (!courseId || fromSessionId === toSessionId) return;
1457
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
1458
- markCourseStarted(storage, toSessionId, courseId);
1459
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
1460
- }
1461
- if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
1462
- markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
1463
- storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
1464
- }
1465
- if (hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)) {
1466
- markCourseStartedPipelineDelivered(storage, toSessionId, courseId);
1467
- storage.removeItem?.(courseStartedPipelineStorageKey(fromSessionId, courseId));
1468
- }
2035
+ migrateStorageMark(
2036
+ storage,
2037
+ courseStartedStorageKey(fromSessionId, courseId),
2038
+ courseStartedStorageKey(toSessionId, courseId),
2039
+ hasCourseStarted(storage, fromSessionId, courseId)
2040
+ );
2041
+ migrateStorageMark(
2042
+ storage,
2043
+ courseStartedTrackingStorageKey(fromSessionId, courseId),
2044
+ courseStartedTrackingStorageKey(toSessionId, courseId),
2045
+ hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)
2046
+ );
2047
+ migrateStorageMark(
2048
+ storage,
2049
+ courseStartedPipelineStorageKey(fromSessionId, courseId),
2050
+ courseStartedPipelineStorageKey(toSessionId, courseId),
2051
+ hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)
2052
+ );
2053
+ migrateStorageMark(
2054
+ storage,
2055
+ courseStartedXapiStorageKey(fromSessionId, courseId),
2056
+ courseStartedXapiStorageKey(toSessionId, courseId),
2057
+ hasCourseStartedXapiSent(storage, fromSessionId, courseId)
2058
+ );
1469
2059
  }
1470
2060
 
1471
2061
  // src/runtime/courseLifecycle.ts
1472
- var courseStartedEmitFlights = /* @__PURE__ */ new Set();
2062
+ var courseStartedEmitFlights = /* @__PURE__ */ new Map();
1473
2063
  function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
1474
2064
  const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
1475
2065
  const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1476
2066
  if (alreadyEmittedToSink) {
1477
- return { emitted: true, marked };
2067
+ const markPersisted = marked ? true : markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
2068
+ return Promise.resolve({
2069
+ emitted: true,
2070
+ marked: markPersisted
2071
+ });
1478
2072
  }
1479
- if (courseStartedEmitFlights.has(flightKey)) {
1480
- return { emitted: false, marked };
2073
+ if (marked && hasCourseStartedEmittedToTracking(ctx.storage, ctx.sessionId, ctx.courseId)) {
2074
+ return Promise.resolve({
2075
+ emitted: true,
2076
+ marked: true
2077
+ });
1481
2078
  }
1482
- courseStartedEmitFlights.add(flightKey);
1483
- try {
1484
- const emitted = deps.emitCourseStartedEvent(ctx);
1485
- if (emitted && !marked) {
1486
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1487
- }
1488
- return {
1489
- emitted,
1490
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1491
- };
1492
- } finally {
1493
- courseStartedEmitFlights.delete(flightKey);
2079
+ const existing = courseStartedEmitFlights.get(flightKey);
2080
+ if (existing) {
2081
+ return existing;
1494
2082
  }
2083
+ const flight = Promise.resolve().then(() => {
2084
+ try {
2085
+ const emitted = deps.emitCourseStartedEvent(ctx);
2086
+ const markPersisted = emitted && !marked ? markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId) : marked;
2087
+ return {
2088
+ emitted,
2089
+ marked: markPersisted
2090
+ };
2091
+ } catch {
2092
+ return {
2093
+ emitted: false,
2094
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
2095
+ };
2096
+ } finally {
2097
+ if (courseStartedEmitFlights.get(flightKey) === flight) {
2098
+ courseStartedEmitFlights.delete(flightKey);
2099
+ }
2100
+ }
2101
+ });
2102
+ courseStartedEmitFlights.set(flightKey, flight);
2103
+ return flight;
1495
2104
  }
1496
2105
  function buildCourseStartedTelemetryEvent(ctx) {
1497
2106
  return buildTelemetryEvent({
@@ -1519,7 +2128,16 @@ function completeCourseWithTelemetry(opts) {
1519
2128
  });
1520
2129
  }
1521
2130
  const result = opts.progress.completeCourse();
1522
- if (!result.didComplete) return false;
2131
+ if (!result.didComplete) {
2132
+ const after = opts.progress.getState();
2133
+ if (after.activeLessonId) {
2134
+ const lessonResult = opts.progress.completeLesson(after.activeLessonId, opts.nowMs);
2135
+ if (lessonResult.didComplete) {
2136
+ opts.emitLessonCompleted(after.activeLessonId, lessonResult.durationMs);
2137
+ }
2138
+ }
2139
+ return false;
2140
+ }
1523
2141
  opts.emitCourseCompleted();
1524
2142
  return true;
1525
2143
  }
@@ -1540,6 +2158,20 @@ function warnDuplicatePlugin(id) {
1540
2158
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
1541
2159
  console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
1542
2160
  }
2161
+ function stableUserHash(user) {
2162
+ if (!user) return "";
2163
+ const keys = Object.keys(user).sort();
2164
+ const normalized = {};
2165
+ for (const key of keys) {
2166
+ normalized[key] = user[key];
2167
+ }
2168
+ let h = 0;
2169
+ const serialized = JSON.stringify(normalized);
2170
+ for (let i = 0; i < serialized.length; i++) {
2171
+ h = Math.imul(31, h) + serialized.charCodeAt(i) >>> 0;
2172
+ }
2173
+ return h.toString(36);
2174
+ }
1543
2175
  function createPluginRegistry(plugins = []) {
1544
2176
  const registry = /* @__PURE__ */ new Map();
1545
2177
  for (const plugin of plugins) {
@@ -1581,7 +2213,7 @@ function createPluginRegistry(plugins = []) {
1581
2213
  const composeTrackingSink = (sink, ctxSource) => {
1582
2214
  if (!sink) return void 0;
1583
2215
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
1584
- const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
2216
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${stableUserHash(ctx.user)}`;
1585
2217
  const layers = [];
1586
2218
  let composed = sink;
1587
2219
  for (const plugin of list) {
@@ -1628,6 +2260,10 @@ function resolvePluginHost(plugins) {
1628
2260
  if (Array.isArray(plugins) && plugins.length > 0) return createPluginRegistry(plugins);
1629
2261
  return null;
1630
2262
  }
2263
+ function pluginListFingerprint(plugins) {
2264
+ if (!plugins || !Array.isArray(plugins)) return null;
2265
+ return plugins.map((p) => `${p.id}\0${p.version ?? ""}`).join("\n");
2266
+ }
1631
2267
  function warnRuntimeV1Deprecated() {
1632
2268
  const g = globalThis;
1633
2269
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
@@ -1640,12 +2276,20 @@ function createLessonkitRuntime(config, ports = {}) {
1640
2276
  const storage = ports.storage ?? createSessionStoragePort();
1641
2277
  const clock = ports.clock ?? createDefaultClock();
1642
2278
  const configSnapshot = { ...config };
2279
+ const hasExplicitSessionId = Boolean(configSnapshot.session?.sessionId?.trim());
2280
+ let autoSessionId;
1643
2281
  let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
2282
+ if (!hasExplicitSessionId) {
2283
+ autoSessionId = sessionId;
2284
+ }
1644
2285
  let attemptId = configSnapshot.session?.attemptId;
1645
2286
  let user = configSnapshot.session?.user;
1646
2287
  let courseId = configSnapshot.courseId;
2288
+ let configuredSessionId = configSnapshot.session?.sessionId;
1647
2289
  let progress = createProgressController();
1648
2290
  let pluginHost = resolvePluginHost(configSnapshot.plugins);
2291
+ let pluginFingerprint = pluginListFingerprint(configSnapshot.plugins);
2292
+ let disposed = false;
1649
2293
  const getPluginCtx = () => buildPluginContext({
1650
2294
  courseId,
1651
2295
  sessionId,
@@ -1656,12 +2300,6 @@ function createLessonkitRuntime(config, ports = {}) {
1656
2300
  pluginHost?.setupAll(getPluginCtx());
1657
2301
  }
1658
2302
  const getSession = () => ({ sessionId, attemptId, user });
1659
- const syncSessionFromConfig = (next) => {
1660
- sessionId = resolveSessionId(storage, next.session?.sessionId);
1661
- attemptId = next.session?.attemptId;
1662
- user = next.session?.user;
1663
- courseId = next.courseId;
1664
- };
1665
2303
  const applyPluginsToEvent = (event) => {
1666
2304
  if (!pluginHost) return event;
1667
2305
  return pluginHost.runTelemetry(event, getPluginCtx());
@@ -1679,28 +2317,23 @@ function createLessonkitRuntime(config, ports = {}) {
1679
2317
  if (!event) return null;
1680
2318
  return applyPluginsToEvent(event);
1681
2319
  };
1682
- const wrapEmitFn = (emitFn) => {
1683
- return (name, data, lessonId) => {
1684
- const event = buildAndApply(name, data, lessonId);
1685
- if (event === null) return;
1686
- const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1687
- const eventData = "data" in event ? event.data : data;
1688
- emitFn(event.name, eventData, eventLessonId);
1689
- };
2320
+ const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
2321
+ const event = buildAndApply(name, data, lessonId);
2322
+ if (event) emitFn(event);
1690
2323
  };
1691
- syncSessionFromConfig(configSnapshot);
1692
2324
  const track = (name, data, emit, lessonId) => {
2325
+ if (disposed) return;
1693
2326
  const event = buildAndApply(name, data, lessonId);
1694
2327
  if (!event) return;
1695
2328
  emit(event);
1696
2329
  };
1697
2330
  const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1698
- const wrapped = wrapEmitFn(emitFn);
1699
- wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
2331
+ emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
1700
2332
  if (durationMs !== void 0) {
1701
- wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
2333
+ emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
1702
2334
  }
1703
2335
  };
2336
+ const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
1704
2337
  return {
1705
2338
  get config() {
1706
2339
  return configSnapshot;
@@ -1713,7 +2346,12 @@ function createLessonkitRuntime(config, ports = {}) {
1713
2346
  },
1714
2347
  getProgressState: () => progress.getState(),
1715
2348
  getSession,
2349
+ migrateSessionMarks(fromSessionId, toSessionId) {
2350
+ if (disposed) return;
2351
+ migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
2352
+ },
1716
2353
  updateConfig(next) {
2354
+ if (disposed) return;
1717
2355
  const previousCourseId = courseId;
1718
2356
  const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1719
2357
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
@@ -1721,35 +2359,69 @@ function createLessonkitRuntime(config, ports = {}) {
1721
2359
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1722
2360
  configSnapshot.runtimeVersion = next.runtimeVersion;
1723
2361
  }
2362
+ if (next.autoCompleteOnLessonSwitch !== void 0) {
2363
+ configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
2364
+ }
2365
+ if (next.courseId !== void 0) {
2366
+ courseId = next.courseId;
2367
+ }
1724
2368
  if (next.session !== void 0) {
2369
+ const previousSessionId = sessionId;
1725
2370
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
2371
+ const explicitSessionId = configSnapshot.session?.sessionId?.trim();
2372
+ if (explicitSessionId) {
2373
+ sessionId = resolveSessionId(storage, explicitSessionId);
2374
+ autoSessionId = void 0;
2375
+ } else {
2376
+ sessionId = autoSessionId ?? resolveSessionId(storage, void 0);
2377
+ if (!autoSessionId) autoSessionId = sessionId;
2378
+ }
2379
+ attemptId = configSnapshot.session?.attemptId;
2380
+ user = configSnapshot.session?.user;
2381
+ if (previousSessionId !== sessionId) {
2382
+ const prevExplicit = configuredSessionId?.trim();
2383
+ const nextExplicit = configSnapshot.session?.sessionId?.trim();
2384
+ const isExplicitLearnerSwap = Boolean(prevExplicit) && Boolean(nextExplicit) && prevExplicit !== nextExplicit;
2385
+ if (!isExplicitLearnerSwap) {
2386
+ migrateCourseStartedMark(storage, previousSessionId, sessionId, courseId);
2387
+ }
2388
+ }
2389
+ configuredSessionId = configSnapshot.session?.sessionId;
1726
2390
  }
1727
- syncSessionFromConfig(configSnapshot);
1728
2391
  const sessionKeyAfter = JSON.stringify({ sessionId, attemptId, user });
1729
2392
  if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
1730
2393
  progress = createProgressController();
1731
- }
1732
- if (next.plugins !== void 0 && next.plugins !== configSnapshot.plugins) {
1733
- pluginHost?.disposeAll();
1734
- configSnapshot.plugins = next.plugins;
1735
- pluginHost = resolvePluginHost(configSnapshot.plugins);
1736
2394
  if (!configSnapshot.deferPluginSetup) {
2395
+ pluginHost?.disposeAll();
1737
2396
  pluginHost?.setupAll(getPluginCtx());
1738
2397
  }
2398
+ }
2399
+ if (next.plugins !== void 0) {
2400
+ const nextFingerprint = pluginListFingerprint(next.plugins);
2401
+ const pluginsChanged = next.plugins !== configSnapshot.plugins || nextFingerprint !== null && nextFingerprint !== pluginFingerprint;
2402
+ if (pluginsChanged) {
2403
+ pluginHost?.disposeAll();
2404
+ configSnapshot.plugins = next.plugins;
2405
+ pluginFingerprint = nextFingerprint;
2406
+ pluginHost = resolvePluginHost(configSnapshot.plugins);
2407
+ if (!configSnapshot.deferPluginSetup) {
2408
+ pluginHost?.setupAll(getPluginCtx());
2409
+ }
2410
+ }
1739
2411
  } else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
1740
2412
  pluginHost.disposeAll();
1741
2413
  pluginHost.setupAll(getPluginCtx());
1742
2414
  }
1743
2415
  },
1744
2416
  setActiveLesson(lessonId, emitFn) {
1745
- const wrapped = wrapEmitFn(emitFn);
2417
+ if (disposed) return;
1746
2418
  const current = progress.getState();
1747
2419
  if (current.activeLessonId === lessonId) return;
1748
2420
  const previous = current.activeLessonId;
1749
- if (previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
2421
+ if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
1750
2422
  const completed = progress.completeLesson(previous, clock.nowMs());
1751
2423
  if (completed.didComplete) {
1752
- emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
2424
+ emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
1753
2425
  }
1754
2426
  }
1755
2427
  if (current.completedLessonIds.has(lessonId)) {
@@ -1757,39 +2429,50 @@ function createLessonkitRuntime(config, ports = {}) {
1757
2429
  return;
1758
2430
  }
1759
2431
  progress.setActiveLesson(lessonId, clock.nowMs());
1760
- wrapped("lesson_started", { lessonId }, lessonId);
2432
+ emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
1761
2433
  },
1762
2434
  completeLesson(lessonId, emitFn) {
2435
+ if (disposed) return;
1763
2436
  completeLessonWithTelemetry({
1764
2437
  progress,
1765
2438
  lessonId,
1766
2439
  nowMs: clock.nowMs(),
1767
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
2440
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
1768
2441
  });
1769
2442
  },
1770
2443
  completeCourse(emitFn) {
2444
+ if (disposed) return;
1771
2445
  completeCourseWithTelemetry({
1772
2446
  progress,
1773
2447
  nowMs: clock.nowMs(),
1774
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1775
- emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
2448
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
2449
+ emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
1776
2450
  });
1777
2451
  },
1778
2452
  track,
1779
2453
  scoreAssessment(input, lessonId) {
1780
- if (!pluginHost) return null;
2454
+ if (disposed || !pluginHost) return null;
1781
2455
  return pluginHost.scoreAssessment(
1782
2456
  { ...input, lessonId: input.lessonId ?? lessonId },
1783
2457
  getPluginCtx()
1784
2458
  );
1785
2459
  },
1786
2460
  resetForCourseChange(nextCourseId) {
2461
+ if (disposed) return;
1787
2462
  configSnapshot.courseId = nextCourseId;
1788
2463
  courseId = nextCourseId;
1789
2464
  progress = createProgressController();
2465
+ if (!configSnapshot.deferPluginSetup) {
2466
+ pluginHost?.disposeAll();
2467
+ pluginHost?.setupAll(getPluginCtx());
2468
+ }
1790
2469
  },
1791
2470
  dispose() {
1792
- pluginHost?.disposeAll();
2471
+ if (disposed) return;
2472
+ disposed = true;
2473
+ if (!configSnapshot.deferPluginSetup) {
2474
+ pluginHost?.disposeAll();
2475
+ }
1793
2476
  }
1794
2477
  };
1795
2478
  }
@@ -1809,12 +2492,16 @@ function defineLifecyclePlugin(plugin) {
1809
2492
  ACCORDION_FORBIDDEN_CHILD_TYPES,
1810
2493
  ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
1811
2494
  BLOCKS_14_PAGE_SLIDE,
2495
+ BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
2496
+ BRANCH_NODE_ALLOWED_CHILD_TYPES,
1812
2497
  COMPOUND_MAX_NESTING_DEPTH,
1813
2498
  COMPOUND_RESUME_SCHEMA_VERSION,
2499
+ GAME_MAP_ALLOWED_CHILD_TYPES,
1814
2500
  ID_MAX_LENGTH,
1815
2501
  ID_PATTERN,
1816
2502
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1817
2503
  INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
2504
+ MAP_STAGE_ALLOWED_CHILD_TYPES,
1818
2505
  PAGE_ALLOWED_CHILD_TYPES,
1819
2506
  SESSION_STORAGE_KEY,
1820
2507
  SLIDE_ALLOWED_CHILD_TYPES,
@@ -1858,11 +2545,14 @@ function defineLifecyclePlugin(plugin) {
1858
2545
  hasCourseStarted,
1859
2546
  hasCourseStartedEmittedToTracking,
1860
2547
  hasCourseStartedPipelineDelivered,
2548
+ hasCourseStartedXapiSent,
1861
2549
  isChildTypeAllowed,
2550
+ isLifecycleTelemetryEvent,
1862
2551
  loadCompoundState,
1863
2552
  markCourseStarted,
1864
2553
  markCourseStartedEmittedToTracking,
1865
2554
  markCourseStartedPipelineDelivered,
2555
+ markCourseStartedXapiSent,
1866
2556
  migrateCourseStartedMark,
1867
2557
  nowIso,
1868
2558
  parseBlockId,
@@ -1881,5 +2571,6 @@ function defineLifecyclePlugin(plugin) {
1881
2571
  telemetryCatalogVersion,
1882
2572
  tryBuildTelemetryEvent,
1883
2573
  tryEmitCourseStarted,
2574
+ validateBranchGraph,
1884
2575
  validateId
1885
2576
  });