// 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 };