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

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