mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
99 lines
3.4 KiB
JavaScript
99 lines
3.4 KiB
JavaScript
// Fisher-Yates in-place shuffle — shared by shuffleOptions and shuffleQuestions.
|
|
function fisherYates(arr) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// Randomises the ORDER OF OPTIONS within each question. Pure — returns new
|
|
// arrays/objects, never mutates input. Grading is unaffected since
|
|
// submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh
|
|
// from the DB and never trust the shuffled client-facing order.
|
|
function shuffleOptions(questions) {
|
|
return questions.map((q) => ({ ...q, options: fisherYates([...(q.options ?? [])]) }));
|
|
}
|
|
|
|
// Randomises the ORDER OF QUESTIONS. Pure — returns a new array.
|
|
// Safe: grading re-fetches questions from DB in stored order; client position
|
|
// has no effect on correctness checks.
|
|
function shuffleQuestions(questions) {
|
|
return fisherYates([...questions]);
|
|
}
|
|
|
|
// Single source of truth for both the GET-time info fields and the
|
|
// submit-time enforcement check.
|
|
//
|
|
// type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open
|
|
// type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles)
|
|
// maxFails / cooldownHours come from the assessment row; null = feature off (no limit/cooldown).
|
|
function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) {
|
|
const attempt_count = attempts.length;
|
|
const has_passed = attempts.some((a) => a.passed);
|
|
const best_attempt = attempts.reduce(
|
|
(best, a) => (!best || a.score > best.score ? a : best),
|
|
null
|
|
);
|
|
|
|
if (type === 'quiz') {
|
|
return {
|
|
attempt_count,
|
|
has_passed,
|
|
best_attempt,
|
|
attempts_remaining: null,
|
|
cooldown_until: null,
|
|
window_reset_at: null,
|
|
can_attempt: true,
|
|
};
|
|
}
|
|
|
|
// Assessment: simulate rolling cycles — N failed attempts → cooldown.
|
|
// null means the feature is off: no attempt cap / no cooldown.
|
|
const failLimit = maxFails ?? null;
|
|
const lockHours = cooldownHours ?? null;
|
|
|
|
if (failLimit === null || lockHours === null) {
|
|
return {
|
|
attempt_count,
|
|
has_passed,
|
|
best_attempt,
|
|
attempts_remaining: null,
|
|
cooldown_until: null,
|
|
window_reset_at: null,
|
|
can_attempt: true,
|
|
};
|
|
}
|
|
|
|
const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
|
|
let cycle_end = null; // when the current cooldown expires (null = no active or past cooldown)
|
|
let failed_in_cycle = 0;
|
|
|
|
for (const a of sorted) {
|
|
// Skip attempts that fall inside a previous cooldown window (they shouldn't exist, but guard anyway)
|
|
if (cycle_end && new Date(a.createdAt) < cycle_end) continue;
|
|
if (!a.passed) {
|
|
failed_in_cycle++;
|
|
if (failed_in_cycle >= failLimit) {
|
|
cycle_end = new Date(new Date(a.createdAt).getTime() + lockHours * 3600000);
|
|
failed_in_cycle = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
const cooldown_until = (cycle_end && cycle_end > now) ? cycle_end.toISOString() : null;
|
|
|
|
return {
|
|
attempt_count,
|
|
has_passed,
|
|
best_attempt,
|
|
attempts_remaining: null,
|
|
cooldown_until,
|
|
window_reset_at: null,
|
|
can_attempt: !cooldown_until,
|
|
};
|
|
}
|
|
|
|
module.exports = { shuffleOptions, shuffleQuestions, getAttemptStatus }; |