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

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 +573 -163
  2. package/dist/server.js +573 -163
  3. package/package.json +1 -1
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.10",
1125
+ version: "0.7.1-beta.11",
1126
1126
  description: "Local development server for Playcademy game development",
1127
1127
  type: "module",
1128
1128
  exports: {
@@ -9423,6 +9423,13 @@ function classifyAssessmentSubmission(attempt, submissionId) {
9423
9423
  }
9424
9424
  return isAssessmentAttemptOpen(attempt) ? "submit" : "reject";
9425
9425
  }
9426
+ function assessmentFlowViolation(message, details) {
9427
+ return {
9428
+ code: "ASSESSMENT_FLOW_VIOLATION",
9429
+ message,
9430
+ ...details ? { details } : {}
9431
+ };
9432
+ }
9426
9433
  function assessmentAttemptUnauthorized(attemptId) {
9427
9434
  return {
9428
9435
  code: "UNAUTHORIZED_ATTEMPT",
@@ -9464,139 +9471,8 @@ function assessmentReviewBankShortage(fulfillment) {
9464
9471
  details: { fulfillment }
9465
9472
  };
9466
9473
  }
9467
- function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
9468
- if (candidates.length === 0) {
9469
- return { outcome: "none" };
9470
- }
9471
- const serviceable = candidates.filter((candidate) => candidate.hasLiveTests || candidate.hasOpenAttempt);
9472
- if (serviceable.length === 0) {
9473
- return { outcome: "no_eligible_tests" };
9474
- }
9475
- const withOpenAttempt = serviceable.filter((candidate) => candidate.hasOpenAttempt);
9476
- const eligible = withOpenAttempt.length > 0 ? withOpenAttempt : serviceable;
9477
- const subjects = new Set(eligible.map((candidate) => candidate.subject)).size;
9478
- if (!subjectSpecified && subjects > 1) {
9479
- return { outcome: "ambiguous", subjects };
9480
- }
9481
- const selected = [...eligible].toSorted((left, right) => left.grade - right.grade || left.id.localeCompare(right.id))[0];
9482
- return { outcome: "selected", selected };
9483
- }
9484
- function assessmentAttemptTimestamp(value) {
9485
- const parsed = Date.parse(value);
9486
- return Number.isNaN(parsed) ? 0 : parsed;
9487
- }
9488
- function newestAttempt(left, right, field) {
9489
- const dateDifference = assessmentAttemptTimestamp(right[field]) - assessmentAttemptTimestamp(left[field]);
9490
- return dateDifference || left.attemptId.localeCompare(right.attemptId);
9491
- }
9492
- function compareNewestCompletedAssessmentAttempt(left, right) {
9493
- return newestAttempt(left, right, "scoreDate");
9494
- }
9495
- function compareAssessmentCatalogOrder(left, right) {
9496
- if (left.sortOrder !== null || right.sortOrder !== null) {
9497
- if (left.sortOrder === null) {
9498
- return 1;
9499
- }
9500
- if (right.sortOrder === null) {
9501
- return -1;
9502
- }
9503
- const orderDifference = left.sortOrder - right.sortOrder;
9504
- if (orderDifference) {
9505
- return orderDifference;
9506
- }
9507
- }
9508
- const leftUpdatedAt = Date.parse(left.updatedAt);
9509
- const rightUpdatedAt = Date.parse(right.updatedAt);
9510
- const leftHasValidDate = Number.isFinite(leftUpdatedAt);
9511
- const rightHasValidDate = Number.isFinite(rightUpdatedAt);
9512
- if (leftHasValidDate && rightHasValidDate) {
9513
- const updatedDifference = leftUpdatedAt - rightUpdatedAt;
9514
- if (updatedDifference) {
9515
- return updatedDifference;
9516
- }
9517
- } else if (leftHasValidDate !== rightHasValidDate) {
9518
- return leftHasValidDate ? -1 : 1;
9519
- }
9520
- return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
9521
- }
9522
- function orderRuntimeAssessmentTests(tests) {
9523
- return [...tests].toSorted(compareAssessmentCatalogOrder);
9524
- }
9525
- function selectRuntimeAssessment(liveTests, attempts) {
9526
- const resumable = attempts.filter((attempt) => attempt.resumable !== false && isAssessmentAttemptOpen(attempt)).toSorted((left, right) => newestAttempt(left, right, "updatedAt"));
9527
- if (resumable.length > 0) {
9528
- return {
9529
- kind: "resume",
9530
- attempt: resumable[0],
9531
- additionalResumableAttemptIds: resumable.slice(1).map((candidate) => candidate.attemptId)
9532
- };
9533
- }
9534
- const orderedTests = orderRuntimeAssessmentTests(liveTests);
9535
- if (orderedTests.length === 0) {
9536
- return null;
9537
- }
9538
- const completed = attempts.filter(isAssessmentAttemptCompleted);
9539
- const exposedTestIds = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedTestIdentifier));
9540
- const unattempted = orderedTests.find((test) => !exposedTestIds.has(test.qtiTestIdentifier));
9541
- if (unattempted) {
9542
- return { kind: "start", test: unattempted, reason: "unattempted" };
9543
- }
9544
- const latestCompleted = completed.toSorted(compareNewestCompletedAssessmentAttempt)[0];
9545
- const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.qtiTestIdentifier === latestCompleted.selectedTestIdentifier) : -1;
9546
- if (latestTestIndex === -1) {
9547
- return { kind: "start", test: orderedTests[0], reason: "first_live" };
9548
- }
9549
- return {
9550
- kind: "start",
9551
- test: orderedTests[(latestTestIndex + 1) % orderedTests.length],
9552
- reason: "round_robin"
9553
- };
9554
- }
9555
- function presentationHash(value) {
9556
- let hash = 2166136261;
9557
- for (let index = 0;index < value.length; index++) {
9558
- hash ^= value.charCodeAt(index);
9559
- hash = Math.imul(hash, 16777619);
9560
- }
9561
- return hash >>> 0;
9562
- }
9563
- function comparePresentationText(left, right) {
9564
- if (left < right) {
9565
- return -1;
9566
- }
9567
- if (left > right) {
9568
- return 1;
9569
- }
9570
- return 0;
9571
- }
9572
- function presentationChoices(choices, attemptId, itemIdentifier, domain) {
9573
- const seed = `${attemptId}\x00${itemIdentifier}\x00${domain}\x00`;
9574
- return [...choices].toSorted((left, right) => {
9575
- const hashDifference = presentationHash(`${seed}${left.identifier}`) - presentationHash(`${seed}${right.identifier}`);
9576
- return hashDifference || comparePresentationText(left.identifier, right.identifier);
9577
- });
9578
- }
9579
- function assessmentPresentationForAttempt(assessment, attemptId) {
9580
- const items = assessment.items.map((item) => {
9581
- const interactions = item.interactions.map((interaction) => {
9582
- if (interaction.type === "order" && interaction.shuffle) {
9583
- return {
9584
- ...interaction,
9585
- choices: presentationChoices(interaction.choices, attemptId, item.identifier, `order\x00${interaction.responseIdentifier}`)
9586
- };
9587
- }
9588
- if (interaction.type === "match" && interaction.shuffle) {
9589
- return {
9590
- ...interaction,
9591
- sourceChoices: presentationChoices(interaction.sourceChoices, attemptId, item.identifier, `match-source\x00${interaction.responseIdentifier}`),
9592
- targetChoices: presentationChoices(interaction.targetChoices, attemptId, item.identifier, `match-target\x00${interaction.responseIdentifier}`)
9593
- };
9594
- }
9595
- return interaction;
9596
- });
9597
- return { ...item, interactions };
9598
- });
9599
- return { ...assessment, items };
9474
+ function assessmentFlowForPurpose(purpose) {
9475
+ return purpose === "review" || purpose === "mastery" ? "item-submit" : "attempt-submit";
9600
9476
  }
9601
9477
  function isTimebackGrade(value) {
9602
9478
  return typeof value === "number" && Number.isInteger(value) && GRADE_VALUES.includes(value);
@@ -9608,18 +9484,22 @@ function isAssessmentResponseValue(value) {
9608
9484
  const entries = Array.isArray(value) ? value : [value];
9609
9485
  return entries.length > 0 && entries.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= TIMEBACK_ASSESSMENT_RESPONSE_VALUE_MAX_LENGTH);
9610
9486
  }
9487
+ function applyAssessmentItemResponseUpdate(current, update) {
9488
+ const next = { ...current };
9489
+ for (const [responseId, value] of Object.entries(update)) {
9490
+ if (value === null) {
9491
+ delete next[responseId];
9492
+ } else {
9493
+ next[responseId] = value;
9494
+ }
9495
+ }
9496
+ return Object.keys(next).length === 0 ? undefined : next;
9497
+ }
9611
9498
  function applyAssessmentResponseUpdate(current, update) {
9612
9499
  const next = structuredClone(current);
9613
9500
  for (const [questionId, questionUpdate] of Object.entries(update)) {
9614
- const questionResponses = { ...next[questionId] };
9615
- for (const [responseId, value] of Object.entries(questionUpdate)) {
9616
- if (value === null) {
9617
- delete questionResponses[responseId];
9618
- } else {
9619
- questionResponses[responseId] = value;
9620
- }
9621
- }
9622
- if (Object.keys(questionResponses).length === 0) {
9501
+ const questionResponses = applyAssessmentItemResponseUpdate(next[questionId], questionUpdate);
9502
+ if (questionResponses === undefined) {
9623
9503
  delete next[questionId];
9624
9504
  } else {
9625
9505
  next[questionId] = questionResponses;
@@ -9780,6 +9660,256 @@ function interactionResponseValidationMessage(itemIdentifier, interaction, respo
9780
9660
  }
9781
9661
  return null;
9782
9662
  }
9663
+ function invalidResponse(message) {
9664
+ return { code: "INVALID_RESPONSE", message };
9665
+ }
9666
+ function responseValuesEqual(left, right) {
9667
+ if (left === undefined || right === undefined) {
9668
+ return left === right;
9669
+ }
9670
+ if (Array.isArray(left) || Array.isArray(right)) {
9671
+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]);
9672
+ }
9673
+ return left === right;
9674
+ }
9675
+ function itemResponsesEqual(left, right) {
9676
+ const leftEntries = Object.entries(left ?? {});
9677
+ const rightEntries = Object.entries(right ?? {});
9678
+ return leftEntries.length === rightEntries.length && leftEntries.every(([identifier, value]) => responseValuesEqual(value, right?.[identifier]));
9679
+ }
9680
+ function prepareAssessmentItemSubmission(state, input) {
9681
+ const flow = assessmentFlowForPurpose(state.purpose);
9682
+ if (flow !== "item-submit") {
9683
+ return {
9684
+ action: "reject",
9685
+ failure: assessmentFlowViolation("Items cannot be submitted individually in this flow.", { flow })
9686
+ };
9687
+ }
9688
+ const priorSubmission = state.itemSubmissions.find((submission) => submission.submissionId === input.submissionId);
9689
+ if (priorSubmission) {
9690
+ const replayedItemResponses = applyAssessmentItemResponseUpdate(state.responses[input.itemIdentifier], input.responses);
9691
+ const sameRequest = priorSubmission.itemIdentifier === input.itemIdentifier && itemResponsesEqual(state.responses[input.itemIdentifier], replayedItemResponses);
9692
+ if (!sameRequest) {
9693
+ return {
9694
+ action: "reject",
9695
+ failure: assessmentFlowViolation("This item submission ID was already used for a different request.", {
9696
+ submissionId: input.submissionId,
9697
+ itemIdentifier: input.itemIdentifier,
9698
+ committedItemIdentifier: priorSubmission.itemIdentifier,
9699
+ flow
9700
+ })
9701
+ };
9702
+ }
9703
+ return {
9704
+ action: "replay",
9705
+ responseVersion: state.responseVersion,
9706
+ responses: state.responses,
9707
+ submission: priorSubmission
9708
+ };
9709
+ }
9710
+ if (state.responseVersion !== input.expectedResponseVersion) {
9711
+ return {
9712
+ action: "reject",
9713
+ failure: assessmentResponseVersionConflict(input.expectedResponseVersion, state.responseVersion)
9714
+ };
9715
+ }
9716
+ const committed = new Set(state.itemSubmissions.map((submission) => submission.itemIdentifier));
9717
+ const itemIndex = state.assessment.items.findIndex((item) => !committed.has(item.identifier));
9718
+ const nextItem = state.assessment.items[itemIndex];
9719
+ if (nextItem?.identifier !== input.itemIdentifier) {
9720
+ return {
9721
+ action: "reject",
9722
+ failure: assessmentFlowViolation("Items must be submitted once and in presentation order.", {
9723
+ itemIdentifier: input.itemIdentifier,
9724
+ expectedItemIdentifier: nextItem?.identifier ?? null,
9725
+ flow
9726
+ })
9727
+ };
9728
+ }
9729
+ const update = { [input.itemIdentifier]: input.responses };
9730
+ const updateMessage = assessmentResponseUpdateValidationMessage(state.assessment, update);
9731
+ if (updateMessage) {
9732
+ return { action: "reject", failure: invalidResponse(updateMessage) };
9733
+ }
9734
+ const responses = applyAssessmentResponseUpdate(state.responses, update);
9735
+ const validationMessage = assessmentResponseValidationMessage(state.assessment, responses);
9736
+ if (validationMessage) {
9737
+ return { action: "reject", failure: invalidResponse(validationMessage) };
9738
+ }
9739
+ return {
9740
+ action: "commit",
9741
+ itemIndex,
9742
+ responseVersion: state.responseVersion + 1,
9743
+ responses,
9744
+ answered: Object.keys(responses[input.itemIdentifier] ?? {}).length > 0
9745
+ };
9746
+ }
9747
+ function completeAssessmentItemSubmission(state, input, prepared, scoring) {
9748
+ const submission = {
9749
+ submissionId: input.submissionId,
9750
+ itemIdentifier: input.itemIdentifier,
9751
+ responseVersion: prepared.responseVersion,
9752
+ answered: prepared.answered,
9753
+ score: scoring.score,
9754
+ isCorrect: scoring.isCorrect
9755
+ };
9756
+ return {
9757
+ responseVersion: prepared.responseVersion,
9758
+ responses: prepared.responses,
9759
+ itemSubmissions: [...state.itemSubmissions, submission],
9760
+ submission
9761
+ };
9762
+ }
9763
+ function assessmentResponseUpdateFlowFailure(purpose, itemSubmissions, update) {
9764
+ const committed = new Set(itemSubmissions.map((submission) => submission.itemIdentifier));
9765
+ const committedUpdate = Object.keys(update).find((identifier) => committed.has(identifier));
9766
+ return committedUpdate ? assessmentFlowViolation("A submitted item response cannot be edited.", {
9767
+ itemIdentifier: committedUpdate,
9768
+ flow: assessmentFlowForPurpose(purpose)
9769
+ }) : null;
9770
+ }
9771
+ function assessmentCompletionFlowFailure(purpose, itemSubmissions, itemCount) {
9772
+ const flow = assessmentFlowForPurpose(purpose);
9773
+ return flow === "item-submit" && itemSubmissions.length !== itemCount ? assessmentFlowViolation("Every item must be submitted before the attempt can be completed.", {
9774
+ flow,
9775
+ submittedItemCount: itemSubmissions.length,
9776
+ itemCount
9777
+ }) : null;
9778
+ }
9779
+ function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
9780
+ if (candidates.length === 0) {
9781
+ return { outcome: "none" };
9782
+ }
9783
+ const serviceable = candidates.filter((candidate) => candidate.hasLiveTests || candidate.hasOpenAttempt);
9784
+ if (serviceable.length === 0) {
9785
+ return { outcome: "no_eligible_tests" };
9786
+ }
9787
+ const withOpenAttempt = serviceable.filter((candidate) => candidate.hasOpenAttempt);
9788
+ const eligible = withOpenAttempt.length > 0 ? withOpenAttempt : serviceable;
9789
+ const subjects = new Set(eligible.map((candidate) => candidate.subject)).size;
9790
+ if (!subjectSpecified && subjects > 1) {
9791
+ return { outcome: "ambiguous", subjects };
9792
+ }
9793
+ const selected = [...eligible].toSorted((left, right) => left.grade - right.grade || left.id.localeCompare(right.id))[0];
9794
+ return { outcome: "selected", selected };
9795
+ }
9796
+ function assessmentAttemptTimestamp(value) {
9797
+ const parsed = Date.parse(value);
9798
+ return Number.isNaN(parsed) ? 0 : parsed;
9799
+ }
9800
+ function newestAttempt(left, right, field) {
9801
+ const dateDifference = assessmentAttemptTimestamp(right[field]) - assessmentAttemptTimestamp(left[field]);
9802
+ return dateDifference || left.attemptId.localeCompare(right.attemptId);
9803
+ }
9804
+ function compareNewestCompletedAssessmentAttempt(left, right) {
9805
+ return newestAttempt(left, right, "scoreDate");
9806
+ }
9807
+ function compareAssessmentCatalogOrder(left, right) {
9808
+ if (left.sortOrder !== null || right.sortOrder !== null) {
9809
+ if (left.sortOrder === null) {
9810
+ return 1;
9811
+ }
9812
+ if (right.sortOrder === null) {
9813
+ return -1;
9814
+ }
9815
+ const orderDifference = left.sortOrder - right.sortOrder;
9816
+ if (orderDifference) {
9817
+ return orderDifference;
9818
+ }
9819
+ }
9820
+ const leftUpdatedAt = Date.parse(left.updatedAt);
9821
+ const rightUpdatedAt = Date.parse(right.updatedAt);
9822
+ const leftHasValidDate = Number.isFinite(leftUpdatedAt);
9823
+ const rightHasValidDate = Number.isFinite(rightUpdatedAt);
9824
+ if (leftHasValidDate && rightHasValidDate) {
9825
+ const updatedDifference = leftUpdatedAt - rightUpdatedAt;
9826
+ if (updatedDifference) {
9827
+ return updatedDifference;
9828
+ }
9829
+ } else if (leftHasValidDate !== rightHasValidDate) {
9830
+ return leftHasValidDate ? -1 : 1;
9831
+ }
9832
+ return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
9833
+ }
9834
+ function orderRuntimeAssessmentTests(tests) {
9835
+ return [...tests].toSorted(compareAssessmentCatalogOrder);
9836
+ }
9837
+ function selectRuntimeAssessment(liveTests, attempts) {
9838
+ const resumable = attempts.filter((attempt) => attempt.resumable !== false && isAssessmentAttemptOpen(attempt)).toSorted((left, right) => newestAttempt(left, right, "updatedAt"));
9839
+ if (resumable.length > 0) {
9840
+ return {
9841
+ kind: "resume",
9842
+ attempt: resumable[0],
9843
+ additionalResumableAttemptIds: resumable.slice(1).map((candidate) => candidate.attemptId)
9844
+ };
9845
+ }
9846
+ const orderedTests = orderRuntimeAssessmentTests(liveTests);
9847
+ if (orderedTests.length === 0) {
9848
+ return null;
9849
+ }
9850
+ const completed = attempts.filter(isAssessmentAttemptCompleted);
9851
+ const exposedTestIds = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedTestIdentifier));
9852
+ const unattempted = orderedTests.find((test) => !exposedTestIds.has(test.qtiTestIdentifier));
9853
+ if (unattempted) {
9854
+ return { kind: "start", test: unattempted, reason: "unattempted" };
9855
+ }
9856
+ const latestCompleted = completed.toSorted(compareNewestCompletedAssessmentAttempt)[0];
9857
+ const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.qtiTestIdentifier === latestCompleted.selectedTestIdentifier) : -1;
9858
+ if (latestTestIndex === -1) {
9859
+ return { kind: "start", test: orderedTests[0], reason: "first_live" };
9860
+ }
9861
+ return {
9862
+ kind: "start",
9863
+ test: orderedTests[(latestTestIndex + 1) % orderedTests.length],
9864
+ reason: "round_robin"
9865
+ };
9866
+ }
9867
+ function presentationHash(value) {
9868
+ let hash = 2166136261;
9869
+ for (let index = 0;index < value.length; index++) {
9870
+ hash ^= value.charCodeAt(index);
9871
+ hash = Math.imul(hash, 16777619);
9872
+ }
9873
+ return hash >>> 0;
9874
+ }
9875
+ function comparePresentationText(left, right) {
9876
+ if (left < right) {
9877
+ return -1;
9878
+ }
9879
+ if (left > right) {
9880
+ return 1;
9881
+ }
9882
+ return 0;
9883
+ }
9884
+ function presentationChoices(choices, attemptId, itemIdentifier, domain) {
9885
+ const seed = `${attemptId}\x00${itemIdentifier}\x00${domain}\x00`;
9886
+ return [...choices].toSorted((left, right) => {
9887
+ const hashDifference = presentationHash(`${seed}${left.identifier}`) - presentationHash(`${seed}${right.identifier}`);
9888
+ return hashDifference || comparePresentationText(left.identifier, right.identifier);
9889
+ });
9890
+ }
9891
+ function assessmentPresentationForAttempt(assessment, attemptId) {
9892
+ const items = assessment.items.map((item) => {
9893
+ const interactions = item.interactions.map((interaction) => {
9894
+ if (interaction.type === "order" && interaction.shuffle) {
9895
+ return {
9896
+ ...interaction,
9897
+ choices: presentationChoices(interaction.choices, attemptId, item.identifier, `order\x00${interaction.responseIdentifier}`)
9898
+ };
9899
+ }
9900
+ if (interaction.type === "match" && interaction.shuffle) {
9901
+ return {
9902
+ ...interaction,
9903
+ sourceChoices: presentationChoices(interaction.sourceChoices, attemptId, item.identifier, `match-source\x00${interaction.responseIdentifier}`),
9904
+ targetChoices: presentationChoices(interaction.targetChoices, attemptId, item.identifier, `match-target\x00${interaction.responseIdentifier}`)
9905
+ };
9906
+ }
9907
+ return interaction;
9908
+ });
9909
+ return { ...item, interactions };
9910
+ });
9911
+ return { ...assessment, items };
9912
+ }
9783
9913
  function compareCodeUnits(left, right) {
9784
9914
  if (left < right) {
9785
9915
  return -1;
@@ -10171,6 +10301,9 @@ function isReviewFulfillment(value) {
10171
10301
  function isReviewItemOutcome(value) {
10172
10302
  return isRecord(value) && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && isAssessmentStandardRef(value.standard) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10173
10303
  }
10304
+ function isAssessmentItemSubmission(value) {
10305
+ return isRecord(value) && typeof value.submissionId === "string" && value.submissionId.length > 0 && typeof value.itemIdentifier === "string" && value.itemIdentifier.length > 0 && Number.isInteger(value.responseVersion) && typeof value.answered === "boolean" && isAssessmentScore(value.score) && (value.isCorrect === null || typeof value.isCorrect === "boolean");
10306
+ }
10174
10307
  function isReviewAttemptMetadata(value) {
10175
10308
  return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.itemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
10176
10309
  }
@@ -10185,7 +10318,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
10185
10318
  const selectedTest = metadata2.selectedTest;
10186
10319
  const mastery = metadata2.mastery;
10187
10320
  const review = metadata2.review;
10188
- return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && (metadata2.completion === undefined || isRecord(metadata2.completion) && typeof metadata2.completion.testName === "string" && Number.isInteger(metadata2.completion.correctQuestions) && (metadata2.completion.totalQuestions === undefined || Number.isInteger(metadata2.completion.totalQuestions) && metadata2.completion.totalQuestions >= 0) && (metadata2.completion.itemOutcomes === undefined || metadata2.purpose === "review" && Array.isArray(metadata2.completion.itemOutcomes) && metadata2.completion.itemOutcomes.every(isReviewItemOutcome)));
10321
+ return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && (metadata2.completion === undefined || isRecord(metadata2.completion) && typeof metadata2.completion.testName === "string" && Number.isInteger(metadata2.completion.correctQuestions) && (metadata2.completion.totalQuestions === undefined || Number.isInteger(metadata2.completion.totalQuestions) && metadata2.completion.totalQuestions >= 0) && (metadata2.completion.itemOutcomes === undefined || metadata2.purpose === "review" && Array.isArray(metadata2.completion.itemOutcomes) && metadata2.completion.itemOutcomes.every(isReviewItemOutcome)));
10189
10322
  }
10190
10323
  function isPlaycademyAssessmentItemResultMetadataV1(value) {
10191
10324
  if (!isRecord(value) || !isRecord(value.responses)) {
@@ -10202,6 +10335,9 @@ function playcademyAssessmentResultMetadata(value) {
10202
10335
  return null;
10203
10336
  }
10204
10337
  let normalized = metadata2.responses === undefined ? { ...metadata2, responses: {} } : metadata2;
10338
+ if (normalized.itemSubmissions === undefined) {
10339
+ normalized = { ...normalized, itemSubmissions: [] };
10340
+ }
10205
10341
  const review = normalized.review;
10206
10342
  if (isRecord(review) && isRecord(review.fulfillment) && review.fulfillment.status === "complete" && review.fulfillment.shortages === undefined) {
10207
10343
  normalized = {
@@ -10228,7 +10364,7 @@ function playcademyAssessmentItemResultMetadata(value) {
10228
10364
  const normalized = metadata2.responses === undefined ? { ...metadata2, responses: {} } : metadata2;
10229
10365
  return isPlaycademyAssessmentItemResultMetadataV1(normalized) ? normalized : null;
10230
10366
  }
10231
- var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "￿", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_RUNTIME_ERROR_STATUS, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", DEFAULT_REVIEW_SELECTION_POLICY, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitRuntimeAssessmentRequestSchema;
10367
+ var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "￿", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_RUNTIME_ERROR_STATUS, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", DEFAULT_REVIEW_SELECTION_POLICY, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema;
10232
10368
  var init_assessment_runtime = __esm(() => {
10233
10369
  init_timeback3();
10234
10370
  init_timeback3();
@@ -10416,7 +10552,8 @@ var init_assessment_runtime = __esm(() => {
10416
10552
  RESPONSE_VERSION_CONFLICT: 409,
10417
10553
  INVALID_RESPONSE: 422,
10418
10554
  UNAUTHORIZED_ATTEMPT: 403,
10419
- ATTEMPT_ALREADY_SUBMITTED: 409
10555
+ ATTEMPT_ALREADY_SUBMITTED: 409,
10556
+ ASSESSMENT_FLOW_VIOLATION: 409
10420
10557
  };
10421
10558
  GRADE_VALUES = TIMEBACK_GRADES;
10422
10559
  POINT_RESPONSE_PATTERN = /^-?\d+(?:\.\d+)? -?\d+(?:\.\d+)?$/;
@@ -10487,12 +10624,19 @@ var init_assessment_runtime = __esm(() => {
10487
10624
  expectedResponseVersion: exports_external.number().int().nonnegative(),
10488
10625
  responses: exports_external.record(ResponseKeySchema, exports_external.record(ResponseKeySchema, AssessmentResponseValueSchema.nullable()))
10489
10626
  });
10627
+ SubmitAssessmentItemBodySchema = exports_external.object({
10628
+ expectedResponseVersion: exports_external.number().int().nonnegative(),
10629
+ submissionId: exports_external.string().trim().min(1),
10630
+ itemIdentifier: ResponseKeySchema,
10631
+ responses: exports_external.record(ResponseKeySchema, AssessmentResponseValueSchema.nullable())
10632
+ });
10490
10633
  SubmitAssessmentBodySchema = exports_external.object({
10491
10634
  expectedResponseVersion: exports_external.number().int().nonnegative(),
10492
10635
  submissionId: exports_external.string().trim().min(1)
10493
10636
  });
10494
10637
  StartRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.and(StartAssessmentBodySchema);
10495
10638
  SaveRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(SaveAssessmentBodySchema.shape);
10639
+ SubmitAssessmentItemRuntimeRequestSchema = AssessmentRuntimeIdentitySchema.extend(SubmitAssessmentItemBodySchema.shape);
10496
10640
  SubmitRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(SubmitAssessmentBodySchema.shape).extend({
10497
10641
  appName: exports_external.string().trim().min(1),
10498
10642
  sensorUrl: exports_external.string().url()
@@ -91167,6 +91311,9 @@ class TimebackAssessmentRuntimeService {
91167
91311
  static ASSESSMENT_CACHE_LIMIT = 32;
91168
91312
  static ASSESSMENT_CACHE_TTL_MS = 60000;
91169
91313
  static EXPORT_CONCURRENCY = 4;
91314
+ static ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS = 3;
91315
+ static PROJECTION_BARRIER_MAX_ATTEMPTS = 5;
91316
+ static PROJECTION_BARRIER_RETRY_DELAY_MS = 25;
91170
91317
  static SCORING_CONCURRENCY = 4;
91171
91318
  deps;
91172
91319
  assessmentCache = new TimebackCache({
@@ -91259,6 +91406,7 @@ class TimebackAssessmentRuntimeService {
91259
91406
  attemptNumber,
91260
91407
  responseVersion: 0,
91261
91408
  responses: {},
91409
+ itemSubmissions: [],
91262
91410
  startedAt: timestamp6,
91263
91411
  updatedAt: timestamp6
91264
91412
  };
@@ -91384,6 +91532,7 @@ class TimebackAssessmentRuntimeService {
91384
91532
  attemptNumber,
91385
91533
  responseVersion: 0,
91386
91534
  responses: {},
91535
+ itemSubmissions: [],
91387
91536
  startedAt: timestamp6,
91388
91537
  updatedAt: timestamp6,
91389
91538
  review: {
@@ -91508,6 +91657,10 @@ class TimebackAssessmentRuntimeService {
91508
91657
  this.assertInProgress(attempt.result);
91509
91658
  this.assertResponseVersion(attempt.metadata, input.expectedResponseVersion);
91510
91659
  const assessment = await this.loadAttemptAssessment(attempt.metadata);
91660
+ const flowFailure = assessmentResponseUpdateFlowFailure(attempt.metadata.purpose, attempt.metadata.itemSubmissions, input.responses);
91661
+ if (flowFailure) {
91662
+ throw AssessmentRuntimeError.from(flowFailure);
91663
+ }
91511
91664
  validateAssessmentResponseUpdate(assessment, input.responses);
91512
91665
  const responses = applyAssessmentResponseUpdate(attempt.metadata.responses, input.responses);
91513
91666
  validateAssessmentResponses(assessment, responses);
@@ -91529,11 +91682,105 @@ class TimebackAssessmentRuntimeService {
91529
91682
  };
91530
91683
  });
91531
91684
  }
91532
- async scoreSubmission(attempt) {
91685
+ async submitItem({
91686
+ input,
91687
+ ...params
91688
+ }) {
91689
+ await this.deps.validateDeveloperAccess(params.user, params.gameId);
91690
+ let preview = await this.prepareItemSubmission(params, input);
91691
+ let preparationAttempts = 1;
91692
+ while (true) {
91693
+ const committed = await this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(params.attemptId), async (db2) => {
91694
+ const attempt = await this.authorizeAttempt(params, {
91695
+ db: db2,
91696
+ developerAccessValidated: true
91697
+ });
91698
+ this.assertInProgress(attempt.result);
91699
+ if (!preview || preview.responseVersion !== attempt.metadata.responseVersion || preview.submissionId !== input.submissionId || preview.itemIdentifier !== input.itemIdentifier) {
91700
+ if (preparationAttempts >= TimebackAssessmentRuntimeService.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
91701
+ if (input.expectedResponseVersion === attempt.metadata.responseVersion) {
91702
+ throw new ServiceUnavailableError("The assessment response state changed repeatedly while this item was being prepared. Try again shortly.", {
91703
+ retryable: true,
91704
+ reason: "PREPARATION_CONTENTION",
91705
+ expectedResponseVersion: input.expectedResponseVersion,
91706
+ responseVersion: attempt.metadata.responseVersion
91707
+ });
91708
+ }
91709
+ throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, attempt.metadata.responseVersion));
91710
+ }
91711
+ return { action: "prepare" };
91712
+ }
91713
+ const transition = prepareAssessmentItemSubmission({
91714
+ purpose: attempt.metadata.purpose,
91715
+ responseVersion: attempt.metadata.responseVersion,
91716
+ responses: attempt.metadata.responses,
91717
+ itemSubmissions: attempt.metadata.itemSubmissions,
91718
+ assessment: preview.assessment
91719
+ }, input);
91720
+ if (transition.action === "reject") {
91721
+ throw AssessmentRuntimeError.from(transition.failure);
91722
+ }
91723
+ if (transition.action === "replay") {
91724
+ return {
91725
+ action: "complete",
91726
+ response: {
91727
+ attemptId: attempt.result.sourcedId,
91728
+ responseVersion: transition.responseVersion,
91729
+ status: "in_progress",
91730
+ responses: transition.responses,
91731
+ itemSubmissions: attempt.metadata.itemSubmissions,
91732
+ submission: transition.submission
91733
+ },
91734
+ projection: attempt.metadata.purpose === "review" ? {
91735
+ result: attempt.result,
91736
+ metadata: attempt.metadata,
91737
+ itemIdentifier: input.itemIdentifier
91738
+ } : null
91739
+ };
91740
+ }
91741
+ if (!preview.scoring) {
91742
+ throw new Error("Prepared item submission is missing its score");
91743
+ }
91744
+ const completed = completeAssessmentItemSubmission(attempt.metadata, input, transition, preview.scoring);
91745
+ const metadata2 = {
91746
+ ...attempt.metadata,
91747
+ responses: completed.responses,
91748
+ responseVersion: completed.responseVersion,
91749
+ itemSubmissions: completed.itemSubmissions,
91750
+ updatedAt: new Date().toISOString()
91751
+ };
91752
+ await this.putResultMetadata(attempt.result, metadata2);
91753
+ return {
91754
+ action: "complete",
91755
+ response: {
91756
+ attemptId: attempt.result.sourcedId,
91757
+ responseVersion: completed.responseVersion,
91758
+ status: "in_progress",
91759
+ responses: completed.responses,
91760
+ itemSubmissions: completed.itemSubmissions,
91761
+ submission: completed.submission
91762
+ },
91763
+ projection: metadata2.purpose === "review" ? {
91764
+ result: attempt.result,
91765
+ metadata: metadata2,
91766
+ itemIdentifier: input.itemIdentifier
91767
+ } : null
91768
+ };
91769
+ });
91770
+ if (committed.action === "prepare") {
91771
+ preparationAttempts += 1;
91772
+ preview = await this.scoreItemSubmission(params, input);
91773
+ } else {
91774
+ if (committed.projection) {
91775
+ await this.projectReviewItemResponse(params, committed.projection);
91776
+ }
91777
+ return committed.response;
91778
+ }
91779
+ }
91780
+ }
91781
+ async scoreItems(assessment, responses, itemIdentifiers) {
91533
91782
  const client2 = this.requireClient();
91534
- const assessment = await this.loadAttemptAssessment(attempt.metadata);
91535
- validateAssessmentResponses(assessment, attempt.metadata.responses);
91536
- const scoringItems = assessmentScoringRequests(assessment, attempt.metadata.responses);
91783
+ const scoringItems = assessmentScoringRequests(assessment, responses).filter((item) => !itemIdentifiers || itemIdentifiers.has(item.itemIdentifier));
91537
91784
  const scoringTasks = scoringItems.flatMap((item, itemIndex) => item.requests.map((request) => ({
91538
91785
  itemIndex,
91539
91786
  itemIdentifier: item.itemIdentifier,
@@ -91547,7 +91794,7 @@ class TimebackAssessmentRuntimeService {
91547
91794
  scoresByItem[task.itemIndex].push(Number.isFinite(processed.score) ? processed.score : 0);
91548
91795
  verdictsByItem[task.itemIndex].push(processed.isCorrect);
91549
91796
  });
91550
- const itemResults = scoringItems.map((item, itemIndex) => {
91797
+ return scoringItems.map((item, itemIndex) => {
91551
91798
  const earned = assessmentItemEarnedScore(scoresByItem[itemIndex], item.maxScore);
91552
91799
  const possible = item.maxScore;
91553
91800
  return {
@@ -91560,6 +91807,52 @@ class TimebackAssessmentRuntimeService {
91560
91807
  isCorrect: assessmentItemCorrectness(verdictsByItem[itemIndex])
91561
91808
  };
91562
91809
  });
91810
+ }
91811
+ async scoreItem(assessment, responses, itemIdentifier) {
91812
+ const [scoring] = await this.scoreItems(assessment, responses, new Set([itemIdentifier]));
91813
+ return scoring;
91814
+ }
91815
+ async prepareItemSubmission(params, input) {
91816
+ try {
91817
+ return await this.scoreItemSubmission(params, input);
91818
+ } catch (error88) {
91819
+ addEvent("assessment.item_submission_preview_failed", {
91820
+ "app.assessment.attempt_id": params.attemptId,
91821
+ "exception.type": errorType(error88),
91822
+ "app.error.message": errorMessage(error88)
91823
+ });
91824
+ return null;
91825
+ }
91826
+ }
91827
+ async scoreItemSubmission(params, input) {
91828
+ const attempt = await this.peekAttempt(params);
91829
+ if (!isAssessmentAttemptOpen({
91830
+ inProgress: attempt.result.inProgress ?? "",
91831
+ scoreStatus: attempt.result.scoreStatus
91832
+ })) {
91833
+ return null;
91834
+ }
91835
+ const assessment = await this.loadAttemptAssessment(attempt.metadata);
91836
+ const transition = prepareAssessmentItemSubmission({
91837
+ purpose: attempt.metadata.purpose,
91838
+ responseVersion: attempt.metadata.responseVersion,
91839
+ responses: attempt.metadata.responses,
91840
+ itemSubmissions: attempt.metadata.itemSubmissions,
91841
+ assessment
91842
+ }, input);
91843
+ const scoring = transition.action === "commit" ? await this.scoreItem(assessment, transition.responses, input.itemIdentifier) : null;
91844
+ return {
91845
+ responseVersion: attempt.metadata.responseVersion,
91846
+ submissionId: input.submissionId,
91847
+ itemIdentifier: input.itemIdentifier,
91848
+ assessment,
91849
+ scoring
91850
+ };
91851
+ }
91852
+ async scoreSubmission(attempt) {
91853
+ const assessment = await this.loadAttemptAssessment(attempt.metadata);
91854
+ validateAssessmentResponses(assessment, attempt.metadata.responses);
91855
+ const itemResults = await this.scoreItems(assessment, attempt.metadata.responses);
91563
91856
  return {
91564
91857
  responseVersion: attempt.metadata.responseVersion,
91565
91858
  assessment,
@@ -91629,6 +91922,13 @@ class TimebackAssessmentRuntimeService {
91629
91922
  throw AssessmentRuntimeError.from(assessmentAlreadySubmitted(attempt.result.sourcedId));
91630
91923
  }
91631
91924
  this.assertResponseVersion(attempt.metadata, input.expectedResponseVersion);
91925
+ if (assessmentFlowForPurpose(attempt.metadata.purpose) === "item-submit") {
91926
+ const assessment2 = await this.loadAttemptAssessment(attempt.metadata);
91927
+ const flowFailure = assessmentCompletionFlowFailure(attempt.metadata.purpose, attempt.metadata.itemSubmissions, assessment2.items.length);
91928
+ if (flowFailure) {
91929
+ throw AssessmentRuntimeError.from(flowFailure);
91930
+ }
91931
+ }
91632
91932
  const scored = preview && preview.responseVersion === attempt.metadata.responseVersion ? preview : await this.scoreSubmission(attempt);
91633
91933
  const { assessment, itemResults, score, correctQuestions } = scored;
91634
91934
  const client2 = this.requireClient();
@@ -92271,18 +92571,105 @@ class TimebackAssessmentRuntimeService {
92271
92571
  }
92272
92572
  async putReviewChildResponses(result, metadata2, changedItemIdentifiers) {
92273
92573
  await runWithConcurrency(metadata2.review.selections.filter((selection) => changedItemIdentifiers.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92274
- const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92275
- const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92276
- const childMetadata = buildReviewItemResultMetadata({
92277
- metadata: metadata2,
92278
- selection,
92279
- parentAttemptId: result.sourcedId
92574
+ try {
92575
+ const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92576
+ const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92577
+ const childMetadata = buildReviewItemResultMetadata({
92578
+ metadata: metadata2,
92579
+ selection,
92580
+ parentAttemptId: result.sourcedId
92581
+ });
92582
+ await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92583
+ childLineItemId,
92584
+ student: result.student,
92585
+ metadata: childMetadata
92586
+ }));
92587
+ } catch (error88) {
92588
+ addEvent("assessment.review_child_response_projection_failed", {
92589
+ "app.assessment.attempt_id": result.sourcedId,
92590
+ "app.assessment.qti_item_identifier": selection.itemIdentifier,
92591
+ "exception.type": errorType(error88),
92592
+ "app.error.message": errorMessage(error88)
92593
+ });
92594
+ }
92595
+ });
92596
+ }
92597
+ async projectReviewItemResponse(params, projection) {
92598
+ await this.putReviewChildResponses(projection.result, projection.metadata, new Set([projection.itemIdentifier]));
92599
+ try {
92600
+ await this.crossAttemptLockBarrier(params.attemptId);
92601
+ const latest = await this.peekAttempt(params);
92602
+ if (isAssessmentAttemptCompleted({
92603
+ inProgress: latest.result.inProgress ?? "",
92604
+ scoreStatus: latest.result.scoreStatus
92605
+ }) && latest.metadata.purpose === "review") {
92606
+ await this.restoreCompletedReviewChildResponses(latest.result, latest.metadata, new Set([projection.itemIdentifier]));
92607
+ }
92608
+ } catch (error88) {
92609
+ addEvent("assessment.review_child_projection_barrier_failed", {
92610
+ "app.assessment.attempt_id": params.attemptId,
92611
+ "app.assessment.qti_item_identifier": projection.itemIdentifier,
92612
+ "exception.type": errorType(error88),
92613
+ "app.error.message": errorMessage(error88)
92280
92614
  });
92281
- await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, buildOpenReviewChildResult({
92282
- childLineItemId,
92283
- student: result.student,
92284
- metadata: childMetadata
92285
- }));
92615
+ }
92616
+ }
92617
+ async crossAttemptLockBarrier(attemptId) {
92618
+ for (let attempt = 1;attempt <= TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS; attempt += 1) {
92619
+ try {
92620
+ await this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(attemptId), async () => {
92621
+ return;
92622
+ });
92623
+ return;
92624
+ } catch (error88) {
92625
+ if (!(error88 instanceof ServiceUnavailableError) || attempt === TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS) {
92626
+ throw error88;
92627
+ }
92628
+ await sleep(TimebackAssessmentRuntimeService.PROJECTION_BARRIER_RETRY_DELAY_MS);
92629
+ }
92630
+ }
92631
+ }
92632
+ async restoreCompletedReviewChildResponses(result, metadata2, changedItemIdentifiers) {
92633
+ const outcomes = metadata2.completion?.itemOutcomes;
92634
+ const submissionId = metadata2.submissionId;
92635
+ if (!submissionId || !outcomes) {
92636
+ addEvent("assessment.review_child_terminal_restore_unavailable", {
92637
+ "app.assessment.attempt_id": result.sourcedId
92638
+ });
92639
+ return;
92640
+ }
92641
+ const outcomeByItem = new Map(outcomes.map((outcome) => [outcome.itemIdentifier, outcome]));
92642
+ await runWithConcurrency(metadata2.review.selections.filter((selection) => changedItemIdentifiers.has(selection.itemIdentifier)), TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, async (selection) => {
92643
+ try {
92644
+ const outcome = outcomeByItem.get(selection.itemIdentifier);
92645
+ if (!outcome) {
92646
+ throw new Error(`Completed review omitted item ${selection.itemIdentifier}`);
92647
+ }
92648
+ const childLineItemId = await reviewQuestionLineItemId(result.assessmentLineItem.sourcedId, selection.itemIdentifier);
92649
+ const childResultId = await reviewQuestionResultId(result.sourcedId, childLineItemId);
92650
+ const finalized = buildFinalizedReviewChildResult({
92651
+ childLineItemId,
92652
+ student: result.student,
92653
+ parentAttemptId: result.sourcedId,
92654
+ metadata: metadata2,
92655
+ selection,
92656
+ itemResult: {
92657
+ itemIdentifier: outcome.itemIdentifier,
92658
+ score: outcome.score,
92659
+ isCorrect: outcome.isCorrect
92660
+ },
92661
+ submissionId,
92662
+ timestamp: result.scoreDate
92663
+ });
92664
+ await this.requireClient().api.oneroster.assessmentResults.upsert(childResultId, finalized.resultUpdate);
92665
+ } catch (error88) {
92666
+ addEvent("assessment.review_child_terminal_restore_failed", {
92667
+ "app.assessment.attempt_id": result.sourcedId,
92668
+ "app.assessment.qti_item_identifier": selection.itemIdentifier,
92669
+ "exception.type": errorType(error88),
92670
+ "app.error.message": errorMessage(error88)
92671
+ });
92672
+ }
92286
92673
  });
92287
92674
  }
92288
92675
  async finalizeReviewChildResults(input) {
@@ -92363,8 +92750,10 @@ class TimebackAssessmentRuntimeService {
92363
92750
  attemptId: result.sourcedId,
92364
92751
  responseVersion: metadata2.responseVersion,
92365
92752
  status: completed ? "completed" : "in_progress",
92753
+ flow: assessmentFlowForPurpose(metadata2.purpose),
92366
92754
  assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
92367
92755
  responses: metadata2.responses,
92756
+ itemSubmissions: metadata2.itemSubmissions,
92368
92757
  score: completed ? this.scoreFromResult(result, assessmentPossibleScore(assessment), metadata2) : null,
92369
92758
  selection
92370
92759
  };
@@ -154073,7 +154462,7 @@ function parseQtiLibraryParams(searchParams) {
154073
154462
  limit
154074
154463
  };
154075
154464
  }
154076
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, saveRuntimeAssessment, submitRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, updateAssessment, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
154465
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, saveRuntimeAssessment, submitRuntimeAssessmentItem, submitRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, updateAssessment, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
154077
154466
  var init_timeback_controller = __esm(() => {
154078
154467
  init_esm();
154079
154468
  init_src();
@@ -154381,6 +154770,25 @@ var init_timeback_controller = __esm(() => {
154381
154770
  user: ctx.user
154382
154771
  });
154383
154772
  });
154773
+ submitRuntimeAssessmentItem = requireDeveloper(async (ctx) => {
154774
+ const attemptId = ctx.params.attemptId;
154775
+ const body2 = await parseRequestBody(ctx.request, SubmitAssessmentItemRuntimeRequestSchema);
154776
+ if (!attemptId) {
154777
+ throw ApiError.badRequest("Missing attemptId");
154778
+ }
154779
+ return ctx.services.timebackAssessmentRuntime.submitItem({
154780
+ gameId: body2.gameId,
154781
+ studentId: body2.studentId,
154782
+ attemptId,
154783
+ input: {
154784
+ expectedResponseVersion: body2.expectedResponseVersion,
154785
+ submissionId: body2.submissionId,
154786
+ itemIdentifier: body2.itemIdentifier,
154787
+ responses: body2.responses
154788
+ },
154789
+ user: ctx.user
154790
+ });
154791
+ });
154384
154792
  submitRuntimeAssessment = requireDeveloper(async (ctx) => {
154385
154793
  const attemptId = ctx.params.attemptId;
154386
154794
  const body2 = await parseRequestBody(ctx.request, SubmitRuntimeAssessmentRequestSchema);
@@ -154832,6 +155240,7 @@ var init_timeback_controller = __esm(() => {
154832
155240
  getLatestRuntimeAssessment,
154833
155241
  getRuntimeAssessment,
154834
155242
  saveRuntimeAssessment,
155243
+ submitRuntimeAssessmentItem,
154835
155244
  submitRuntimeAssessment,
154836
155245
  exportRuntimeAssessmentFixture,
154837
155246
  getStudentXp,
@@ -155820,6 +156229,7 @@ var init_timeback8 = __esm(async () => {
155820
156229
  timebackRouter.get("/assessments/latest", handle2(timeback2.getLatestRuntimeAssessment));
155821
156230
  timebackRouter.get("/assessments/:attemptId", handle2(timeback2.getRuntimeAssessment));
155822
156231
  timebackRouter.post("/assessments/:attemptId/save", handle2(timeback2.saveRuntimeAssessment));
156232
+ timebackRouter.post("/assessments/:attemptId/submit-item", handle2(timeback2.submitRuntimeAssessmentItem));
155823
156233
  timebackRouter.post("/assessments/:attemptId/submit", handle2(timeback2.submitRuntimeAssessment));
155824
156234
  timebackRouter.get("/user", async (c2) => {
155825
156235
  const user = c2.get("user");