// Curriculum progression rules — reads scenarios/rootnode-path.json's per-stage // completion_rule (and the capstone stage's unlock_rule) against a student's progress map to // answer: current stage, completed/locked stages, next required scenario, percent complete, // and what's blocking advancement. Rule-driven, not adaptive — see // docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §4 for why that's a deliberate scope line. // // Capstone completeness itself isn't computed here (a capstone stage has no scenario_ids to // check against progress) — the caller supplies it after checking for a 'capstone_completed' // achievement, since that's Postgres-backed state this module has no access to. function isStageComplete(stage, progress) { const ids = stage.scenario_ids || []; if (!ids.length) return false; const rule = stage.completion_rule || { type: 'all_required' }; const completedIds = ids.filter((id) => progress[id]?.status === 'completed'); switch (rule.type) { case 'threshold': return completedIds.length / ids.length >= (rule.percent || 100) / 100; case 'prerequisites': { const required = rule.scenario_ids && rule.scenario_ids.length ? rule.scenario_ids : ids; return required.every((id) => progress[id]?.status === 'completed'); } case 'all_required': default: return completedIds.length === ids.length; } } function nextIncompleteScenario(stage, progress) { return (stage.scenario_ids || []).find((id) => progress[id]?.status !== 'completed') || null; } // capstoneStatus: 'locked' | 'unlocked' | 'completed' — supplied by the caller (server route), // since it depends on Postgres state (achievements) this module can't see. function computeCurriculumState(path, progress, capstoneStatus) { const contentStages = path.stages.filter((s) => s.type !== 'capstone'); const capstoneStage = path.stages.find((s) => s.type === 'capstone'); let priorComplete = true; // the first content stage is always unlocked const stageCompletion = {}; // stage id -> bool, used by the capstone's stages_complete rule const stages = contentStages.map((stage) => { const complete = isStageComplete(stage, progress); stageCompletion[stage.id] = complete; const locked = !priorComplete; if (!complete) priorComplete = false; // next stage stays locked until this one is done const ids = stage.scenario_ids || []; const completedCount = ids.filter((id) => progress[id]?.status === 'completed').length; return { id: stage.id, name: stage.name, icon: stage.icon, locked, complete, total: ids.length, completed: completedCount, pct: ids.length ? Math.round((completedCount / ids.length) * 100) : 0, next_scenario: locked || complete ? null : nextIncompleteScenario(stage, progress), }; }); const currentStage = stages.find((s) => !s.locked && !s.complete) || null; const overallTotal = contentStages.reduce((n, s) => n + (s.scenario_ids || []).length, 0); const overallCompleted = contentStages.reduce( (n, s) => n + (s.scenario_ids || []).filter((id) => progress[id]?.status === 'completed').length, 0 ); let capstone = null; if (capstoneStage) { const rule = capstoneStage.unlock_rule || { type: 'stages_complete', stage_ids: contentStages.map((s) => s.id) }; const unlockedByRule = rule.type === 'stages_complete' && rule.stage_ids.every((id) => stageCompletion[id]); const blocking = rule.type === 'stages_complete' ? rule.stage_ids.filter((id) => !stageCompletion[id]) : []; capstone = { id: capstoneStage.id, name: capstoneStage.name, icon: capstoneStage.icon, assessment_definition_id: capstoneStage.capstone_assessment_id, status: capstoneStatus || (unlockedByRule ? 'unlocked' : 'locked'), blocking_stage_ids: blocking, }; } return { stages, current_stage: currentStage?.id || null, percent_complete: overallTotal ? Math.round((overallCompleted / overallTotal) * 100) : 0, capstone, }; } module.exports = { computeCurriculumState, isStageComplete };