@playcademy/sandbox 0.7.1-beta.11 → 0.7.1-beta.13

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.
Files changed (3) hide show
  1. package/dist/cli.js +296 -62
  2. package/dist/server.js +296 -62
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1123,7 +1123,7 @@ var package_default;
1123
1123
  var init_package = __esm(() => {
1124
1124
  package_default = {
1125
1125
  name: "@playcademy/sandbox",
1126
- version: "0.7.1-beta.11",
1126
+ version: "0.7.1-beta.13",
1127
1127
  description: "Local development server for Playcademy game development",
1128
1128
  type: "module",
1129
1129
  exports: {
@@ -9251,6 +9251,23 @@ var init_esm = __esm(() => {
9251
9251
  });
9252
9252
 
9253
9253
  // ../timeback/dist/engines/assessment-runtime/index.js
9254
+ function qtiDirectedPairValidationIssue(values, contract) {
9255
+ const sourceCounts = new Map;
9256
+ const targetCounts = new Map;
9257
+ for (const value of values) {
9258
+ const [source, target, extra] = value.split(" ");
9259
+ if (extra !== undefined || !source || !target || !contract.sources.has(source) || !contract.targets.has(target)) {
9260
+ return { kind: "invalid-pair", value };
9261
+ }
9262
+ if (!withinQtiCapacity(contract.sources, sourceCounts, source)) {
9263
+ return { kind: "source-capacity", identifier: source };
9264
+ }
9265
+ if (!withinQtiCapacity(contract.targets, targetCounts, target)) {
9266
+ return { kind: "target-capacity", identifier: target };
9267
+ }
9268
+ }
9269
+ return null;
9270
+ }
9254
9271
  function isRecord2(value) {
9255
9272
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
9256
9273
  }
@@ -9553,19 +9570,15 @@ function assessmentResponseValidationMessage(assessment, responses) {
9553
9570
  return null;
9554
9571
  }
9555
9572
  function directedPairsValidationMessage(itemIdentifier, values, contract) {
9556
- const sourceCounts = new Map;
9557
- const targetCounts = new Map;
9558
- for (const value of values) {
9559
- const [source, target, extra] = value.split(" ");
9560
- if (extra !== undefined || !source || !target || !contract.sources.has(source) || !contract.targets.has(target)) {
9573
+ const issue = qtiDirectedPairValidationIssue(values, contract);
9574
+ if (issue) {
9575
+ if (issue.kind === "invalid-pair") {
9561
9576
  return `Response for ${itemIdentifier} contains an invalid association pair.`;
9562
9577
  }
9563
- if (!withinQtiCapacity(contract.sources, sourceCounts, source)) {
9578
+ if (issue.kind === "source-capacity") {
9564
9579
  return `Response for ${itemIdentifier} uses a token beyond its allowed placements.`;
9565
9580
  }
9566
- if (!withinQtiCapacity(contract.targets, targetCounts, target)) {
9567
- return `Response for ${itemIdentifier} fills a target beyond its capacity.`;
9568
- }
9581
+ return `Response for ${itemIdentifier} fills a target beyond its capacity.`;
9569
9582
  }
9570
9583
  return null;
9571
9584
  }
@@ -9645,8 +9658,14 @@ function interactionResponseValidationMessage(itemIdentifier, interaction, respo
9645
9658
  allowed = new Set(interaction.choices.map((candidate) => candidate.identifier));
9646
9659
  } else if (interaction.type === "match") {
9647
9660
  const message = directedPairsValidationMessage(itemIdentifier, values, {
9648
- sources: new Map(interaction.sourceChoices.map((candidate) => [candidate.identifier, 0])),
9649
- targets: new Map(interaction.targetChoices.map((candidate) => [candidate.identifier, 0]))
9661
+ sources: new Map(interaction.sourceChoices.map((candidate) => [
9662
+ candidate.identifier,
9663
+ candidate.matchMax
9664
+ ])),
9665
+ targets: new Map(interaction.targetChoices.map((candidate) => [
9666
+ candidate.identifier,
9667
+ candidate.matchMax
9668
+ ]))
9650
9669
  });
9651
9670
  if (message) {
9652
9671
  return message;
@@ -10372,6 +10391,7 @@ var init_assessment_runtime = __esm(() => {
10372
10391
  init_fxp();
10373
10392
  init_timeback3();
10374
10393
  init_timeback3();
10394
+ init_timeback3();
10375
10395
  init_src();
10376
10396
  init_timeback3();
10377
10397
  init_src();
@@ -86427,18 +86447,18 @@ function serializeSanitizedMathMl(node) {
86427
86447
  const children = node.children.map(serializeSanitizedMathMl).join("");
86428
86448
  return `<${node.localName}${attributes2}>${children}</${node.localName}>`;
86429
86449
  }
86430
- function tableContentNode(element, blankForInteraction) {
86450
+ function tableContentNode(element, blankForInteraction, gapForElement) {
86431
86451
  const rows = xmlDescendants(element, (candidate) => candidate.localName === "tr").map((row) => {
86432
86452
  const cells = row.children.filter((cell) => cell.kind === "element" && (cell.localName === "td" || cell.localName === "th"));
86433
86453
  return {
86434
86454
  header: cells.length > 0 && cells.every((cell) => cell.localName === "th"),
86435
- cells: cells.map((cell) => contentNodesFrom(cell.children, blankForInteraction))
86455
+ cells: cells.map((cell) => contentNodesFrom(cell.children, blankForInteraction, gapForElement))
86436
86456
  };
86437
86457
  });
86438
86458
  return { kind: "table", rows };
86439
86459
  }
86440
- function listContentNode(element, blankForInteraction) {
86441
- const items = element.children.filter((child) => child.kind === "element" && child.localName === "li").map((item) => contentNodesFrom(item.children, blankForInteraction));
86460
+ function listContentNode(element, blankForInteraction, gapForElement) {
86461
+ const items = element.children.filter((child) => child.kind === "element" && child.localName === "li").map((item) => contentNodesFrom(item.children, blankForInteraction, gapForElement));
86442
86462
  return { kind: "list", ordered: element.localName === "ol", items };
86443
86463
  }
86444
86464
  function contentText(raw) {
@@ -86463,15 +86483,18 @@ function trimContentBoundaries(nodes) {
86463
86483
  }
86464
86484
  return kept.filter((node) => node.kind !== "text" || node.text !== "");
86465
86485
  }
86466
- function collectContentNodes(nodes, blankForInteraction) {
86486
+ function collectContentNodes(nodes, blankForInteraction, gapForElement) {
86467
86487
  const result = [];
86468
86488
  for (const node of nodes) {
86489
+ const gapIdentifier = node.kind === "element" ? gapForElement(node) : null;
86469
86490
  if (node.kind === "text") {
86470
86491
  const text3 = contentText(node.value);
86471
86492
  if (text3) {
86472
86493
  result.push({ kind: "text", text: text3 });
86473
86494
  }
86474
- } else if (CONTENT_SKIPPED_TAGS2.has(node.localName)) {} else if (isInteractionElement(node)) {
86495
+ } else if (CONTENT_SKIPPED_TAGS2.has(node.localName)) {} else if (gapIdentifier !== null) {
86496
+ result.push({ kind: "gap", identifier: gapIdentifier });
86497
+ } else if (isInteractionElement(node)) {
86475
86498
  const responseIdentifier = blankForInteraction(node);
86476
86499
  if (responseIdentifier) {
86477
86500
  result.push({ kind: "blank", responseIdentifier });
@@ -86496,19 +86519,19 @@ function collectContentNodes(nodes, blankForInteraction) {
86496
86519
  });
86497
86520
  }
86498
86521
  } else if (node.localName === "table") {
86499
- result.push(tableContentNode(node, blankForInteraction));
86522
+ result.push(tableContentNode(node, blankForInteraction, gapForElement));
86500
86523
  } else if (node.localName === "ul" || node.localName === "ol") {
86501
- const list = listContentNode(node, blankForInteraction);
86524
+ const list = listContentNode(node, blankForInteraction, gapForElement);
86502
86525
  if (list.kind === "list" && list.items.length > 0) {
86503
86526
  result.push(list);
86504
86527
  }
86505
86528
  } else if (node.localName === "p") {
86506
- const children = contentNodesFrom(node.children, blankForInteraction);
86529
+ const children = contentNodesFrom(node.children, blankForInteraction, gapForElement);
86507
86530
  if (children.length > 0) {
86508
86531
  result.push({ kind: "paragraph", children });
86509
86532
  }
86510
86533
  } else if (EMPHASIS_TAGS2.has(node.localName) || STRONG_TAGS2.has(node.localName)) {
86511
- const children = collectContentNodes(node.children, blankForInteraction);
86534
+ const children = collectContentNodes(node.children, blankForInteraction, gapForElement);
86512
86535
  const hasContent = children.some((child) => child.kind !== "text" || Boolean(child.text.trim()));
86513
86536
  if (hasContent) {
86514
86537
  result.push({
@@ -86517,17 +86540,17 @@ function collectContentNodes(nodes, blankForInteraction) {
86517
86540
  });
86518
86541
  }
86519
86542
  } else {
86520
- result.push(...collectContentNodes(node.children, blankForInteraction));
86543
+ result.push(...collectContentNodes(node.children, blankForInteraction, gapForElement));
86521
86544
  }
86522
86545
  }
86523
86546
  return result;
86524
86547
  }
86525
- function contentNodesFrom(nodes, blankForInteraction) {
86526
- return trimContentBoundaries(collectContentNodes(nodes, blankForInteraction));
86548
+ function contentNodesFrom(nodes, blankForInteraction, gapForElement = () => null) {
86549
+ return trimContentBoundaries(collectContentNodes(nodes, blankForInteraction, gapForElement));
86527
86550
  }
86528
86551
  function hasRichContent(nodes) {
86529
86552
  return nodes.some((node) => {
86530
- if (node.kind === "math" || node.kind === "image" || node.kind === "table" || node.kind === "list" || node.kind === "blank") {
86553
+ if (node.kind === "math" || node.kind === "image" || node.kind === "table" || node.kind === "list" || node.kind === "blank" || node.kind === "gap") {
86531
86554
  return true;
86532
86555
  }
86533
86556
  if (node.kind === "paragraph" || node.kind === "emphasis" || node.kind === "strong") {
@@ -86548,7 +86571,14 @@ function parseGapMatchText(interaction) {
86548
86571
  }
86549
86572
  return GAP_MATCH_TOKEN_TAGS2.has(element.localName) ? "" : undefined;
86550
86573
  });
86551
- return { textWithGaps, gaps };
86574
+ const passageNodes = interaction.children.filter((node) => node.kind === "text" || !GAP_MATCH_TOKEN_TAGS2.has(node.localName));
86575
+ const gapContent = contentNodesFrom(passageNodes, () => null, (element) => {
86576
+ if (element.localName !== "qti-gap") {
86577
+ return null;
86578
+ }
86579
+ return parseXmlAttributes(element.attributes).identifier ?? null;
86580
+ });
86581
+ return { textWithGaps, gaps, gapContent };
86552
86582
  }
86553
86583
  function isInlineInteractionElement(element) {
86554
86584
  const type = INTERACTION_TYPES2[element.localName];
@@ -86575,6 +86605,7 @@ function parseInteraction(source, interaction, context2) {
86575
86605
  let gapImages = [];
86576
86606
  let gaps = [];
86577
86607
  let textWithGaps;
86608
+ let gapContent;
86578
86609
  function ownStageGraphic() {
86579
86610
  return firstGraphic(interaction, STAGE_GRAPHIC_EXCLUDED_CONTAINERS2);
86580
86611
  }
@@ -86596,7 +86627,7 @@ function parseInteraction(source, interaction, context2) {
86596
86627
  graphic = ownStageGraphic();
86597
86628
  } else if (type === "gap-match") {
86598
86629
  gapTexts = parseGapTexts(interaction);
86599
- ({ textWithGaps, gaps } = parseGapMatchText(interaction));
86630
+ ({ textWithGaps, gaps, gapContent } = parseGapMatchText(interaction));
86600
86631
  } else if (type === "graphic-gap-match") {
86601
86632
  graphic = ownStageGraphic();
86602
86633
  gapImages = parseGapImages(interaction);
@@ -86624,6 +86655,7 @@ function parseInteraction(source, interaction, context2) {
86624
86655
  gapImages,
86625
86656
  gaps,
86626
86657
  ...textWithGaps === undefined ? {} : { textWithGaps },
86658
+ ...gapContent === undefined ? {} : { gapContent },
86627
86659
  rawXml: xmlElementSource(source, interaction)
86628
86660
  };
86629
86661
  }
@@ -86714,6 +86746,23 @@ function parseQtiItemXml(rawXml) {
86714
86746
  rawXml
86715
86747
  };
86716
86748
  }
86749
+ function qtiDirectedPairValidationIssue2(values, contract) {
86750
+ const sourceCounts = new Map;
86751
+ const targetCounts = new Map;
86752
+ for (const value of values) {
86753
+ const [source, target, extra] = value.split(" ");
86754
+ if (extra !== undefined || !source || !target || !contract.sources.has(source) || !contract.targets.has(target)) {
86755
+ return { kind: "invalid-pair", value };
86756
+ }
86757
+ if (!withinQtiCapacity(contract.sources, sourceCounts, source)) {
86758
+ return { kind: "source-capacity", identifier: source };
86759
+ }
86760
+ if (!withinQtiCapacity(contract.targets, targetCounts, target)) {
86761
+ return { kind: "target-capacity", identifier: target };
86762
+ }
86763
+ }
86764
+ return null;
86765
+ }
86717
86766
  function usableQtiRegionGeometry(shape, coords) {
86718
86767
  const arity2 = REGION_SHAPE_COORDS[shape];
86719
86768
  if (!arity2) {
@@ -87178,6 +87227,17 @@ function interactionBody(item, node, type, declaration, attributes2) {
87178
87227
  if (!attributes2.has("max-associations")) {
87179
87228
  attributes2.set("max-associations", String(correctValueStrings(declaration).length || sources.length));
87180
87229
  }
87230
+ const capacityIssue = qtiDirectedPairValidationIssue2(correctValueStrings(declaration), {
87231
+ sources: new Map(sources.map((choice) => [choice.identifier, 1])),
87232
+ targets: new Map(targets.map((choice) => [choice.identifier, 1]))
87233
+ });
87234
+ if (capacityIssue) {
87235
+ if (capacityIssue.kind === "invalid-pair") {
87236
+ compileError(`Match pair “${capacityIssue.value}” must name one declared source and target choice`);
87237
+ }
87238
+ const role = capacityIssue.kind === "source-capacity" ? "Source" : "Target";
87239
+ compileError(`${role} “${capacityIssue.identifier}” takes part in more associations than its match-max allows`);
87240
+ }
87181
87241
  const sets = [sources, targets].map((choices) => `<qti-simple-match-set>${choices.map((choice) => choiceXml(item, "qti-simple-associable-choice", choice, {
87182
87242
  "match-max": "1"
87183
87243
  })).join("")}</qti-simple-match-set>`).join("");
@@ -87463,6 +87523,14 @@ function qtiAttributeNumber(attributes2, name3, fallback) {
87463
87523
  const value = Number(raw);
87464
87524
  return Number.isFinite(value) ? value : fallback;
87465
87525
  }
87526
+ function qtiAttributeCapacity(attributes2, name3, fallback) {
87527
+ const raw = attributes2[name3]?.trim();
87528
+ if (!raw) {
87529
+ return fallback;
87530
+ }
87531
+ const value = Number(raw);
87532
+ return Number.isInteger(value) && value >= 0 ? value : null;
87533
+ }
87466
87534
  function qtiAttributeMaximum(attributes2, name3, fallback, unlimited) {
87467
87535
  const maximum = qtiAttributeNumber(attributes2, name3, fallback);
87468
87536
  return maximum === 0 ? unlimited : maximum;
@@ -87471,6 +87539,27 @@ function qtiAttributeBoolean(attributes2, name3) {
87471
87539
  const value = attributes2[name3];
87472
87540
  return value === "true" || value === "1";
87473
87541
  }
87542
+ function gapContentIdentifiers(nodes) {
87543
+ return nodes.flatMap((node) => {
87544
+ if (node.kind === "gap") {
87545
+ return [node.identifier];
87546
+ }
87547
+ if (node.kind === "paragraph" || node.kind === "emphasis" || node.kind === "strong") {
87548
+ return gapContentIdentifiers(node.children);
87549
+ }
87550
+ if (node.kind === "list") {
87551
+ return node.items.flatMap(gapContentIdentifiers);
87552
+ }
87553
+ if (node.kind === "table") {
87554
+ return node.rows.flatMap((row) => row.cells.flatMap(gapContentIdentifiers));
87555
+ }
87556
+ return [];
87557
+ });
87558
+ }
87559
+ function qtiGapContentMatchesGaps(nodes, gaps) {
87560
+ const identifiers = gapContentIdentifiers(nodes);
87561
+ return identifiers.length === gaps.length && identifiers.every((identifier, index2) => identifier === gaps[index2]);
87562
+ }
87474
87563
  function normalizedInteractionType(value) {
87475
87564
  if (typeof value !== "string") {
87476
87565
  return;
@@ -87481,6 +87570,9 @@ function normalizedInteractionType(value) {
87481
87570
  function usableChoices(choices) {
87482
87571
  return choices !== undefined && choices.length > 0 && choices.every((choice) => Boolean(choice.identifier));
87483
87572
  }
87573
+ function usableAssociableChoices(choices) {
87574
+ return usableChoices(choices) && choices.every((choice) => qtiAttributeCapacity(choice.attributes, "match-max", 0) !== null);
87575
+ }
87484
87576
  function transportableIdentifiers(identifiers) {
87485
87577
  return identifiers.every((identifier) => Boolean(identifier) && !/\s/.test(identifier));
87486
87578
  }
@@ -87553,7 +87645,7 @@ function authorableQtiInteractionProjection(interaction) {
87553
87645
  }
87554
87646
  case "match": {
87555
87647
  const [sourceChoices, targetChoices] = interaction.choiceSets;
87556
- if (interaction.choiceSets.length !== 2 || !usableChoices(sourceChoices) || !usableChoices(targetChoices) || !transportableIdentifiers([...sourceChoices, ...targetChoices].map((choice) => choice.identifier))) {
87648
+ if (interaction.choiceSets.length !== 2 || !usableAssociableChoices(sourceChoices) || !usableAssociableChoices(targetChoices) || !transportableIdentifiers([...sourceChoices, ...targetChoices].map((choice) => choice.identifier))) {
87557
87649
  return null;
87558
87650
  }
87559
87651
  return {
@@ -87622,8 +87714,8 @@ function authorableQtiInteractionProjection(interaction) {
87622
87714
  };
87623
87715
  }
87624
87716
  case "gap-match": {
87625
- const { gapTexts, gaps, textWithGaps } = interaction;
87626
- if (gapTexts.length === 0 || gaps.length === 0 || textWithGaps === undefined || !gapTexts.every((gapText) => Boolean(gapText.identifier && gapText.content)) || !transportableIdentifiers([...gapTexts.map((gapText) => gapText.identifier), ...gaps])) {
87717
+ const { gapTexts, gaps, textWithGaps, gapContent } = interaction;
87718
+ if (gapTexts.length === 0 || gaps.length === 0 || textWithGaps === undefined || !gapContent || !qtiGapContentMatchesGaps(gapContent, gaps) || !gapTexts.every((gapText) => Boolean(gapText.identifier && gapText.content)) || !transportableIdentifiers([...gapTexts.map((gapText) => gapText.identifier), ...gaps])) {
87627
87719
  return null;
87628
87720
  }
87629
87721
  return {
@@ -87632,6 +87724,7 @@ function authorableQtiInteractionProjection(interaction) {
87632
87724
  gapTexts,
87633
87725
  gaps,
87634
87726
  textWithGaps,
87727
+ gapContent,
87635
87728
  shuffle: qtiAttributeBoolean(attributes2, "shuffle")
87636
87729
  };
87637
87730
  }
@@ -87655,7 +87748,7 @@ function contributesScore(declaration) {
87655
87748
  function responsePairs(left, right) {
87656
87749
  return left.flatMap((first) => right.map((second) => `${first} ${second}`));
87657
87750
  }
87658
- function mappingResponseContext(item, declaration) {
87751
+ function interactionResponseSpace(item, declaration) {
87659
87752
  const interaction = item.interactions.find((candidate) => candidate.responseIdentifier === declaration.identifier);
87660
87753
  if (!interaction) {
87661
87754
  return {};
@@ -87747,21 +87840,49 @@ function processesResponseMapping(item, declaration) {
87747
87840
  }
87748
87841
  return item.responseProcessing.elements.some((element) => element.tagName === "qti-map-response" && (element.attributes.identifier ?? "RESPONSE") === declaration.identifier);
87749
87842
  }
87750
- function responseMappingMaximum(item, declaration) {
87843
+ function responseMappingMaximum(item, declaration, space) {
87751
87844
  if (!declaration.mapping || !processesResponseMapping(item, declaration)) {
87752
87845
  return null;
87753
87846
  }
87754
87847
  return qtiResponseMappingMaximum({
87755
87848
  cardinality: declaration.cardinality,
87756
87849
  mapping: declaration.mapping,
87757
- ...mappingResponseContext(item, declaration)
87850
+ ...space ?? interactionResponseSpace(item, declaration)
87758
87851
  });
87759
87852
  }
87760
87853
  function qtiItemScoringValidationMessage(item) {
87761
87854
  for (const declaration of item.responseDeclarations) {
87855
+ const space = interactionResponseSpace(item, declaration);
87856
+ const { responseValues: responseValues2, maximumResponses } = space;
87857
+ if (responseValues2 && declaration.correctValues.some((value) => !responseValues2.includes(value))) {
87858
+ return `Response ${declaration.identifier} declares a correct value its interaction cannot produce.`;
87859
+ }
87860
+ const interaction = item.interactions.find((candidate) => candidate.responseIdentifier === declaration.identifier);
87861
+ if (interaction?.type === "match") {
87862
+ const [sources = [], targets = []] = interaction.choiceSets;
87863
+ const issue6 = qtiDirectedPairValidationIssue2(declaration.correctValues, {
87864
+ sources: new Map(sources.map((choice) => [
87865
+ choice.identifier,
87866
+ qtiAttributeNumber(choice.attributes, "match-max", 0)
87867
+ ])),
87868
+ targets: new Map(targets.map((choice) => [
87869
+ choice.identifier,
87870
+ qtiAttributeNumber(choice.attributes, "match-max", 0)
87871
+ ]))
87872
+ });
87873
+ if (issue6?.kind === "source-capacity") {
87874
+ return `Response ${declaration.identifier} declares correct pairs that exceed source ${issue6.identifier}'s match-max.`;
87875
+ }
87876
+ if (issue6?.kind === "target-capacity") {
87877
+ return `Response ${declaration.identifier} declares correct pairs that exceed target ${issue6.identifier}'s match-max.`;
87878
+ }
87879
+ }
87880
+ if (declaration.cardinality !== "single" && maximumResponses !== undefined && maximumResponses !== 0 && declaration.correctValues.length > maximumResponses) {
87881
+ return `Response ${declaration.identifier} declares more correct values than its interaction accepts.`;
87882
+ }
87762
87883
  if (declaration.mapping) {
87763
87884
  if (processesResponseMapping(item, declaration)) {
87764
- if (responseMappingMaximum(item, declaration) === null) {
87885
+ if (responseMappingMaximum(item, declaration, space) === null) {
87765
87886
  return `Response ${declaration.identifier} has a mapping with no reachable positive score.`;
87766
87887
  }
87767
87888
  } else if (declaration.correctValues.length === 0) {
@@ -88084,6 +88205,7 @@ var init_qti = __esm(() => {
88084
88205
  init_fxp();
88085
88206
  init_timeback3();
88086
88207
  init_timeback3();
88208
+ init_timeback3();
88087
88209
  EVENT_HANDLER_ATTRIBUTE = /^on/i;
88088
88210
  SCRIPT_SCHEMES2 = {
88089
88211
  javascript: ["java", "script:"].join(""),
@@ -90920,6 +91042,16 @@ function playableChoice(choice) {
90920
91042
  ...choice.contentNodes ? { contentNodes: choice.contentNodes } : {}
90921
91043
  };
90922
91044
  }
91045
+ function playableAssociableChoice(choice) {
91046
+ const matchMax = qtiAttributeCapacity(choice.attributes, "match-max", 0);
91047
+ if (matchMax === null) {
91048
+ throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", "The selected assessment contains an invalid Match capacity.");
91049
+ }
91050
+ return {
91051
+ ...playableChoice(choice),
91052
+ matchMax
91053
+ };
91054
+ }
90923
91055
  function requireChoices2(choices) {
90924
91056
  if (choices.length === 0 || choices.some((candidate) => !candidate.identifier)) {
90925
91057
  throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", "The selected assessment contains an interaction Playcademy cannot render.");
@@ -91005,8 +91137,8 @@ function parseInteraction2(questionIdentifier, interaction) {
91005
91137
  if (sets.length !== 2) {
91006
91138
  throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", "Match interactions require exactly two choice sets.");
91007
91139
  }
91008
- const sourceChoices = requireChoices2(sets[0].map(playableChoice));
91009
- const targetChoices = requireChoices2(sets[1].map(playableChoice));
91140
+ const sourceChoices = requireChoices2(sets[0].map(playableAssociableChoice));
91141
+ const targetChoices = requireChoices2(sets[1].map(playableAssociableChoice));
91010
91142
  return {
91011
91143
  type,
91012
91144
  responseIdentifier,
@@ -91031,7 +91163,7 @@ function parseInteraction2(questionIdentifier, interaction) {
91031
91163
  };
91032
91164
  }
91033
91165
  if (type === "gap-match") {
91034
- if (interaction.gapTexts.length === 0 || interaction.gaps.length === 0 || interaction.textWithGaps === undefined) {
91166
+ if (interaction.gapTexts.length === 0 || interaction.gaps.length === 0 || interaction.textWithGaps === undefined || !interaction.gapContent || !qtiGapContentMatchesGaps(interaction.gapContent, interaction.gaps)) {
91035
91167
  throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", `Assessment item ${questionIdentifier} has an incomplete gap-match interaction.`);
91036
91168
  }
91037
91169
  return {
@@ -91039,6 +91171,7 @@ function parseInteraction2(questionIdentifier, interaction) {
91039
91171
  responseIdentifier,
91040
91172
  ...prompt,
91041
91173
  content: interaction.textWithGaps,
91174
+ contentNodes: interaction.gapContent,
91042
91175
  gapTexts: interaction.gapTexts.map((gapText) => ({
91043
91176
  identifier: gapText.identifier,
91044
91177
  content: gapText.content,
@@ -93148,8 +93281,7 @@ function assertPlayableQtiQuestion(question) {
93148
93281
  }
93149
93282
  const item = parsedQtiItemOrNull(question.rawXml);
93150
93283
  const playable = item && playableQtiInteractions(item);
93151
- const scoringIssue = item && qtiItemScoringValidationMessage(item);
93152
- if (!playable || scoringIssue) {
93284
+ if (!playable || qtiItemScoringValidationMessage(item)) {
93153
93285
  throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
93154
93286
  }
93155
93287
  }
@@ -93402,31 +93534,38 @@ ${input.itemBody}
93402
93534
  </qti-response-processing>
93403
93535
  </qti-assessment-item>`;
93404
93536
  }
93405
- function parseInlineChoices(value) {
93406
- if (!Array.isArray(value) || value.length < 2) {
93407
- throw new ValidationError("Inline-choice questions require at least two choices");
93537
+ function parseStructuredChoices(value, options) {
93538
+ const { label, minimum, validate: validate2 } = options;
93539
+ if (!Array.isArray(value) || value.length < minimum) {
93540
+ throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
93408
93541
  }
93409
93542
  const choices = value.map((choice) => {
93410
93543
  const record3 = recordValue(choice);
93411
93544
  const identifier = record3?.identifier;
93412
93545
  const content = record3?.content;
93413
- if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim()) {
93414
- throw new ValidationError("Inline-choice options are invalid");
93546
+ if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
93547
+ throw new ValidationError(`${label} options are invalid`);
93415
93548
  }
93416
93549
  return { identifier: identifier.trim(), content: content.trim() };
93417
93550
  });
93418
93551
  if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
93419
- throw new ValidationError("Inline-choice option identifiers must be unique");
93552
+ throw new ValidationError(`${label} option identifiers must be unique`);
93420
93553
  }
93421
93554
  return choices;
93422
93555
  }
93423
- function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
93556
+ function declaredCorrectResponse(input, responseIdentifier) {
93424
93557
  const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
93425
93558
  const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
93426
- const correctResponse = recordValue(declaration?.correctResponse);
93427
- const rawValues = correctResponse?.value;
93428
- const values = Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : [];
93429
- if (declaration?.cardinality !== "single" || declaration.baseType !== "identifier") {
93559
+ const rawValues = recordValue(declaration?.correctResponse)?.value;
93560
+ return {
93561
+ cardinality: declaration?.cardinality,
93562
+ baseType: declaration?.baseType,
93563
+ values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
93564
+ };
93565
+ }
93566
+ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
93567
+ const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
93568
+ if (cardinality !== "single" || baseType !== "identifier") {
93430
93569
  throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
93431
93570
  }
93432
93571
  if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
@@ -93434,27 +93573,41 @@ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifi
93434
93573
  }
93435
93574
  return values[0];
93436
93575
  }
93437
- function buildInlineChoiceQuestionXml(input, identifier, title) {
93576
+ function structuredQuestionEnvelope(input, type, label, title) {
93438
93577
  const interaction = recordValue(input.interaction);
93439
- const interactionType = normalizedQtiInteractionType(interaction?.type ?? input.type);
93440
- if (interactionType !== "inline-choice") {
93578
+ if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
93441
93579
  return null;
93442
93580
  }
93443
- const responseIdentifier = interaction?.responseIdentifier;
93444
93581
  const structure = recordValue(interaction?.questionStructure);
93582
+ const responseIdentifier = interaction?.responseIdentifier;
93445
93583
  const prompt = structure?.prompt;
93446
- if (!structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
93447
- throw new ValidationError("Inline-choice question data is invalid");
93584
+ if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
93585
+ throw new ValidationError(`${label} question data is invalid`);
93448
93586
  }
93449
93587
  if (!title || typeof prompt !== "string" || !prompt.trim()) {
93450
- throw new ValidationError("Inline-choice questions require a title and prompt");
93588
+ throw new ValidationError(`${label} questions require a title and prompt`);
93589
+ }
93590
+ return {
93591
+ interaction,
93592
+ structure,
93593
+ responseIdentifier: responseIdentifier.trim(),
93594
+ prompt: prompt.trim()
93595
+ };
93596
+ }
93597
+ function buildInlineChoiceQuestionXml(input, identifier, title) {
93598
+ const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
93599
+ if (!envelope) {
93600
+ return null;
93451
93601
  }
93452
- const normalizedPrompt = prompt.trim();
93453
- const promptParts = normalizedPrompt.split(INLINE_CHOICE_BLANK);
93602
+ const { structure, responseIdentifier, prompt } = envelope;
93603
+ const promptParts = prompt.split(INLINE_CHOICE_BLANK);
93454
93604
  if (promptParts.length !== 2) {
93455
93605
  throw new ValidationError("Inline-choice questions require exactly one blank");
93456
93606
  }
93457
- const choices = parseInlineChoices(structure.inlineChoices);
93607
+ const choices = parseStructuredChoices(structure.inlineChoices, {
93608
+ label: "Inline-choice",
93609
+ minimum: 2
93610
+ });
93458
93611
  const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
93459
93612
  const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
93460
93613
  const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
@@ -93472,6 +93625,86 @@ ${choicesXml}
93472
93625
  itemBody
93473
93626
  });
93474
93627
  }
93628
+ function parseStructuredMatchChoices(value, label) {
93629
+ return parseStructuredChoices(value, {
93630
+ label: `Match ${label}`,
93631
+ minimum: 1,
93632
+ validate: (choice) => choice.matchMax === 1
93633
+ });
93634
+ }
93635
+ function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
93636
+ const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
93637
+ if (cardinality !== "multiple" || baseType !== "directedPair") {
93638
+ throw new ValidationError("Match questions require a multiple directed-pair response declaration");
93639
+ }
93640
+ const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
93641
+ const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
93642
+ const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
93643
+ const [source, target, extra] = value.split(" ");
93644
+ return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
93645
+ });
93646
+ if (!valid) {
93647
+ throw new ValidationError("Every Match correct response must name one declared source and target choice");
93648
+ }
93649
+ return values;
93650
+ }
93651
+ function buildMatchQuestionXml(input, identifier, title) {
93652
+ const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
93653
+ if (!envelope) {
93654
+ return null;
93655
+ }
93656
+ const { interaction, structure, responseIdentifier, prompt } = envelope;
93657
+ const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
93658
+ const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
93659
+ const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
93660
+ if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
93661
+ throw new ValidationError("Match source and target choice identifiers must be unique");
93662
+ }
93663
+ const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
93664
+ const maxAssociations = interaction.maxAssociations;
93665
+ if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
93666
+ throw new ValidationError("Match max associations must fit every correct pair");
93667
+ }
93668
+ const item = {
93669
+ key: identifier,
93670
+ title,
93671
+ body: [
93672
+ {
93673
+ kind: "interaction",
93674
+ type: "match",
93675
+ responseIdentifier,
93676
+ attributes: {
93677
+ shuffle: interaction.shuffle === true,
93678
+ "max-associations": maxAssociations
93679
+ },
93680
+ content: [
93681
+ {
93682
+ kind: "element",
93683
+ name: "qti-prompt",
93684
+ children: [{ kind: "text", text: prompt }]
93685
+ }
93686
+ ],
93687
+ choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
93688
+ identifier: choice.identifier,
93689
+ content: [{ kind: "text", text: choice.content }]
93690
+ })))
93691
+ }
93692
+ ],
93693
+ responseDeclarations: [
93694
+ {
93695
+ identifier: responseIdentifier,
93696
+ cardinality: "multiple",
93697
+ baseType: "directedPair",
93698
+ correctValues
93699
+ }
93700
+ ]
93701
+ };
93702
+ try {
93703
+ return compileQtiAuthoringItemXml(item, { identifier, title });
93704
+ } catch (error88) {
93705
+ throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage(error88)}`);
93706
+ }
93707
+ }
93475
93708
  function parseHottextSegments2(value) {
93476
93709
  if (!Array.isArray(value) || value.length === 0) {
93477
93710
  throw new ValidationError("Hottext questions require passage segments");
@@ -93773,7 +94006,8 @@ var init_timeback_qti_authoring_util = __esm(() => {
93773
94006
  buildNumericQuestionXml,
93774
94007
  buildAuthoringDocumentQuestionXml,
93775
94008
  buildHottextQuestionXml,
93776
- buildInlineChoiceQuestionXml
94009
+ buildInlineChoiceQuestionXml,
94010
+ buildMatchQuestionXml
93777
94011
  ];
93778
94012
  });
93779
94013
 
package/dist/server.js CHANGED
@@ -1122,7 +1122,7 @@ var package_default;
1122
1122
  var init_package = __esm(() => {
1123
1123
  package_default = {
1124
1124
  name: "@playcademy/sandbox",
1125
- version: "0.7.1-beta.11",
1125
+ version: "0.7.1-beta.13",
1126
1126
  description: "Local development server for Playcademy game development",
1127
1127
  type: "module",
1128
1128
  exports: {
@@ -9250,6 +9250,23 @@ var init_esm = __esm(() => {
9250
9250
  });
9251
9251
 
9252
9252
  // ../timeback/dist/engines/assessment-runtime/index.js
9253
+ function qtiDirectedPairValidationIssue(values, contract) {
9254
+ const sourceCounts = new Map;
9255
+ const targetCounts = new Map;
9256
+ for (const value of values) {
9257
+ const [source, target, extra] = value.split(" ");
9258
+ if (extra !== undefined || !source || !target || !contract.sources.has(source) || !contract.targets.has(target)) {
9259
+ return { kind: "invalid-pair", value };
9260
+ }
9261
+ if (!withinQtiCapacity(contract.sources, sourceCounts, source)) {
9262
+ return { kind: "source-capacity", identifier: source };
9263
+ }
9264
+ if (!withinQtiCapacity(contract.targets, targetCounts, target)) {
9265
+ return { kind: "target-capacity", identifier: target };
9266
+ }
9267
+ }
9268
+ return null;
9269
+ }
9253
9270
  function isRecord2(value) {
9254
9271
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
9255
9272
  }
@@ -9552,19 +9569,15 @@ function assessmentResponseValidationMessage(assessment, responses) {
9552
9569
  return null;
9553
9570
  }
9554
9571
  function directedPairsValidationMessage(itemIdentifier, values, contract) {
9555
- const sourceCounts = new Map;
9556
- const targetCounts = new Map;
9557
- for (const value of values) {
9558
- const [source, target, extra] = value.split(" ");
9559
- if (extra !== undefined || !source || !target || !contract.sources.has(source) || !contract.targets.has(target)) {
9572
+ const issue = qtiDirectedPairValidationIssue(values, contract);
9573
+ if (issue) {
9574
+ if (issue.kind === "invalid-pair") {
9560
9575
  return `Response for ${itemIdentifier} contains an invalid association pair.`;
9561
9576
  }
9562
- if (!withinQtiCapacity(contract.sources, sourceCounts, source)) {
9577
+ if (issue.kind === "source-capacity") {
9563
9578
  return `Response for ${itemIdentifier} uses a token beyond its allowed placements.`;
9564
9579
  }
9565
- if (!withinQtiCapacity(contract.targets, targetCounts, target)) {
9566
- return `Response for ${itemIdentifier} fills a target beyond its capacity.`;
9567
- }
9580
+ return `Response for ${itemIdentifier} fills a target beyond its capacity.`;
9568
9581
  }
9569
9582
  return null;
9570
9583
  }
@@ -9644,8 +9657,14 @@ function interactionResponseValidationMessage(itemIdentifier, interaction, respo
9644
9657
  allowed = new Set(interaction.choices.map((candidate) => candidate.identifier));
9645
9658
  } else if (interaction.type === "match") {
9646
9659
  const message = directedPairsValidationMessage(itemIdentifier, values, {
9647
- sources: new Map(interaction.sourceChoices.map((candidate) => [candidate.identifier, 0])),
9648
- targets: new Map(interaction.targetChoices.map((candidate) => [candidate.identifier, 0]))
9660
+ sources: new Map(interaction.sourceChoices.map((candidate) => [
9661
+ candidate.identifier,
9662
+ candidate.matchMax
9663
+ ])),
9664
+ targets: new Map(interaction.targetChoices.map((candidate) => [
9665
+ candidate.identifier,
9666
+ candidate.matchMax
9667
+ ]))
9649
9668
  });
9650
9669
  if (message) {
9651
9670
  return message;
@@ -10371,6 +10390,7 @@ var init_assessment_runtime = __esm(() => {
10371
10390
  init_fxp();
10372
10391
  init_timeback3();
10373
10392
  init_timeback3();
10393
+ init_timeback3();
10374
10394
  init_src();
10375
10395
  init_timeback3();
10376
10396
  init_src();
@@ -86426,18 +86446,18 @@ function serializeSanitizedMathMl(node) {
86426
86446
  const children = node.children.map(serializeSanitizedMathMl).join("");
86427
86447
  return `<${node.localName}${attributes2}>${children}</${node.localName}>`;
86428
86448
  }
86429
- function tableContentNode(element, blankForInteraction) {
86449
+ function tableContentNode(element, blankForInteraction, gapForElement) {
86430
86450
  const rows = xmlDescendants(element, (candidate) => candidate.localName === "tr").map((row) => {
86431
86451
  const cells = row.children.filter((cell) => cell.kind === "element" && (cell.localName === "td" || cell.localName === "th"));
86432
86452
  return {
86433
86453
  header: cells.length > 0 && cells.every((cell) => cell.localName === "th"),
86434
- cells: cells.map((cell) => contentNodesFrom(cell.children, blankForInteraction))
86454
+ cells: cells.map((cell) => contentNodesFrom(cell.children, blankForInteraction, gapForElement))
86435
86455
  };
86436
86456
  });
86437
86457
  return { kind: "table", rows };
86438
86458
  }
86439
- function listContentNode(element, blankForInteraction) {
86440
- const items = element.children.filter((child) => child.kind === "element" && child.localName === "li").map((item) => contentNodesFrom(item.children, blankForInteraction));
86459
+ function listContentNode(element, blankForInteraction, gapForElement) {
86460
+ const items = element.children.filter((child) => child.kind === "element" && child.localName === "li").map((item) => contentNodesFrom(item.children, blankForInteraction, gapForElement));
86441
86461
  return { kind: "list", ordered: element.localName === "ol", items };
86442
86462
  }
86443
86463
  function contentText(raw) {
@@ -86462,15 +86482,18 @@ function trimContentBoundaries(nodes) {
86462
86482
  }
86463
86483
  return kept.filter((node) => node.kind !== "text" || node.text !== "");
86464
86484
  }
86465
- function collectContentNodes(nodes, blankForInteraction) {
86485
+ function collectContentNodes(nodes, blankForInteraction, gapForElement) {
86466
86486
  const result = [];
86467
86487
  for (const node of nodes) {
86488
+ const gapIdentifier = node.kind === "element" ? gapForElement(node) : null;
86468
86489
  if (node.kind === "text") {
86469
86490
  const text3 = contentText(node.value);
86470
86491
  if (text3) {
86471
86492
  result.push({ kind: "text", text: text3 });
86472
86493
  }
86473
- } else if (CONTENT_SKIPPED_TAGS2.has(node.localName)) {} else if (isInteractionElement(node)) {
86494
+ } else if (CONTENT_SKIPPED_TAGS2.has(node.localName)) {} else if (gapIdentifier !== null) {
86495
+ result.push({ kind: "gap", identifier: gapIdentifier });
86496
+ } else if (isInteractionElement(node)) {
86474
86497
  const responseIdentifier = blankForInteraction(node);
86475
86498
  if (responseIdentifier) {
86476
86499
  result.push({ kind: "blank", responseIdentifier });
@@ -86495,19 +86518,19 @@ function collectContentNodes(nodes, blankForInteraction) {
86495
86518
  });
86496
86519
  }
86497
86520
  } else if (node.localName === "table") {
86498
- result.push(tableContentNode(node, blankForInteraction));
86521
+ result.push(tableContentNode(node, blankForInteraction, gapForElement));
86499
86522
  } else if (node.localName === "ul" || node.localName === "ol") {
86500
- const list = listContentNode(node, blankForInteraction);
86523
+ const list = listContentNode(node, blankForInteraction, gapForElement);
86501
86524
  if (list.kind === "list" && list.items.length > 0) {
86502
86525
  result.push(list);
86503
86526
  }
86504
86527
  } else if (node.localName === "p") {
86505
- const children = contentNodesFrom(node.children, blankForInteraction);
86528
+ const children = contentNodesFrom(node.children, blankForInteraction, gapForElement);
86506
86529
  if (children.length > 0) {
86507
86530
  result.push({ kind: "paragraph", children });
86508
86531
  }
86509
86532
  } else if (EMPHASIS_TAGS2.has(node.localName) || STRONG_TAGS2.has(node.localName)) {
86510
- const children = collectContentNodes(node.children, blankForInteraction);
86533
+ const children = collectContentNodes(node.children, blankForInteraction, gapForElement);
86511
86534
  const hasContent = children.some((child) => child.kind !== "text" || Boolean(child.text.trim()));
86512
86535
  if (hasContent) {
86513
86536
  result.push({
@@ -86516,17 +86539,17 @@ function collectContentNodes(nodes, blankForInteraction) {
86516
86539
  });
86517
86540
  }
86518
86541
  } else {
86519
- result.push(...collectContentNodes(node.children, blankForInteraction));
86542
+ result.push(...collectContentNodes(node.children, blankForInteraction, gapForElement));
86520
86543
  }
86521
86544
  }
86522
86545
  return result;
86523
86546
  }
86524
- function contentNodesFrom(nodes, blankForInteraction) {
86525
- return trimContentBoundaries(collectContentNodes(nodes, blankForInteraction));
86547
+ function contentNodesFrom(nodes, blankForInteraction, gapForElement = () => null) {
86548
+ return trimContentBoundaries(collectContentNodes(nodes, blankForInteraction, gapForElement));
86526
86549
  }
86527
86550
  function hasRichContent(nodes) {
86528
86551
  return nodes.some((node) => {
86529
- if (node.kind === "math" || node.kind === "image" || node.kind === "table" || node.kind === "list" || node.kind === "blank") {
86552
+ if (node.kind === "math" || node.kind === "image" || node.kind === "table" || node.kind === "list" || node.kind === "blank" || node.kind === "gap") {
86530
86553
  return true;
86531
86554
  }
86532
86555
  if (node.kind === "paragraph" || node.kind === "emphasis" || node.kind === "strong") {
@@ -86547,7 +86570,14 @@ function parseGapMatchText(interaction) {
86547
86570
  }
86548
86571
  return GAP_MATCH_TOKEN_TAGS2.has(element.localName) ? "" : undefined;
86549
86572
  });
86550
- return { textWithGaps, gaps };
86573
+ const passageNodes = interaction.children.filter((node) => node.kind === "text" || !GAP_MATCH_TOKEN_TAGS2.has(node.localName));
86574
+ const gapContent = contentNodesFrom(passageNodes, () => null, (element) => {
86575
+ if (element.localName !== "qti-gap") {
86576
+ return null;
86577
+ }
86578
+ return parseXmlAttributes(element.attributes).identifier ?? null;
86579
+ });
86580
+ return { textWithGaps, gaps, gapContent };
86551
86581
  }
86552
86582
  function isInlineInteractionElement(element) {
86553
86583
  const type = INTERACTION_TYPES2[element.localName];
@@ -86574,6 +86604,7 @@ function parseInteraction(source, interaction, context2) {
86574
86604
  let gapImages = [];
86575
86605
  let gaps = [];
86576
86606
  let textWithGaps;
86607
+ let gapContent;
86577
86608
  function ownStageGraphic() {
86578
86609
  return firstGraphic(interaction, STAGE_GRAPHIC_EXCLUDED_CONTAINERS2);
86579
86610
  }
@@ -86595,7 +86626,7 @@ function parseInteraction(source, interaction, context2) {
86595
86626
  graphic = ownStageGraphic();
86596
86627
  } else if (type === "gap-match") {
86597
86628
  gapTexts = parseGapTexts(interaction);
86598
- ({ textWithGaps, gaps } = parseGapMatchText(interaction));
86629
+ ({ textWithGaps, gaps, gapContent } = parseGapMatchText(interaction));
86599
86630
  } else if (type === "graphic-gap-match") {
86600
86631
  graphic = ownStageGraphic();
86601
86632
  gapImages = parseGapImages(interaction);
@@ -86623,6 +86654,7 @@ function parseInteraction(source, interaction, context2) {
86623
86654
  gapImages,
86624
86655
  gaps,
86625
86656
  ...textWithGaps === undefined ? {} : { textWithGaps },
86657
+ ...gapContent === undefined ? {} : { gapContent },
86626
86658
  rawXml: xmlElementSource(source, interaction)
86627
86659
  };
86628
86660
  }
@@ -86713,6 +86745,23 @@ function parseQtiItemXml(rawXml) {
86713
86745
  rawXml
86714
86746
  };
86715
86747
  }
86748
+ function qtiDirectedPairValidationIssue2(values, contract) {
86749
+ const sourceCounts = new Map;
86750
+ const targetCounts = new Map;
86751
+ for (const value of values) {
86752
+ const [source, target, extra] = value.split(" ");
86753
+ if (extra !== undefined || !source || !target || !contract.sources.has(source) || !contract.targets.has(target)) {
86754
+ return { kind: "invalid-pair", value };
86755
+ }
86756
+ if (!withinQtiCapacity(contract.sources, sourceCounts, source)) {
86757
+ return { kind: "source-capacity", identifier: source };
86758
+ }
86759
+ if (!withinQtiCapacity(contract.targets, targetCounts, target)) {
86760
+ return { kind: "target-capacity", identifier: target };
86761
+ }
86762
+ }
86763
+ return null;
86764
+ }
86716
86765
  function usableQtiRegionGeometry(shape, coords) {
86717
86766
  const arity2 = REGION_SHAPE_COORDS[shape];
86718
86767
  if (!arity2) {
@@ -87177,6 +87226,17 @@ function interactionBody(item, node, type, declaration, attributes2) {
87177
87226
  if (!attributes2.has("max-associations")) {
87178
87227
  attributes2.set("max-associations", String(correctValueStrings(declaration).length || sources.length));
87179
87228
  }
87229
+ const capacityIssue = qtiDirectedPairValidationIssue2(correctValueStrings(declaration), {
87230
+ sources: new Map(sources.map((choice) => [choice.identifier, 1])),
87231
+ targets: new Map(targets.map((choice) => [choice.identifier, 1]))
87232
+ });
87233
+ if (capacityIssue) {
87234
+ if (capacityIssue.kind === "invalid-pair") {
87235
+ compileError(`Match pair “${capacityIssue.value}” must name one declared source and target choice`);
87236
+ }
87237
+ const role = capacityIssue.kind === "source-capacity" ? "Source" : "Target";
87238
+ compileError(`${role} “${capacityIssue.identifier}” takes part in more associations than its match-max allows`);
87239
+ }
87180
87240
  const sets = [sources, targets].map((choices) => `<qti-simple-match-set>${choices.map((choice) => choiceXml(item, "qti-simple-associable-choice", choice, {
87181
87241
  "match-max": "1"
87182
87242
  })).join("")}</qti-simple-match-set>`).join("");
@@ -87462,6 +87522,14 @@ function qtiAttributeNumber(attributes2, name3, fallback) {
87462
87522
  const value = Number(raw);
87463
87523
  return Number.isFinite(value) ? value : fallback;
87464
87524
  }
87525
+ function qtiAttributeCapacity(attributes2, name3, fallback) {
87526
+ const raw = attributes2[name3]?.trim();
87527
+ if (!raw) {
87528
+ return fallback;
87529
+ }
87530
+ const value = Number(raw);
87531
+ return Number.isInteger(value) && value >= 0 ? value : null;
87532
+ }
87465
87533
  function qtiAttributeMaximum(attributes2, name3, fallback, unlimited) {
87466
87534
  const maximum = qtiAttributeNumber(attributes2, name3, fallback);
87467
87535
  return maximum === 0 ? unlimited : maximum;
@@ -87470,6 +87538,27 @@ function qtiAttributeBoolean(attributes2, name3) {
87470
87538
  const value = attributes2[name3];
87471
87539
  return value === "true" || value === "1";
87472
87540
  }
87541
+ function gapContentIdentifiers(nodes) {
87542
+ return nodes.flatMap((node) => {
87543
+ if (node.kind === "gap") {
87544
+ return [node.identifier];
87545
+ }
87546
+ if (node.kind === "paragraph" || node.kind === "emphasis" || node.kind === "strong") {
87547
+ return gapContentIdentifiers(node.children);
87548
+ }
87549
+ if (node.kind === "list") {
87550
+ return node.items.flatMap(gapContentIdentifiers);
87551
+ }
87552
+ if (node.kind === "table") {
87553
+ return node.rows.flatMap((row) => row.cells.flatMap(gapContentIdentifiers));
87554
+ }
87555
+ return [];
87556
+ });
87557
+ }
87558
+ function qtiGapContentMatchesGaps(nodes, gaps) {
87559
+ const identifiers = gapContentIdentifiers(nodes);
87560
+ return identifiers.length === gaps.length && identifiers.every((identifier, index2) => identifier === gaps[index2]);
87561
+ }
87473
87562
  function normalizedInteractionType(value) {
87474
87563
  if (typeof value !== "string") {
87475
87564
  return;
@@ -87480,6 +87569,9 @@ function normalizedInteractionType(value) {
87480
87569
  function usableChoices(choices) {
87481
87570
  return choices !== undefined && choices.length > 0 && choices.every((choice) => Boolean(choice.identifier));
87482
87571
  }
87572
+ function usableAssociableChoices(choices) {
87573
+ return usableChoices(choices) && choices.every((choice) => qtiAttributeCapacity(choice.attributes, "match-max", 0) !== null);
87574
+ }
87483
87575
  function transportableIdentifiers(identifiers) {
87484
87576
  return identifiers.every((identifier) => Boolean(identifier) && !/\s/.test(identifier));
87485
87577
  }
@@ -87552,7 +87644,7 @@ function authorableQtiInteractionProjection(interaction) {
87552
87644
  }
87553
87645
  case "match": {
87554
87646
  const [sourceChoices, targetChoices] = interaction.choiceSets;
87555
- if (interaction.choiceSets.length !== 2 || !usableChoices(sourceChoices) || !usableChoices(targetChoices) || !transportableIdentifiers([...sourceChoices, ...targetChoices].map((choice) => choice.identifier))) {
87647
+ if (interaction.choiceSets.length !== 2 || !usableAssociableChoices(sourceChoices) || !usableAssociableChoices(targetChoices) || !transportableIdentifiers([...sourceChoices, ...targetChoices].map((choice) => choice.identifier))) {
87556
87648
  return null;
87557
87649
  }
87558
87650
  return {
@@ -87621,8 +87713,8 @@ function authorableQtiInteractionProjection(interaction) {
87621
87713
  };
87622
87714
  }
87623
87715
  case "gap-match": {
87624
- const { gapTexts, gaps, textWithGaps } = interaction;
87625
- if (gapTexts.length === 0 || gaps.length === 0 || textWithGaps === undefined || !gapTexts.every((gapText) => Boolean(gapText.identifier && gapText.content)) || !transportableIdentifiers([...gapTexts.map((gapText) => gapText.identifier), ...gaps])) {
87716
+ const { gapTexts, gaps, textWithGaps, gapContent } = interaction;
87717
+ if (gapTexts.length === 0 || gaps.length === 0 || textWithGaps === undefined || !gapContent || !qtiGapContentMatchesGaps(gapContent, gaps) || !gapTexts.every((gapText) => Boolean(gapText.identifier && gapText.content)) || !transportableIdentifiers([...gapTexts.map((gapText) => gapText.identifier), ...gaps])) {
87626
87718
  return null;
87627
87719
  }
87628
87720
  return {
@@ -87631,6 +87723,7 @@ function authorableQtiInteractionProjection(interaction) {
87631
87723
  gapTexts,
87632
87724
  gaps,
87633
87725
  textWithGaps,
87726
+ gapContent,
87634
87727
  shuffle: qtiAttributeBoolean(attributes2, "shuffle")
87635
87728
  };
87636
87729
  }
@@ -87654,7 +87747,7 @@ function contributesScore(declaration) {
87654
87747
  function responsePairs(left, right) {
87655
87748
  return left.flatMap((first) => right.map((second) => `${first} ${second}`));
87656
87749
  }
87657
- function mappingResponseContext(item, declaration) {
87750
+ function interactionResponseSpace(item, declaration) {
87658
87751
  const interaction = item.interactions.find((candidate) => candidate.responseIdentifier === declaration.identifier);
87659
87752
  if (!interaction) {
87660
87753
  return {};
@@ -87746,21 +87839,49 @@ function processesResponseMapping(item, declaration) {
87746
87839
  }
87747
87840
  return item.responseProcessing.elements.some((element) => element.tagName === "qti-map-response" && (element.attributes.identifier ?? "RESPONSE") === declaration.identifier);
87748
87841
  }
87749
- function responseMappingMaximum(item, declaration) {
87842
+ function responseMappingMaximum(item, declaration, space) {
87750
87843
  if (!declaration.mapping || !processesResponseMapping(item, declaration)) {
87751
87844
  return null;
87752
87845
  }
87753
87846
  return qtiResponseMappingMaximum({
87754
87847
  cardinality: declaration.cardinality,
87755
87848
  mapping: declaration.mapping,
87756
- ...mappingResponseContext(item, declaration)
87849
+ ...space ?? interactionResponseSpace(item, declaration)
87757
87850
  });
87758
87851
  }
87759
87852
  function qtiItemScoringValidationMessage(item) {
87760
87853
  for (const declaration of item.responseDeclarations) {
87854
+ const space = interactionResponseSpace(item, declaration);
87855
+ const { responseValues: responseValues2, maximumResponses } = space;
87856
+ if (responseValues2 && declaration.correctValues.some((value) => !responseValues2.includes(value))) {
87857
+ return `Response ${declaration.identifier} declares a correct value its interaction cannot produce.`;
87858
+ }
87859
+ const interaction = item.interactions.find((candidate) => candidate.responseIdentifier === declaration.identifier);
87860
+ if (interaction?.type === "match") {
87861
+ const [sources = [], targets = []] = interaction.choiceSets;
87862
+ const issue6 = qtiDirectedPairValidationIssue2(declaration.correctValues, {
87863
+ sources: new Map(sources.map((choice) => [
87864
+ choice.identifier,
87865
+ qtiAttributeNumber(choice.attributes, "match-max", 0)
87866
+ ])),
87867
+ targets: new Map(targets.map((choice) => [
87868
+ choice.identifier,
87869
+ qtiAttributeNumber(choice.attributes, "match-max", 0)
87870
+ ]))
87871
+ });
87872
+ if (issue6?.kind === "source-capacity") {
87873
+ return `Response ${declaration.identifier} declares correct pairs that exceed source ${issue6.identifier}'s match-max.`;
87874
+ }
87875
+ if (issue6?.kind === "target-capacity") {
87876
+ return `Response ${declaration.identifier} declares correct pairs that exceed target ${issue6.identifier}'s match-max.`;
87877
+ }
87878
+ }
87879
+ if (declaration.cardinality !== "single" && maximumResponses !== undefined && maximumResponses !== 0 && declaration.correctValues.length > maximumResponses) {
87880
+ return `Response ${declaration.identifier} declares more correct values than its interaction accepts.`;
87881
+ }
87761
87882
  if (declaration.mapping) {
87762
87883
  if (processesResponseMapping(item, declaration)) {
87763
- if (responseMappingMaximum(item, declaration) === null) {
87884
+ if (responseMappingMaximum(item, declaration, space) === null) {
87764
87885
  return `Response ${declaration.identifier} has a mapping with no reachable positive score.`;
87765
87886
  }
87766
87887
  } else if (declaration.correctValues.length === 0) {
@@ -88083,6 +88204,7 @@ var init_qti = __esm(() => {
88083
88204
  init_fxp();
88084
88205
  init_timeback3();
88085
88206
  init_timeback3();
88207
+ init_timeback3();
88086
88208
  EVENT_HANDLER_ATTRIBUTE = /^on/i;
88087
88209
  SCRIPT_SCHEMES2 = {
88088
88210
  javascript: ["java", "script:"].join(""),
@@ -90919,6 +91041,16 @@ function playableChoice(choice) {
90919
91041
  ...choice.contentNodes ? { contentNodes: choice.contentNodes } : {}
90920
91042
  };
90921
91043
  }
91044
+ function playableAssociableChoice(choice) {
91045
+ const matchMax = qtiAttributeCapacity(choice.attributes, "match-max", 0);
91046
+ if (matchMax === null) {
91047
+ throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", "The selected assessment contains an invalid Match capacity.");
91048
+ }
91049
+ return {
91050
+ ...playableChoice(choice),
91051
+ matchMax
91052
+ };
91053
+ }
90922
91054
  function requireChoices2(choices) {
90923
91055
  if (choices.length === 0 || choices.some((candidate) => !candidate.identifier)) {
90924
91056
  throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", "The selected assessment contains an interaction Playcademy cannot render.");
@@ -91004,8 +91136,8 @@ function parseInteraction2(questionIdentifier, interaction) {
91004
91136
  if (sets.length !== 2) {
91005
91137
  throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", "Match interactions require exactly two choice sets.");
91006
91138
  }
91007
- const sourceChoices = requireChoices2(sets[0].map(playableChoice));
91008
- const targetChoices = requireChoices2(sets[1].map(playableChoice));
91139
+ const sourceChoices = requireChoices2(sets[0].map(playableAssociableChoice));
91140
+ const targetChoices = requireChoices2(sets[1].map(playableAssociableChoice));
91009
91141
  return {
91010
91142
  type,
91011
91143
  responseIdentifier,
@@ -91030,7 +91162,7 @@ function parseInteraction2(questionIdentifier, interaction) {
91030
91162
  };
91031
91163
  }
91032
91164
  if (type === "gap-match") {
91033
- if (interaction.gapTexts.length === 0 || interaction.gaps.length === 0 || interaction.textWithGaps === undefined) {
91165
+ if (interaction.gapTexts.length === 0 || interaction.gaps.length === 0 || interaction.textWithGaps === undefined || !interaction.gapContent || !qtiGapContentMatchesGaps(interaction.gapContent, interaction.gaps)) {
91034
91166
  throw new AssessmentRuntimeError("UNSUPPORTED_INTERACTION", `Assessment item ${questionIdentifier} has an incomplete gap-match interaction.`);
91035
91167
  }
91036
91168
  return {
@@ -91038,6 +91170,7 @@ function parseInteraction2(questionIdentifier, interaction) {
91038
91170
  responseIdentifier,
91039
91171
  ...prompt,
91040
91172
  content: interaction.textWithGaps,
91173
+ contentNodes: interaction.gapContent,
91041
91174
  gapTexts: interaction.gapTexts.map((gapText) => ({
91042
91175
  identifier: gapText.identifier,
91043
91176
  content: gapText.content,
@@ -93147,8 +93280,7 @@ function assertPlayableQtiQuestion(question) {
93147
93280
  }
93148
93281
  const item = parsedQtiItemOrNull(question.rawXml);
93149
93282
  const playable = item && playableQtiInteractions(item);
93150
- const scoringIssue = item && qtiItemScoringValidationMessage(item);
93151
- if (!playable || scoringIssue) {
93283
+ if (!playable || qtiItemScoringValidationMessage(item)) {
93152
93284
  throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
93153
93285
  }
93154
93286
  }
@@ -93401,31 +93533,38 @@ ${input.itemBody}
93401
93533
  </qti-response-processing>
93402
93534
  </qti-assessment-item>`;
93403
93535
  }
93404
- function parseInlineChoices(value) {
93405
- if (!Array.isArray(value) || value.length < 2) {
93406
- throw new ValidationError("Inline-choice questions require at least two choices");
93536
+ function parseStructuredChoices(value, options) {
93537
+ const { label, minimum, validate: validate2 } = options;
93538
+ if (!Array.isArray(value) || value.length < minimum) {
93539
+ throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
93407
93540
  }
93408
93541
  const choices = value.map((choice) => {
93409
93542
  const record3 = recordValue(choice);
93410
93543
  const identifier = record3?.identifier;
93411
93544
  const content = record3?.content;
93412
- if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim()) {
93413
- throw new ValidationError("Inline-choice options are invalid");
93545
+ if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
93546
+ throw new ValidationError(`${label} options are invalid`);
93414
93547
  }
93415
93548
  return { identifier: identifier.trim(), content: content.trim() };
93416
93549
  });
93417
93550
  if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
93418
- throw new ValidationError("Inline-choice option identifiers must be unique");
93551
+ throw new ValidationError(`${label} option identifiers must be unique`);
93419
93552
  }
93420
93553
  return choices;
93421
93554
  }
93422
- function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
93555
+ function declaredCorrectResponse(input, responseIdentifier) {
93423
93556
  const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
93424
93557
  const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
93425
- const correctResponse = recordValue(declaration?.correctResponse);
93426
- const rawValues = correctResponse?.value;
93427
- const values = Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : [];
93428
- if (declaration?.cardinality !== "single" || declaration.baseType !== "identifier") {
93558
+ const rawValues = recordValue(declaration?.correctResponse)?.value;
93559
+ return {
93560
+ cardinality: declaration?.cardinality,
93561
+ baseType: declaration?.baseType,
93562
+ values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
93563
+ };
93564
+ }
93565
+ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
93566
+ const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
93567
+ if (cardinality !== "single" || baseType !== "identifier") {
93429
93568
  throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
93430
93569
  }
93431
93570
  if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
@@ -93433,27 +93572,41 @@ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifi
93433
93572
  }
93434
93573
  return values[0];
93435
93574
  }
93436
- function buildInlineChoiceQuestionXml(input, identifier, title) {
93575
+ function structuredQuestionEnvelope(input, type, label, title) {
93437
93576
  const interaction = recordValue(input.interaction);
93438
- const interactionType = normalizedQtiInteractionType(interaction?.type ?? input.type);
93439
- if (interactionType !== "inline-choice") {
93577
+ if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
93440
93578
  return null;
93441
93579
  }
93442
- const responseIdentifier = interaction?.responseIdentifier;
93443
93580
  const structure = recordValue(interaction?.questionStructure);
93581
+ const responseIdentifier = interaction?.responseIdentifier;
93444
93582
  const prompt = structure?.prompt;
93445
- if (!structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
93446
- throw new ValidationError("Inline-choice question data is invalid");
93583
+ if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
93584
+ throw new ValidationError(`${label} question data is invalid`);
93447
93585
  }
93448
93586
  if (!title || typeof prompt !== "string" || !prompt.trim()) {
93449
- throw new ValidationError("Inline-choice questions require a title and prompt");
93587
+ throw new ValidationError(`${label} questions require a title and prompt`);
93588
+ }
93589
+ return {
93590
+ interaction,
93591
+ structure,
93592
+ responseIdentifier: responseIdentifier.trim(),
93593
+ prompt: prompt.trim()
93594
+ };
93595
+ }
93596
+ function buildInlineChoiceQuestionXml(input, identifier, title) {
93597
+ const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
93598
+ if (!envelope) {
93599
+ return null;
93450
93600
  }
93451
- const normalizedPrompt = prompt.trim();
93452
- const promptParts = normalizedPrompt.split(INLINE_CHOICE_BLANK);
93601
+ const { structure, responseIdentifier, prompt } = envelope;
93602
+ const promptParts = prompt.split(INLINE_CHOICE_BLANK);
93453
93603
  if (promptParts.length !== 2) {
93454
93604
  throw new ValidationError("Inline-choice questions require exactly one blank");
93455
93605
  }
93456
- const choices = parseInlineChoices(structure.inlineChoices);
93606
+ const choices = parseStructuredChoices(structure.inlineChoices, {
93607
+ label: "Inline-choice",
93608
+ minimum: 2
93609
+ });
93457
93610
  const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
93458
93611
  const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
93459
93612
  const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
@@ -93471,6 +93624,86 @@ ${choicesXml}
93471
93624
  itemBody
93472
93625
  });
93473
93626
  }
93627
+ function parseStructuredMatchChoices(value, label) {
93628
+ return parseStructuredChoices(value, {
93629
+ label: `Match ${label}`,
93630
+ minimum: 1,
93631
+ validate: (choice) => choice.matchMax === 1
93632
+ });
93633
+ }
93634
+ function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
93635
+ const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
93636
+ if (cardinality !== "multiple" || baseType !== "directedPair") {
93637
+ throw new ValidationError("Match questions require a multiple directed-pair response declaration");
93638
+ }
93639
+ const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
93640
+ const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
93641
+ const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
93642
+ const [source, target, extra] = value.split(" ");
93643
+ return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
93644
+ });
93645
+ if (!valid) {
93646
+ throw new ValidationError("Every Match correct response must name one declared source and target choice");
93647
+ }
93648
+ return values;
93649
+ }
93650
+ function buildMatchQuestionXml(input, identifier, title) {
93651
+ const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
93652
+ if (!envelope) {
93653
+ return null;
93654
+ }
93655
+ const { interaction, structure, responseIdentifier, prompt } = envelope;
93656
+ const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
93657
+ const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
93658
+ const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
93659
+ if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
93660
+ throw new ValidationError("Match source and target choice identifiers must be unique");
93661
+ }
93662
+ const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
93663
+ const maxAssociations = interaction.maxAssociations;
93664
+ if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
93665
+ throw new ValidationError("Match max associations must fit every correct pair");
93666
+ }
93667
+ const item = {
93668
+ key: identifier,
93669
+ title,
93670
+ body: [
93671
+ {
93672
+ kind: "interaction",
93673
+ type: "match",
93674
+ responseIdentifier,
93675
+ attributes: {
93676
+ shuffle: interaction.shuffle === true,
93677
+ "max-associations": maxAssociations
93678
+ },
93679
+ content: [
93680
+ {
93681
+ kind: "element",
93682
+ name: "qti-prompt",
93683
+ children: [{ kind: "text", text: prompt }]
93684
+ }
93685
+ ],
93686
+ choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
93687
+ identifier: choice.identifier,
93688
+ content: [{ kind: "text", text: choice.content }]
93689
+ })))
93690
+ }
93691
+ ],
93692
+ responseDeclarations: [
93693
+ {
93694
+ identifier: responseIdentifier,
93695
+ cardinality: "multiple",
93696
+ baseType: "directedPair",
93697
+ correctValues
93698
+ }
93699
+ ]
93700
+ };
93701
+ try {
93702
+ return compileQtiAuthoringItemXml(item, { identifier, title });
93703
+ } catch (error88) {
93704
+ throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage(error88)}`);
93705
+ }
93706
+ }
93474
93707
  function parseHottextSegments2(value) {
93475
93708
  if (!Array.isArray(value) || value.length === 0) {
93476
93709
  throw new ValidationError("Hottext questions require passage segments");
@@ -93772,7 +94005,8 @@ var init_timeback_qti_authoring_util = __esm(() => {
93772
94005
  buildNumericQuestionXml,
93773
94006
  buildAuthoringDocumentQuestionXml,
93774
94007
  buildHottextQuestionXml,
93775
- buildInlineChoiceQuestionXml
94008
+ buildInlineChoiceQuestionXml,
94009
+ buildMatchQuestionXml
93776
94010
  ];
93777
94011
  });
93778
94012
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.7.1-beta.11",
3
+ "version": "0.7.1-beta.13",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {