@playcademy/sandbox 0.10.1-beta.2 → 0.10.1-beta.4

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/cli.js CHANGED
@@ -285,7 +285,7 @@ var init_platform = __esm(() => {
285
285
  var PLATFORM_TIMEZONE = "America/New_York";
286
286
 
287
287
  // ../constants/src/timeback.ts
288
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS, ASSESSMENT_PURPOSES, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION = 1, PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION = 1, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY = "playcademyAssessment", PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY = "playcademyAssessmentItem", TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH = 2048, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
288
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS, ASSESSMENT_PURPOSES, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION = 1, PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION = 2, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY = "playcademyAssessment", PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY = "playcademyAssessmentItem", TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH = 2048, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
289
289
  var init_timeback2 = __esm(() => {
290
290
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
291
291
  TIMEBACK_SUBJECTS = [
@@ -1128,7 +1128,7 @@ var package_default;
1128
1128
  var init_package = __esm(() => {
1129
1129
  package_default = {
1130
1130
  name: "@playcademy/sandbox",
1131
- version: "0.10.1-beta.2",
1131
+ version: "0.10.1-beta.4",
1132
1132
  description: "Local development server for Playcademy game development",
1133
1133
  type: "module",
1134
1134
  exports: {
@@ -10834,30 +10834,60 @@ function comparePresentationText(left, right) {
10834
10834
  }
10835
10835
  return 0;
10836
10836
  }
10837
+ function presentationRandomIndex(seed) {
10838
+ let state = presentationHash(seed);
10839
+ const range = 2 ** 32;
10840
+ return (bound) => {
10841
+ const limit = range - range % bound;
10842
+ let value;
10843
+ do {
10844
+ state = state + 1831565813 >>> 0;
10845
+ let mixed = Math.imul(state ^ state >>> 15, state | 1);
10846
+ mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61);
10847
+ value = (mixed ^ mixed >>> 14) >>> 0;
10848
+ } while (value >= limit);
10849
+ return value % bound;
10850
+ };
10851
+ }
10837
10852
  function presentationChoices(choices, attemptId, itemIdentifier, domain) {
10838
- const seed = `${attemptId}\x00${itemIdentifier}\x00${domain}\x00`;
10839
- return [...choices].toSorted((left, right) => {
10840
- const hashDifference = presentationHash(`${seed}${left.identifier}`) - presentationHash(`${seed}${right.identifier}`);
10841
- return hashDifference || comparePresentationText(left.identifier, right.identifier);
10842
- });
10853
+ const randomIndex = presentationRandomIndex(JSON.stringify([attemptId, itemIdentifier, domain]));
10854
+ const movable = choices.filter((choice) => !choice.fixed).toSorted((left, right) => comparePresentationText(left.identifier, right.identifier));
10855
+ for (let index = movable.length - 1;index > 0; index--) {
10856
+ const other = randomIndex(index + 1);
10857
+ [movable[index], movable[other]] = [movable[other], movable[index]];
10858
+ }
10859
+ let next = 0;
10860
+ return choices.map((choice) => choice.fixed ? choice : movable[next++]);
10843
10861
  }
10844
10862
  function assessmentPresentationForAttempt(assessment, attemptId) {
10845
10863
  const items = assessment.items.map((item) => {
10846
10864
  const interactions = item.interactions.map((interaction) => {
10847
- if (interaction.type === "order" && interaction.shuffle) {
10848
- return {
10849
- ...interaction,
10850
- choices: presentationChoices(interaction.choices, attemptId, item.identifier, `order\x00${interaction.responseIdentifier}`)
10851
- };
10865
+ if (!("shuffle" in interaction) || !interaction.shuffle) {
10866
+ return interaction;
10852
10867
  }
10853
- if (interaction.type === "match" && interaction.shuffle) {
10854
- return {
10855
- ...interaction,
10856
- sourceChoices: presentationChoices(interaction.sourceChoices, attemptId, item.identifier, `match-source\x00${interaction.responseIdentifier}`),
10857
- targetChoices: presentationChoices(interaction.targetChoices, attemptId, item.identifier, `match-target\x00${interaction.responseIdentifier}`)
10858
- };
10868
+ function shuffled(choices, domain) {
10869
+ return presentationChoices(choices, attemptId, item.identifier, `${domain}\x00${interaction.responseIdentifier}`);
10870
+ }
10871
+ switch (interaction.type) {
10872
+ case "choice":
10873
+ case "inline-choice":
10874
+ case "order": {
10875
+ return {
10876
+ ...interaction,
10877
+ choices: shuffled(interaction.choices, interaction.type)
10878
+ };
10879
+ }
10880
+ case "gap-match": {
10881
+ return { ...interaction, gapTexts: shuffled(interaction.gapTexts, "gap-match") };
10882
+ }
10883
+ case "match": {
10884
+ return {
10885
+ ...interaction,
10886
+ sourceChoices: shuffled(interaction.sourceChoices, "match-source"),
10887
+ targetChoices: shuffled(interaction.targetChoices, "match-target")
10888
+ };
10889
+ }
10859
10890
  }
10860
- return interaction;
10861
10891
  });
10862
10892
  return { ...item, interactions };
10863
10893
  });
@@ -11002,19 +11032,22 @@ function scorePlatformAssessmentItem(item, responses) {
11002
11032
  isCorrect: verdicts.length > 0 && verdicts.every(Boolean)
11003
11033
  };
11004
11034
  }
11035
+ function reviewStandardRef({
11036
+ framework,
11037
+ identifier
11038
+ }) {
11039
+ return { framework, identifier };
11040
+ }
11005
11041
  function reportReviewRequiredPairUnavailable(review, administeredItemIdentifiers2, report) {
11006
- if (!review.requireBothQuestions) {
11007
- return;
11008
- }
11009
11042
  const administered = new Set(administeredItemIdentifiers2);
11010
11043
  const failures = [];
11011
- for (const standard of review.standards) {
11044
+ for (const standard of review.standards.filter((entry) => entry.requireBothQuestions)) {
11012
11045
  const key = assessmentStandardRefKey(standard);
11013
11046
  const selected = new Set(review.selections.filter((selection) => assessmentStandardRefKey(selection.standard) === key).map((selection) => selection.itemIdentifier));
11014
11047
  const administeredCount = [...selected].filter((identifier) => administered.has(identifier)).length;
11015
11048
  if (selected.size < 2 || administeredCount === 1) {
11016
11049
  failures.push({
11017
- standard,
11050
+ standard: reviewStandardRef(standard),
11018
11051
  reason: selected.size < 2 ? "selection_shortage" : "not_administered",
11019
11052
  requiredCount: 2,
11020
11053
  selectedCount: selected.size,
@@ -11057,6 +11090,9 @@ function canonicalRequestStandards(standards) {
11057
11090
  }
11058
11091
  const canonical = new Map;
11059
11092
  for (const input of standards) {
11093
+ if (input.requireBothQuestions !== undefined && typeof input.requireBothQuestions !== "boolean") {
11094
+ throw new Error("review standard requireBothQuestions must be a boolean");
11095
+ }
11060
11096
  const standard = canonicalAssessmentStandardRef(input);
11061
11097
  if (!reviewStandardFieldsWithinLimits(standard)) {
11062
11098
  throw new RangeError(`Review standard fields may contain at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
@@ -11065,13 +11101,19 @@ function canonicalRequestStandards(standards) {
11065
11101
  throw new Error("Review standards require a framework and identifier");
11066
11102
  }
11067
11103
  const key = assessmentStandardRefKey(standard);
11068
- if (!canonical.has(key)) {
11069
- canonical.set(key, standard);
11104
+ if (!canonical.has(key) || input.requireBothQuestions) {
11105
+ canonical.set(key, {
11106
+ ...standard,
11107
+ ...input.requireBothQuestions ? { requireBothQuestions: true } : {}
11108
+ });
11070
11109
  }
11071
11110
  }
11072
- return [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
11111
+ return [...canonical.values()];
11073
11112
  }
11074
11113
  function normalizeReviewRequest(request) {
11114
+ if ("requireBothQuestions" in request && request.requireBothQuestions !== undefined) {
11115
+ throw new Error("Set requireBothQuestions on individual review standards, not the review request");
11116
+ }
11075
11117
  const candidateItemsPerStandard = request.candidateItemsPerStandard ?? 1;
11076
11118
  if (!Number.isInteger(candidateItemsPerStandard) || candidateItemsPerStandard <= 0 || candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard) {
11077
11119
  throw new RangeError(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
@@ -11082,20 +11124,20 @@ function normalizeReviewRequest(request) {
11082
11124
  }
11083
11125
  return {
11084
11126
  standards,
11085
- candidateItemsPerStandard,
11086
- ...request.requireBothQuestions ? { requireBothQuestions: true } : {}
11127
+ candidateItemsPerStandard
11087
11128
  };
11088
11129
  }
11089
11130
  function reviewRequestFingerprint(request, policy) {
11090
11131
  const normalized = normalizeReviewRequest(request);
11132
+ const requiredStandardKeys = normalized.standards.filter((standard) => standard.requireBothQuestions).map(assessmentStandardRefKey);
11091
11133
  return JSON.stringify({
11092
11134
  kind: "standards-review",
11093
11135
  policy: {
11094
11136
  version: policy.version
11095
11137
  },
11096
11138
  candidateItemsPerStandard: normalized.candidateItemsPerStandard,
11097
- ...normalized.requireBothQuestions ? { requireBothQuestions: true } : {},
11098
- standardKeys: normalized.standards.map(assessmentStandardRefKey)
11139
+ standardKeys: normalized.standards.map(assessmentStandardRefKey),
11140
+ ...requiredStandardKeys.length > 0 ? { requiredStandardKeys } : {}
11099
11141
  });
11100
11142
  }
11101
11143
  function reviewQuestionLineItemId(parentLineItemId, itemIdentifier) {
@@ -11321,9 +11363,10 @@ function rankedCandidatesByStandard(bank, exposures) {
11321
11363
  return byStandard;
11322
11364
  }
11323
11365
  function reviewSlots(request) {
11324
- return request.standards.flatMap((standard) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
11325
- standard,
11326
- standardKey: assessmentStandardRefKey(standard)
11366
+ return request.standards.flatMap((standard, servingIndex) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
11367
+ standard: reviewStandardRef(standard),
11368
+ standardKey: assessmentStandardRefKey(standard),
11369
+ servingIndex
11327
11370
  })));
11328
11371
  }
11329
11372
  function matchingCandidates(candidatesByStandard, slotCount) {
@@ -11436,7 +11479,7 @@ function fulfillment(request, candidatesByStandard, selections) {
11436
11479
  const selected = selectedByStandard.get(key) ?? 0;
11437
11480
  return selected < request.candidateItemsPerStandard ? [
11438
11481
  {
11439
- standard,
11482
+ standard: reviewStandardRef(standard),
11440
11483
  requested: request.candidateItemsPerStandard,
11441
11484
  selected,
11442
11485
  eligible: candidatesByStandard.get(key)?.length ?? 0
@@ -11461,10 +11504,10 @@ function fulfillment(request, candidatesByStandard, selections) {
11461
11504
  function selectReviewItems(input) {
11462
11505
  const request = normalizeReviewRequest(input.request);
11463
11506
  const candidatesByStandard = rankedCandidatesByStandard(input.bank, input.exposures);
11464
- const slots = reviewSlots(request);
11507
+ const slots = reviewSlots(request).toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey));
11465
11508
  const boundedCandidates = matchingCandidates(candidatesByStandard, slots.length);
11466
11509
  const selected = preferredDistinctMatching(slots, boundedCandidates);
11467
- const selections = [...selected.entries()].toSorted(([left], [right]) => left - right).map(([index, itemIdentifier]) => ({
11510
+ const selections = [...selected.entries()].toSorted(([left], [right]) => slots[left].servingIndex - slots[right].servingIndex || left - right).map(([index, itemIdentifier]) => ({
11468
11511
  standard: slots[index].standard,
11469
11512
  itemIdentifier
11470
11513
  }));
@@ -11603,6 +11646,9 @@ function isAssessmentScore(value) {
11603
11646
  function isAssessmentStandardRef(value) {
11604
11647
  return isRecord(value) && typeof value.framework === "string" && value.framework.length > 0 && typeof value.identifier === "string" && value.identifier.length > 0;
11605
11648
  }
11649
+ function isAssessmentReviewStandard(value) {
11650
+ return isRecord(value) && isAssessmentStandardRef(value) && (value.requireBothQuestions === undefined || typeof value.requireBothQuestions === "boolean");
11651
+ }
11606
11652
  function isReviewFulfillment(value) {
11607
11653
  if (!isRecord(value) || value.status !== "complete" && value.status !== "partial" || !Number.isInteger(value.requestedItemCount) || !Number.isInteger(value.selectedItemCount) || !Array.isArray(value.shortages)) {
11608
11654
  return false;
@@ -11677,7 +11723,7 @@ function isRoutedDiagnosticAttemptMetadata(value, itemSubmissions, responseVersi
11677
11723
  });
11678
11724
  }
11679
11725
  function isReviewAttemptMetadata(value) {
11680
- return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.candidateItemsPerStandard) && (value.requireBothQuestions === undefined || typeof value.requireBothQuestions === "boolean") && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
11726
+ return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentReviewStandard) && Number.isInteger(value.candidateItemsPerStandard) && value.requireBothQuestions === undefined && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
11681
11727
  }
11682
11728
  function isOptionalInteger(value) {
11683
11729
  return value === undefined || Number.isInteger(value);
@@ -11796,6 +11842,7 @@ var init_assessment_runtime2 = __esm(() => {
11796
11842
  init_src();
11797
11843
  init_timeback3();
11798
11844
  init_src();
11845
+ init_src();
11799
11846
  init_uuid();
11800
11847
  init_esm();
11801
11848
  init_src();
@@ -12375,8 +12422,8 @@ var init_assessment_runtime2 = __esm(() => {
12375
12422
  }),
12376
12423
  StartAssessmentBaseSchema.extend({
12377
12424
  purpose: exports_external.literal("review"),
12378
- requireBothQuestions: exports_external.boolean().optional(),
12379
- standards: exports_external.array(AssessmentStandardRefSchema2).min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards),
12425
+ requireBothQuestions: exports_external.never().optional(),
12426
+ standards: exports_external.array(AssessmentStandardRefSchema2.and(exports_external.object({ requireBothQuestions: exports_external.boolean().optional() }))).min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards),
12380
12427
  candidateItemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard).optional()
12381
12428
  }),
12382
12429
  StartAssessmentBaseSchema.extend({
@@ -39193,7 +39240,7 @@ function assertMatchingAssessmentArtifactPair(playable, scoring, expectedIdentit
39193
39240
  throw new ValidationError("Assessment artifact pair does not describe one revision");
39194
39241
  }
39195
39242
  }
39196
- var ASSESSMENT_ARTIFACT_SCHEMA_VERSION = 1, ASSESSMENT_GRADER_PROTOCOL_VERSION = 1, ARTIFACT_KEY_PREFIX = "assessment-artifacts", SHA256_HEX, textEncoder;
39243
+ var ASSESSMENT_ARTIFACT_SCHEMA_VERSION = 2, ASSESSMENT_GRADER_PROTOCOL_VERSION = 1, ARTIFACT_KEY_PREFIX = "assessment-artifacts", SHA256_HEX, textEncoder;
39197
39244
  var init_assessment_artifact_envelope_util = __esm(() => {
39198
39245
  init_errors2();
39199
39246
  SHA256_HEX = /^[a-f0-9]{64}$/;
@@ -94287,6 +94334,30 @@ var init_timeback_game_metrics_comparison_util = __esm(() => {
94287
94334
  });
94288
94335
 
94289
94336
  // ../timeback/dist/engines/qti/index.js
94337
+ function qtiAttributeNumber(attributes, name3, fallback) {
94338
+ const raw = attributes[name3]?.trim();
94339
+ if (!raw) {
94340
+ return fallback;
94341
+ }
94342
+ const value = Number(raw);
94343
+ return Number.isFinite(value) ? value : fallback;
94344
+ }
94345
+ function qtiAttributeCapacity(attributes, name3, fallback) {
94346
+ const raw = attributes[name3]?.trim();
94347
+ if (!raw) {
94348
+ return fallback;
94349
+ }
94350
+ const value = Number(raw);
94351
+ return Number.isInteger(value) && value >= 0 ? value : null;
94352
+ }
94353
+ function qtiAttributeMaximum(attributes, name3, fallback, unlimited) {
94354
+ const maximum = qtiAttributeNumber(attributes, name3, fallback);
94355
+ return maximum === 0 ? unlimited : maximum;
94356
+ }
94357
+ function qtiAttributeBoolean(attributes, name3) {
94358
+ const value = attributes[name3];
94359
+ return value === "true" || value === "1";
94360
+ }
94290
94361
  function decodeCssEscapes(value) {
94291
94362
  return value.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex3) => {
94292
94363
  const code = Number.parseInt(hex3, 16);
@@ -94874,7 +94945,8 @@ function parseGapTexts(interaction) {
94874
94945
  return {
94875
94946
  identifier: attributes2.identifier || "",
94876
94947
  content: plainTextFromNodes(gapText.children),
94877
- matchMax: finiteNumber(attributes2["match-max"]) ?? 1
94948
+ matchMax: finiteNumber(attributes2["match-max"]) ?? 1,
94949
+ ...qtiAttributeBoolean(attributes2, "fixed") ? { fixed: true } : {}
94878
94950
  };
94879
94951
  }).filter((gapText) => gapText.identifier);
94880
94952
  }
@@ -95974,30 +96046,6 @@ ${body2}
95974
96046
  ${processing}` : ""}
95975
96047
  </qti-assessment-item>`;
95976
96048
  }
95977
- function qtiAttributeNumber(attributes2, name3, fallback) {
95978
- const raw = attributes2[name3]?.trim();
95979
- if (!raw) {
95980
- return fallback;
95981
- }
95982
- const value = Number(raw);
95983
- return Number.isFinite(value) ? value : fallback;
95984
- }
95985
- function qtiAttributeCapacity(attributes2, name3, fallback) {
95986
- const raw = attributes2[name3]?.trim();
95987
- if (!raw) {
95988
- return fallback;
95989
- }
95990
- const value = Number(raw);
95991
- return Number.isInteger(value) && value >= 0 ? value : null;
95992
- }
95993
- function qtiAttributeMaximum(attributes2, name3, fallback, unlimited) {
95994
- const maximum = qtiAttributeNumber(attributes2, name3, fallback);
95995
- return maximum === 0 ? unlimited : maximum;
95996
- }
95997
- function qtiAttributeBoolean(attributes2, name3) {
95998
- const value = attributes2[name3];
95999
- return value === "true" || value === "1";
96000
- }
96001
96049
  function gapContentIdentifiers(nodes) {
96002
96050
  return nodes.flatMap((node) => {
96003
96051
  if (node.kind === "gap") {
@@ -96078,6 +96126,7 @@ function authorableQtiInteractionProjection(interaction) {
96078
96126
  type: interaction.type,
96079
96127
  choices: interaction.choices,
96080
96128
  minChoices: qtiAttributeNumber(attributes2, "min-choices", 1),
96129
+ shuffle: qtiAttributeBoolean(attributes2, "shuffle"),
96081
96130
  maxChoices: qtiAttributeMaximum(attributes2, "max-choices", 1, interaction.choices.length)
96082
96131
  };
96083
96132
  }
@@ -96086,6 +96135,7 @@ function authorableQtiInteractionProjection(interaction) {
96086
96135
  ...base,
96087
96136
  type: interaction.type,
96088
96137
  choices: interaction.choices,
96138
+ shuffle: qtiAttributeBoolean(attributes2, "shuffle"),
96089
96139
  blankIndex: interaction.blankIndex ?? 0
96090
96140
  } : null;
96091
96141
  }
@@ -99917,11 +99967,13 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99917
99967
  playableContentNodesSchema = exports_external.array(exports_external.unknown());
99918
99968
  playableChoiceSchema = exports_external.object({
99919
99969
  identifier: trimmedNonEmptyString3,
99970
+ fixed: exports_external.boolean().optional(),
99920
99971
  content: exports_external.string(),
99921
99972
  contentNodes: playableContentNodesSchema.optional()
99922
99973
  }).strict();
99923
99974
  playableMatchChoiceSchema = exports_external.object({
99924
99975
  identifier: trimmedNonEmptyString3,
99976
+ fixed: exports_external.boolean().optional(),
99925
99977
  content: exports_external.string(),
99926
99978
  contentNodes: playableContentNodesSchema.optional(),
99927
99979
  matchMax: nonnegativeInteger
@@ -99941,6 +99993,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99941
99993
  }).strict();
99942
99994
  playableGapTextSchema = exports_external.object({
99943
99995
  identifier: trimmedNonEmptyString3,
99996
+ fixed: exports_external.boolean().optional(),
99944
99997
  content: exports_external.string(),
99945
99998
  matchMax: nonnegativeInteger
99946
99999
  }).strict();
@@ -99958,6 +100011,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99958
100011
  exports_external.object({
99959
100012
  ...playableInteractionBase,
99960
100013
  type: exports_external.literal("choice"),
100014
+ shuffle: exports_external.boolean(),
99961
100015
  choices: exports_external.array(playableChoiceSchema),
99962
100016
  minChoices: nonnegativeInteger,
99963
100017
  maxChoices: nonnegativeInteger
@@ -99965,6 +100019,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99965
100019
  exports_external.object({
99966
100020
  ...playableInteractionBase,
99967
100021
  type: exports_external.literal("inline-choice"),
100022
+ shuffle: exports_external.boolean(),
99968
100023
  choices: exports_external.array(playableChoiceSchema)
99969
100024
  }).strict(),
99970
100025
  exports_external.object({
@@ -100008,6 +100063,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
100008
100063
  exports_external.object({
100009
100064
  ...playableInteractionBase,
100010
100065
  type: exports_external.literal("gap-match"),
100066
+ shuffle: exports_external.boolean(),
100011
100067
  content: exports_external.string(),
100012
100068
  contentNodes: playableContentNodesSchema,
100013
100069
  gapTexts: exports_external.array(playableGapTextSchema),
@@ -100346,7 +100402,6 @@ function buildConventionalAssessmentSnapshot(input) {
100346
100402
  purpose: metadata2.purpose,
100347
100403
  standards: [...metadata2.review.standards],
100348
100404
  candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
100349
- ...metadata2.review.requireBothQuestions ? { requireBothQuestions: true } : {},
100350
100405
  selections: [...metadata2.review.selections],
100351
100406
  fulfillment: metadata2.review.fulfillment
100352
100407
  };
@@ -100521,6 +100576,7 @@ function playableChoice(choice) {
100521
100576
  return {
100522
100577
  identifier: choice.identifier,
100523
100578
  content: choice.content,
100579
+ ...qtiAttributeBoolean(choice.attributes, "fixed") ? { fixed: true } : {},
100524
100580
  ...choice.contentNodes ? { contentNodes: choice.contentNodes } : {}
100525
100581
  };
100526
100582
  }
@@ -100583,6 +100639,7 @@ function parseInteraction2(questionIdentifier, interaction) {
100583
100639
  responseIdentifier,
100584
100640
  ...prompt,
100585
100641
  choices,
100642
+ shuffle: qtiAttributeBoolean(interaction.attributes, "shuffle"),
100586
100643
  minChoices: qtiAttributeNumber(interaction.attributes, "min-choices", 1),
100587
100644
  maxChoices: qtiAttributeMaximum(interaction.attributes, "max-choices", 1, choices.length)
100588
100645
  };
@@ -100592,7 +100649,8 @@ function parseInteraction2(questionIdentifier, interaction) {
100592
100649
  type,
100593
100650
  responseIdentifier,
100594
100651
  ...prompt,
100595
- choices: requireChoices2(interaction.choices.map(playableChoice))
100652
+ choices: requireChoices2(interaction.choices.map(playableChoice)),
100653
+ shuffle: qtiAttributeBoolean(interaction.attributes, "shuffle")
100596
100654
  };
100597
100655
  }
100598
100656
  if (type === "text-entry") {
@@ -100653,11 +100711,13 @@ function parseInteraction2(questionIdentifier, interaction) {
100653
100711
  responseIdentifier,
100654
100712
  ...prompt,
100655
100713
  content: interaction.textWithGaps,
100714
+ shuffle: qtiAttributeBoolean(interaction.attributes, "shuffle"),
100656
100715
  contentNodes: interaction.gapContent,
100657
100716
  gapTexts: interaction.gapTexts.map((gapText) => ({
100658
100717
  identifier: gapText.identifier,
100659
100718
  content: gapText.content,
100660
- matchMax: gapText.matchMax
100719
+ matchMax: gapText.matchMax,
100720
+ ...gapText.fixed ? { fixed: true } : {}
100661
100721
  })),
100662
100722
  gaps: [...interaction.gaps],
100663
100723
  maxAssociations: qtiAttributeNumber(interaction.attributes, "max-associations", 0)
@@ -101476,7 +101536,7 @@ function buildInlineChoiceQuestionXml(input, identifier, title) {
101476
101536
  if (!envelope) {
101477
101537
  return null;
101478
101538
  }
101479
- const { structure, responseIdentifier, prompt } = envelope;
101539
+ const { interaction, structure, responseIdentifier, prompt } = envelope;
101480
101540
  const promptParts = prompt.split(INLINE_CHOICE_BLANK);
101481
101541
  if (promptParts.length !== 2) {
101482
101542
  throw new ValidationError("Inline-choice questions require exactly one blank");
@@ -101490,7 +101550,7 @@ function buildInlineChoiceQuestionXml(input, identifier, title) {
101490
101550
  const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
101491
101551
  `);
101492
101552
  const [before = "", after = ""] = promptParts;
101493
- const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
101553
+ const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}" shuffle="${interaction.shuffle === true}">
101494
101554
  ${choicesXml}
101495
101555
  </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
101496
101556
  return buildExactMatchQuestionXml({
@@ -102105,7 +102165,7 @@ async function prepareReviewMappingUpdate(test, questions) {
102105
102165
  const contentRevision = await assessmentContentRevision(test, questions);
102106
102166
  const assessment = {
102107
102167
  identifier: test.identifier,
102108
- contractVersion: 1,
102168
+ contractVersion: PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION,
102109
102169
  contentRevision,
102110
102170
  title: test.title,
102111
102171
  items: questions.questions.map(({ question }) => ({
@@ -102144,6 +102204,7 @@ async function prepareReviewMappingUpdate(test, questions) {
102144
102204
  var PLAYCADEMY_REVIEW_BANK_TITLE = "Review Bank", PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY = "playcademyReviewBankSink", PLAYCADEMY_REVIEW_BANK_SINK_VERSION = 1, StoredSinkWarningSchema, ReviewBankSinkMarkerSchema;
102145
102205
  var init_timeback_review_mapping_util = __esm(() => {
102146
102206
  init_esm();
102207
+ init_src();
102147
102208
  init_assessment_runtime2();
102148
102209
  init_qti();
102149
102210
  init_timeback3();
@@ -102818,13 +102879,11 @@ function buildPreparedAssessmentMetadata(plan, timestamp6, testName) {
102818
102879
  review: {
102819
102880
  requestFingerprint: reviewRequestFingerprint({
102820
102881
  standards: plan.review.standards,
102821
- candidateItemsPerStandard: plan.review.candidateItemsPerStandard,
102822
- requireBothQuestions: plan.review.requireBothQuestions
102882
+ candidateItemsPerStandard: plan.review.candidateItemsPerStandard
102823
102883
  }, DEFAULT_REVIEW_SELECTION_POLICY),
102824
102884
  bankRevision: plan.review.bankRevision,
102825
102885
  standards: plan.review.standards,
102826
102886
  candidateItemsPerStandard: plan.review.candidateItemsPerStandard,
102827
- ...plan.review.requireBothQuestions ? { requireBothQuestions: true } : {},
102828
102887
  selections: plan.review.selections,
102829
102888
  fulfillment: plan.review.fulfillment
102830
102889
  }
@@ -104007,7 +104066,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104007
104066
  bankRevision: catalog.bank.bankRevision,
104008
104067
  standards: [...normalized.standards],
104009
104068
  candidateItemsPerStandard: normalized.candidateItemsPerStandard,
104010
- ...normalized.requireBothQuestions ? { requireBothQuestions: true } : {},
104011
104069
  selections: [...selected.selections],
104012
104070
  fulfillment: selected.fulfillment
104013
104071
  }
@@ -104279,7 +104337,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104279
104337
  bankRevision: source.bank.bankRevision,
104280
104338
  standards: [...normalizedRequest.standards],
104281
104339
  candidateItemsPerStandard: normalizedRequest.candidateItemsPerStandard,
104282
- ...normalizedRequest.requireBothQuestions ? { requireBothQuestions: true } : {},
104283
104340
  selections: [...selected.selections],
104284
104341
  fulfillment: selected.fulfillment
104285
104342
  }
package/dist/constants.js CHANGED
@@ -100,7 +100,7 @@ var init_platform = __esm(() => {
100
100
  var PLATFORM_TIMEZONE = "America/New_York";
101
101
 
102
102
  // ../constants/src/timeback.ts
103
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS, ASSESSMENT_PURPOSES, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION = 1, PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION = 1, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY = "playcademyAssessment", PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY = "playcademyAssessmentItem", TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH = 2048, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
103
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS, ASSESSMENT_PURPOSES, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION = 1, PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION = 2, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY = "playcademyAssessment", PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY = "playcademyAssessmentItem", TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH = 2048, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
104
104
  var init_timeback = __esm(() => {
105
105
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
106
106
  TIMEBACK_SUBJECTS = [
package/dist/server.js CHANGED
@@ -284,7 +284,7 @@ var init_platform = __esm(() => {
284
284
  var PLATFORM_TIMEZONE = "America/New_York";
285
285
 
286
286
  // ../constants/src/timeback.ts
287
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS, ASSESSMENT_PURPOSES, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION = 1, PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION = 1, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY = "playcademyAssessment", PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY = "playcademyAssessmentItem", TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH = 2048, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
287
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS, ASSESSMENT_PURPOSES, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION = 1, PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION = 2, PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY = "playcademyAssessment", PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY = "playcademyAssessmentItem", TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH = 2048, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
288
288
  var init_timeback2 = __esm(() => {
289
289
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
290
290
  TIMEBACK_SUBJECTS = [
@@ -1127,7 +1127,7 @@ var package_default;
1127
1127
  var init_package = __esm(() => {
1128
1128
  package_default = {
1129
1129
  name: "@playcademy/sandbox",
1130
- version: "0.10.1-beta.2",
1130
+ version: "0.10.1-beta.4",
1131
1131
  description: "Local development server for Playcademy game development",
1132
1132
  type: "module",
1133
1133
  exports: {
@@ -10833,30 +10833,60 @@ function comparePresentationText(left, right) {
10833
10833
  }
10834
10834
  return 0;
10835
10835
  }
10836
+ function presentationRandomIndex(seed) {
10837
+ let state = presentationHash(seed);
10838
+ const range = 2 ** 32;
10839
+ return (bound) => {
10840
+ const limit = range - range % bound;
10841
+ let value;
10842
+ do {
10843
+ state = state + 1831565813 >>> 0;
10844
+ let mixed = Math.imul(state ^ state >>> 15, state | 1);
10845
+ mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61);
10846
+ value = (mixed ^ mixed >>> 14) >>> 0;
10847
+ } while (value >= limit);
10848
+ return value % bound;
10849
+ };
10850
+ }
10836
10851
  function presentationChoices(choices, attemptId, itemIdentifier, domain) {
10837
- const seed = `${attemptId}\x00${itemIdentifier}\x00${domain}\x00`;
10838
- return [...choices].toSorted((left, right) => {
10839
- const hashDifference = presentationHash(`${seed}${left.identifier}`) - presentationHash(`${seed}${right.identifier}`);
10840
- return hashDifference || comparePresentationText(left.identifier, right.identifier);
10841
- });
10852
+ const randomIndex = presentationRandomIndex(JSON.stringify([attemptId, itemIdentifier, domain]));
10853
+ const movable = choices.filter((choice) => !choice.fixed).toSorted((left, right) => comparePresentationText(left.identifier, right.identifier));
10854
+ for (let index = movable.length - 1;index > 0; index--) {
10855
+ const other = randomIndex(index + 1);
10856
+ [movable[index], movable[other]] = [movable[other], movable[index]];
10857
+ }
10858
+ let next = 0;
10859
+ return choices.map((choice) => choice.fixed ? choice : movable[next++]);
10842
10860
  }
10843
10861
  function assessmentPresentationForAttempt(assessment, attemptId) {
10844
10862
  const items = assessment.items.map((item) => {
10845
10863
  const interactions = item.interactions.map((interaction) => {
10846
- if (interaction.type === "order" && interaction.shuffle) {
10847
- return {
10848
- ...interaction,
10849
- choices: presentationChoices(interaction.choices, attemptId, item.identifier, `order\x00${interaction.responseIdentifier}`)
10850
- };
10864
+ if (!("shuffle" in interaction) || !interaction.shuffle) {
10865
+ return interaction;
10851
10866
  }
10852
- if (interaction.type === "match" && interaction.shuffle) {
10853
- return {
10854
- ...interaction,
10855
- sourceChoices: presentationChoices(interaction.sourceChoices, attemptId, item.identifier, `match-source\x00${interaction.responseIdentifier}`),
10856
- targetChoices: presentationChoices(interaction.targetChoices, attemptId, item.identifier, `match-target\x00${interaction.responseIdentifier}`)
10857
- };
10867
+ function shuffled(choices, domain) {
10868
+ return presentationChoices(choices, attemptId, item.identifier, `${domain}\x00${interaction.responseIdentifier}`);
10869
+ }
10870
+ switch (interaction.type) {
10871
+ case "choice":
10872
+ case "inline-choice":
10873
+ case "order": {
10874
+ return {
10875
+ ...interaction,
10876
+ choices: shuffled(interaction.choices, interaction.type)
10877
+ };
10878
+ }
10879
+ case "gap-match": {
10880
+ return { ...interaction, gapTexts: shuffled(interaction.gapTexts, "gap-match") };
10881
+ }
10882
+ case "match": {
10883
+ return {
10884
+ ...interaction,
10885
+ sourceChoices: shuffled(interaction.sourceChoices, "match-source"),
10886
+ targetChoices: shuffled(interaction.targetChoices, "match-target")
10887
+ };
10888
+ }
10858
10889
  }
10859
- return interaction;
10860
10890
  });
10861
10891
  return { ...item, interactions };
10862
10892
  });
@@ -11001,19 +11031,22 @@ function scorePlatformAssessmentItem(item, responses) {
11001
11031
  isCorrect: verdicts.length > 0 && verdicts.every(Boolean)
11002
11032
  };
11003
11033
  }
11034
+ function reviewStandardRef({
11035
+ framework,
11036
+ identifier
11037
+ }) {
11038
+ return { framework, identifier };
11039
+ }
11004
11040
  function reportReviewRequiredPairUnavailable(review, administeredItemIdentifiers2, report) {
11005
- if (!review.requireBothQuestions) {
11006
- return;
11007
- }
11008
11041
  const administered = new Set(administeredItemIdentifiers2);
11009
11042
  const failures = [];
11010
- for (const standard of review.standards) {
11043
+ for (const standard of review.standards.filter((entry) => entry.requireBothQuestions)) {
11011
11044
  const key = assessmentStandardRefKey(standard);
11012
11045
  const selected = new Set(review.selections.filter((selection) => assessmentStandardRefKey(selection.standard) === key).map((selection) => selection.itemIdentifier));
11013
11046
  const administeredCount = [...selected].filter((identifier) => administered.has(identifier)).length;
11014
11047
  if (selected.size < 2 || administeredCount === 1) {
11015
11048
  failures.push({
11016
- standard,
11049
+ standard: reviewStandardRef(standard),
11017
11050
  reason: selected.size < 2 ? "selection_shortage" : "not_administered",
11018
11051
  requiredCount: 2,
11019
11052
  selectedCount: selected.size,
@@ -11056,6 +11089,9 @@ function canonicalRequestStandards(standards) {
11056
11089
  }
11057
11090
  const canonical = new Map;
11058
11091
  for (const input of standards) {
11092
+ if (input.requireBothQuestions !== undefined && typeof input.requireBothQuestions !== "boolean") {
11093
+ throw new Error("review standard requireBothQuestions must be a boolean");
11094
+ }
11059
11095
  const standard = canonicalAssessmentStandardRef(input);
11060
11096
  if (!reviewStandardFieldsWithinLimits(standard)) {
11061
11097
  throw new RangeError(`Review standard fields may contain at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
@@ -11064,13 +11100,19 @@ function canonicalRequestStandards(standards) {
11064
11100
  throw new Error("Review standards require a framework and identifier");
11065
11101
  }
11066
11102
  const key = assessmentStandardRefKey(standard);
11067
- if (!canonical.has(key)) {
11068
- canonical.set(key, standard);
11103
+ if (!canonical.has(key) || input.requireBothQuestions) {
11104
+ canonical.set(key, {
11105
+ ...standard,
11106
+ ...input.requireBothQuestions ? { requireBothQuestions: true } : {}
11107
+ });
11069
11108
  }
11070
11109
  }
11071
- return [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
11110
+ return [...canonical.values()];
11072
11111
  }
11073
11112
  function normalizeReviewRequest(request) {
11113
+ if ("requireBothQuestions" in request && request.requireBothQuestions !== undefined) {
11114
+ throw new Error("Set requireBothQuestions on individual review standards, not the review request");
11115
+ }
11074
11116
  const candidateItemsPerStandard = request.candidateItemsPerStandard ?? 1;
11075
11117
  if (!Number.isInteger(candidateItemsPerStandard) || candidateItemsPerStandard <= 0 || candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard) {
11076
11118
  throw new RangeError(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
@@ -11081,20 +11123,20 @@ function normalizeReviewRequest(request) {
11081
11123
  }
11082
11124
  return {
11083
11125
  standards,
11084
- candidateItemsPerStandard,
11085
- ...request.requireBothQuestions ? { requireBothQuestions: true } : {}
11126
+ candidateItemsPerStandard
11086
11127
  };
11087
11128
  }
11088
11129
  function reviewRequestFingerprint(request, policy) {
11089
11130
  const normalized = normalizeReviewRequest(request);
11131
+ const requiredStandardKeys = normalized.standards.filter((standard) => standard.requireBothQuestions).map(assessmentStandardRefKey);
11090
11132
  return JSON.stringify({
11091
11133
  kind: "standards-review",
11092
11134
  policy: {
11093
11135
  version: policy.version
11094
11136
  },
11095
11137
  candidateItemsPerStandard: normalized.candidateItemsPerStandard,
11096
- ...normalized.requireBothQuestions ? { requireBothQuestions: true } : {},
11097
- standardKeys: normalized.standards.map(assessmentStandardRefKey)
11138
+ standardKeys: normalized.standards.map(assessmentStandardRefKey),
11139
+ ...requiredStandardKeys.length > 0 ? { requiredStandardKeys } : {}
11098
11140
  });
11099
11141
  }
11100
11142
  function reviewQuestionLineItemId(parentLineItemId, itemIdentifier) {
@@ -11320,9 +11362,10 @@ function rankedCandidatesByStandard(bank, exposures) {
11320
11362
  return byStandard;
11321
11363
  }
11322
11364
  function reviewSlots(request) {
11323
- return request.standards.flatMap((standard) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
11324
- standard,
11325
- standardKey: assessmentStandardRefKey(standard)
11365
+ return request.standards.flatMap((standard, servingIndex) => Array.from({ length: request.candidateItemsPerStandard }, () => ({
11366
+ standard: reviewStandardRef(standard),
11367
+ standardKey: assessmentStandardRefKey(standard),
11368
+ servingIndex
11326
11369
  })));
11327
11370
  }
11328
11371
  function matchingCandidates(candidatesByStandard, slotCount) {
@@ -11435,7 +11478,7 @@ function fulfillment(request, candidatesByStandard, selections) {
11435
11478
  const selected = selectedByStandard.get(key) ?? 0;
11436
11479
  return selected < request.candidateItemsPerStandard ? [
11437
11480
  {
11438
- standard,
11481
+ standard: reviewStandardRef(standard),
11439
11482
  requested: request.candidateItemsPerStandard,
11440
11483
  selected,
11441
11484
  eligible: candidatesByStandard.get(key)?.length ?? 0
@@ -11460,10 +11503,10 @@ function fulfillment(request, candidatesByStandard, selections) {
11460
11503
  function selectReviewItems(input) {
11461
11504
  const request = normalizeReviewRequest(input.request);
11462
11505
  const candidatesByStandard = rankedCandidatesByStandard(input.bank, input.exposures);
11463
- const slots = reviewSlots(request);
11506
+ const slots = reviewSlots(request).toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey));
11464
11507
  const boundedCandidates = matchingCandidates(candidatesByStandard, slots.length);
11465
11508
  const selected = preferredDistinctMatching(slots, boundedCandidates);
11466
- const selections = [...selected.entries()].toSorted(([left], [right]) => left - right).map(([index, itemIdentifier]) => ({
11509
+ const selections = [...selected.entries()].toSorted(([left], [right]) => slots[left].servingIndex - slots[right].servingIndex || left - right).map(([index, itemIdentifier]) => ({
11467
11510
  standard: slots[index].standard,
11468
11511
  itemIdentifier
11469
11512
  }));
@@ -11602,6 +11645,9 @@ function isAssessmentScore(value) {
11602
11645
  function isAssessmentStandardRef(value) {
11603
11646
  return isRecord(value) && typeof value.framework === "string" && value.framework.length > 0 && typeof value.identifier === "string" && value.identifier.length > 0;
11604
11647
  }
11648
+ function isAssessmentReviewStandard(value) {
11649
+ return isRecord(value) && isAssessmentStandardRef(value) && (value.requireBothQuestions === undefined || typeof value.requireBothQuestions === "boolean");
11650
+ }
11605
11651
  function isReviewFulfillment(value) {
11606
11652
  if (!isRecord(value) || value.status !== "complete" && value.status !== "partial" || !Number.isInteger(value.requestedItemCount) || !Number.isInteger(value.selectedItemCount) || !Array.isArray(value.shortages)) {
11607
11653
  return false;
@@ -11676,7 +11722,7 @@ function isRoutedDiagnosticAttemptMetadata(value, itemSubmissions, responseVersi
11676
11722
  });
11677
11723
  }
11678
11724
  function isReviewAttemptMetadata(value) {
11679
- return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.candidateItemsPerStandard) && (value.requireBothQuestions === undefined || typeof value.requireBothQuestions === "boolean") && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
11725
+ return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentReviewStandard) && Number.isInteger(value.candidateItemsPerStandard) && value.requireBothQuestions === undefined && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
11680
11726
  }
11681
11727
  function isOptionalInteger(value) {
11682
11728
  return value === undefined || Number.isInteger(value);
@@ -11795,6 +11841,7 @@ var init_assessment_runtime2 = __esm(() => {
11795
11841
  init_src();
11796
11842
  init_timeback3();
11797
11843
  init_src();
11844
+ init_src();
11798
11845
  init_uuid();
11799
11846
  init_esm();
11800
11847
  init_src();
@@ -12374,8 +12421,8 @@ var init_assessment_runtime2 = __esm(() => {
12374
12421
  }),
12375
12422
  StartAssessmentBaseSchema.extend({
12376
12423
  purpose: exports_external.literal("review"),
12377
- requireBothQuestions: exports_external.boolean().optional(),
12378
- standards: exports_external.array(AssessmentStandardRefSchema2).min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards),
12424
+ requireBothQuestions: exports_external.never().optional(),
12425
+ standards: exports_external.array(AssessmentStandardRefSchema2.and(exports_external.object({ requireBothQuestions: exports_external.boolean().optional() }))).min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards),
12379
12426
  candidateItemsPerStandard: exports_external.number().int().positive().max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard).optional()
12380
12427
  }),
12381
12428
  StartAssessmentBaseSchema.extend({
@@ -39192,7 +39239,7 @@ function assertMatchingAssessmentArtifactPair(playable, scoring, expectedIdentit
39192
39239
  throw new ValidationError("Assessment artifact pair does not describe one revision");
39193
39240
  }
39194
39241
  }
39195
- var ASSESSMENT_ARTIFACT_SCHEMA_VERSION = 1, ASSESSMENT_GRADER_PROTOCOL_VERSION = 1, ARTIFACT_KEY_PREFIX = "assessment-artifacts", SHA256_HEX, textEncoder;
39242
+ var ASSESSMENT_ARTIFACT_SCHEMA_VERSION = 2, ASSESSMENT_GRADER_PROTOCOL_VERSION = 1, ARTIFACT_KEY_PREFIX = "assessment-artifacts", SHA256_HEX, textEncoder;
39196
39243
  var init_assessment_artifact_envelope_util = __esm(() => {
39197
39244
  init_errors2();
39198
39245
  SHA256_HEX = /^[a-f0-9]{64}$/;
@@ -94286,6 +94333,30 @@ var init_timeback_game_metrics_comparison_util = __esm(() => {
94286
94333
  });
94287
94334
 
94288
94335
  // ../timeback/dist/engines/qti/index.js
94336
+ function qtiAttributeNumber(attributes, name3, fallback) {
94337
+ const raw = attributes[name3]?.trim();
94338
+ if (!raw) {
94339
+ return fallback;
94340
+ }
94341
+ const value = Number(raw);
94342
+ return Number.isFinite(value) ? value : fallback;
94343
+ }
94344
+ function qtiAttributeCapacity(attributes, name3, fallback) {
94345
+ const raw = attributes[name3]?.trim();
94346
+ if (!raw) {
94347
+ return fallback;
94348
+ }
94349
+ const value = Number(raw);
94350
+ return Number.isInteger(value) && value >= 0 ? value : null;
94351
+ }
94352
+ function qtiAttributeMaximum(attributes, name3, fallback, unlimited) {
94353
+ const maximum = qtiAttributeNumber(attributes, name3, fallback);
94354
+ return maximum === 0 ? unlimited : maximum;
94355
+ }
94356
+ function qtiAttributeBoolean(attributes, name3) {
94357
+ const value = attributes[name3];
94358
+ return value === "true" || value === "1";
94359
+ }
94289
94360
  function decodeCssEscapes(value) {
94290
94361
  return value.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex3) => {
94291
94362
  const code = Number.parseInt(hex3, 16);
@@ -94873,7 +94944,8 @@ function parseGapTexts(interaction) {
94873
94944
  return {
94874
94945
  identifier: attributes2.identifier || "",
94875
94946
  content: plainTextFromNodes(gapText.children),
94876
- matchMax: finiteNumber(attributes2["match-max"]) ?? 1
94947
+ matchMax: finiteNumber(attributes2["match-max"]) ?? 1,
94948
+ ...qtiAttributeBoolean(attributes2, "fixed") ? { fixed: true } : {}
94877
94949
  };
94878
94950
  }).filter((gapText) => gapText.identifier);
94879
94951
  }
@@ -95973,30 +96045,6 @@ ${body2}
95973
96045
  ${processing}` : ""}
95974
96046
  </qti-assessment-item>`;
95975
96047
  }
95976
- function qtiAttributeNumber(attributes2, name3, fallback) {
95977
- const raw = attributes2[name3]?.trim();
95978
- if (!raw) {
95979
- return fallback;
95980
- }
95981
- const value = Number(raw);
95982
- return Number.isFinite(value) ? value : fallback;
95983
- }
95984
- function qtiAttributeCapacity(attributes2, name3, fallback) {
95985
- const raw = attributes2[name3]?.trim();
95986
- if (!raw) {
95987
- return fallback;
95988
- }
95989
- const value = Number(raw);
95990
- return Number.isInteger(value) && value >= 0 ? value : null;
95991
- }
95992
- function qtiAttributeMaximum(attributes2, name3, fallback, unlimited) {
95993
- const maximum = qtiAttributeNumber(attributes2, name3, fallback);
95994
- return maximum === 0 ? unlimited : maximum;
95995
- }
95996
- function qtiAttributeBoolean(attributes2, name3) {
95997
- const value = attributes2[name3];
95998
- return value === "true" || value === "1";
95999
- }
96000
96048
  function gapContentIdentifiers(nodes) {
96001
96049
  return nodes.flatMap((node) => {
96002
96050
  if (node.kind === "gap") {
@@ -96077,6 +96125,7 @@ function authorableQtiInteractionProjection(interaction) {
96077
96125
  type: interaction.type,
96078
96126
  choices: interaction.choices,
96079
96127
  minChoices: qtiAttributeNumber(attributes2, "min-choices", 1),
96128
+ shuffle: qtiAttributeBoolean(attributes2, "shuffle"),
96080
96129
  maxChoices: qtiAttributeMaximum(attributes2, "max-choices", 1, interaction.choices.length)
96081
96130
  };
96082
96131
  }
@@ -96085,6 +96134,7 @@ function authorableQtiInteractionProjection(interaction) {
96085
96134
  ...base,
96086
96135
  type: interaction.type,
96087
96136
  choices: interaction.choices,
96137
+ shuffle: qtiAttributeBoolean(attributes2, "shuffle"),
96088
96138
  blankIndex: interaction.blankIndex ?? 0
96089
96139
  } : null;
96090
96140
  }
@@ -99916,11 +99966,13 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99916
99966
  playableContentNodesSchema = exports_external.array(exports_external.unknown());
99917
99967
  playableChoiceSchema = exports_external.object({
99918
99968
  identifier: trimmedNonEmptyString3,
99969
+ fixed: exports_external.boolean().optional(),
99919
99970
  content: exports_external.string(),
99920
99971
  contentNodes: playableContentNodesSchema.optional()
99921
99972
  }).strict();
99922
99973
  playableMatchChoiceSchema = exports_external.object({
99923
99974
  identifier: trimmedNonEmptyString3,
99975
+ fixed: exports_external.boolean().optional(),
99924
99976
  content: exports_external.string(),
99925
99977
  contentNodes: playableContentNodesSchema.optional(),
99926
99978
  matchMax: nonnegativeInteger
@@ -99940,6 +99992,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99940
99992
  }).strict();
99941
99993
  playableGapTextSchema = exports_external.object({
99942
99994
  identifier: trimmedNonEmptyString3,
99995
+ fixed: exports_external.boolean().optional(),
99943
99996
  content: exports_external.string(),
99944
99997
  matchMax: nonnegativeInteger
99945
99998
  }).strict();
@@ -99957,6 +100010,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99957
100010
  exports_external.object({
99958
100011
  ...playableInteractionBase,
99959
100012
  type: exports_external.literal("choice"),
100013
+ shuffle: exports_external.boolean(),
99960
100014
  choices: exports_external.array(playableChoiceSchema),
99961
100015
  minChoices: nonnegativeInteger,
99962
100016
  maxChoices: nonnegativeInteger
@@ -99964,6 +100018,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
99964
100018
  exports_external.object({
99965
100019
  ...playableInteractionBase,
99966
100020
  type: exports_external.literal("inline-choice"),
100021
+ shuffle: exports_external.boolean(),
99967
100022
  choices: exports_external.array(playableChoiceSchema)
99968
100023
  }).strict(),
99969
100024
  exports_external.object({
@@ -100007,6 +100062,7 @@ var init_assessment_artifact_runtime_util = __esm(() => {
100007
100062
  exports_external.object({
100008
100063
  ...playableInteractionBase,
100009
100064
  type: exports_external.literal("gap-match"),
100065
+ shuffle: exports_external.boolean(),
100010
100066
  content: exports_external.string(),
100011
100067
  contentNodes: playableContentNodesSchema,
100012
100068
  gapTexts: exports_external.array(playableGapTextSchema),
@@ -100345,7 +100401,6 @@ function buildConventionalAssessmentSnapshot(input) {
100345
100401
  purpose: metadata2.purpose,
100346
100402
  standards: [...metadata2.review.standards],
100347
100403
  candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
100348
- ...metadata2.review.requireBothQuestions ? { requireBothQuestions: true } : {},
100349
100404
  selections: [...metadata2.review.selections],
100350
100405
  fulfillment: metadata2.review.fulfillment
100351
100406
  };
@@ -100520,6 +100575,7 @@ function playableChoice(choice) {
100520
100575
  return {
100521
100576
  identifier: choice.identifier,
100522
100577
  content: choice.content,
100578
+ ...qtiAttributeBoolean(choice.attributes, "fixed") ? { fixed: true } : {},
100523
100579
  ...choice.contentNodes ? { contentNodes: choice.contentNodes } : {}
100524
100580
  };
100525
100581
  }
@@ -100582,6 +100638,7 @@ function parseInteraction2(questionIdentifier, interaction) {
100582
100638
  responseIdentifier,
100583
100639
  ...prompt,
100584
100640
  choices,
100641
+ shuffle: qtiAttributeBoolean(interaction.attributes, "shuffle"),
100585
100642
  minChoices: qtiAttributeNumber(interaction.attributes, "min-choices", 1),
100586
100643
  maxChoices: qtiAttributeMaximum(interaction.attributes, "max-choices", 1, choices.length)
100587
100644
  };
@@ -100591,7 +100648,8 @@ function parseInteraction2(questionIdentifier, interaction) {
100591
100648
  type,
100592
100649
  responseIdentifier,
100593
100650
  ...prompt,
100594
- choices: requireChoices2(interaction.choices.map(playableChoice))
100651
+ choices: requireChoices2(interaction.choices.map(playableChoice)),
100652
+ shuffle: qtiAttributeBoolean(interaction.attributes, "shuffle")
100595
100653
  };
100596
100654
  }
100597
100655
  if (type === "text-entry") {
@@ -100652,11 +100710,13 @@ function parseInteraction2(questionIdentifier, interaction) {
100652
100710
  responseIdentifier,
100653
100711
  ...prompt,
100654
100712
  content: interaction.textWithGaps,
100713
+ shuffle: qtiAttributeBoolean(interaction.attributes, "shuffle"),
100655
100714
  contentNodes: interaction.gapContent,
100656
100715
  gapTexts: interaction.gapTexts.map((gapText) => ({
100657
100716
  identifier: gapText.identifier,
100658
100717
  content: gapText.content,
100659
- matchMax: gapText.matchMax
100718
+ matchMax: gapText.matchMax,
100719
+ ...gapText.fixed ? { fixed: true } : {}
100660
100720
  })),
100661
100721
  gaps: [...interaction.gaps],
100662
100722
  maxAssociations: qtiAttributeNumber(interaction.attributes, "max-associations", 0)
@@ -101475,7 +101535,7 @@ function buildInlineChoiceQuestionXml(input, identifier, title) {
101475
101535
  if (!envelope) {
101476
101536
  return null;
101477
101537
  }
101478
- const { structure, responseIdentifier, prompt } = envelope;
101538
+ const { interaction, structure, responseIdentifier, prompt } = envelope;
101479
101539
  const promptParts = prompt.split(INLINE_CHOICE_BLANK);
101480
101540
  if (promptParts.length !== 2) {
101481
101541
  throw new ValidationError("Inline-choice questions require exactly one blank");
@@ -101489,7 +101549,7 @@ function buildInlineChoiceQuestionXml(input, identifier, title) {
101489
101549
  const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
101490
101550
  `);
101491
101551
  const [before = "", after = ""] = promptParts;
101492
- const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
101552
+ const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}" shuffle="${interaction.shuffle === true}">
101493
101553
  ${choicesXml}
101494
101554
  </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
101495
101555
  return buildExactMatchQuestionXml({
@@ -102104,7 +102164,7 @@ async function prepareReviewMappingUpdate(test, questions) {
102104
102164
  const contentRevision = await assessmentContentRevision(test, questions);
102105
102165
  const assessment = {
102106
102166
  identifier: test.identifier,
102107
- contractVersion: 1,
102167
+ contractVersion: PLAYCADEMY_ASSESSMENT_CONTRACT_VERSION,
102108
102168
  contentRevision,
102109
102169
  title: test.title,
102110
102170
  items: questions.questions.map(({ question }) => ({
@@ -102143,6 +102203,7 @@ async function prepareReviewMappingUpdate(test, questions) {
102143
102203
  var PLAYCADEMY_REVIEW_BANK_TITLE = "Review Bank", PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY = "playcademyReviewBankSink", PLAYCADEMY_REVIEW_BANK_SINK_VERSION = 1, StoredSinkWarningSchema, ReviewBankSinkMarkerSchema;
102144
102204
  var init_timeback_review_mapping_util = __esm(() => {
102145
102205
  init_esm();
102206
+ init_src();
102146
102207
  init_assessment_runtime2();
102147
102208
  init_qti();
102148
102209
  init_timeback3();
@@ -102817,13 +102878,11 @@ function buildPreparedAssessmentMetadata(plan, timestamp6, testName) {
102817
102878
  review: {
102818
102879
  requestFingerprint: reviewRequestFingerprint({
102819
102880
  standards: plan.review.standards,
102820
- candidateItemsPerStandard: plan.review.candidateItemsPerStandard,
102821
- requireBothQuestions: plan.review.requireBothQuestions
102881
+ candidateItemsPerStandard: plan.review.candidateItemsPerStandard
102822
102882
  }, DEFAULT_REVIEW_SELECTION_POLICY),
102823
102883
  bankRevision: plan.review.bankRevision,
102824
102884
  standards: plan.review.standards,
102825
102885
  candidateItemsPerStandard: plan.review.candidateItemsPerStandard,
102826
- ...plan.review.requireBothQuestions ? { requireBothQuestions: true } : {},
102827
102886
  selections: plan.review.selections,
102828
102887
  fulfillment: plan.review.fulfillment
102829
102888
  }
@@ -104006,7 +104065,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104006
104065
  bankRevision: catalog.bank.bankRevision,
104007
104066
  standards: [...normalized.standards],
104008
104067
  candidateItemsPerStandard: normalized.candidateItemsPerStandard,
104009
- ...normalized.requireBothQuestions ? { requireBothQuestions: true } : {},
104010
104068
  selections: [...selected.selections],
104011
104069
  fulfillment: selected.fulfillment
104012
104070
  }
@@ -104278,7 +104336,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104278
104336
  bankRevision: source.bank.bankRevision,
104279
104337
  standards: [...normalizedRequest.standards],
104280
104338
  candidateItemsPerStandard: normalizedRequest.candidateItemsPerStandard,
104281
- ...normalizedRequest.requireBothQuestions ? { requireBothQuestions: true } : {},
104282
104339
  selections: [...selected.selections],
104283
104340
  fulfillment: selected.fulfillment
104284
104341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.10.1-beta.2",
3
+ "version": "0.10.1-beta.4",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {