From 7ff327ee24446f282034cdf09f0febc43a73077b Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:32:18 -0400 Subject: [PATCH 1/3] feat(quests): per-course activity pinning from course-space state (client#7748) --- .github/instructions/quests.instructions.md | 13 +- .../quests/quest_objectives_loader.dart | 13 +- lib/features/quests/repo/quest_repo.dart | 51 +++++ .../chat/chat_details/chat_details.dart | 16 +- .../chat/chat_details/teacher_mode_model.dart | 24 +++ .../courses/own/selected_course_page.dart | 21 +- lib/routes/world/joined_objective_cache.dart | 32 ++- lib/routes/world/world_map.dart | 15 +- lib/routes/world/world_map_pins_manager.dart | 39 +++- .../quest_outline_restriction_test.dart | 194 ++++++++++++++++++ 10 files changed, 397 insertions(+), 21 deletions(-) create mode 100644 test/pangea/quests/quest_outline_restriction_test.dart diff --git a/.github/instructions/quests.instructions.md b/.github/instructions/quests.instructions.md index 70bc330405..e80f5198f1 100644 --- a/.github/instructions/quests.instructions.md +++ b/.github/instructions/quests.instructions.md @@ -53,11 +53,22 @@ The course plan panel lists the course's activities as cards, in rows. Each card 3. **Ongoing** β€” 🟣 purple card with an "Ongoing" overlay tag on the top right in white text; same text-not-color-only rationale; the purple matches the ongoing map pin (V6). 4. **Needs more participants to start** β€” πŸ”˜ light gray card at 30% opacity: still clickable but de-emphasized. Tapping it explains why ("Uh oh, you need to invite N people…"). +## Per-course activity pinning + +The design β€” what a pin means, why it lives in course state and never the quest plan, attribution-level semantics β€” is the org doc's [Per-course activity pinning](../../../.github/.github/instructions/quests-and-learning-objectives.instructions.md#per-course-activity-pinning). This doc records the client mechanics ([client#7748](https://github.com/pangeachat/client/issues/7748)): + +- The pin travels on the course space's teacher-mode state (`TeacherModeModel.pinnedActivitiesByObjective`: Mission id β†’ pinned `activity_id` content ids). Null, a missing Mission key, or an empty list all mean unrestricted. +- Restriction is a **pure copy** at the outline boundary (`QuestOutline.restrictedTo`) β€” never a mutation of the quest-outline cache, which is shared across courses referencing the same quest; that copy is what lets the same quest run restricted in one course and open in another. +- **One rule, one home**: `effectivePinnedActivityIds` carries the fail-open rule (no pin, empty pin, or an all-stale pin β†’ unrestricted, so a pin can never make a Mission unsatisfiable). Both the outline restriction and the course-scoped map's marker filter call it. The world-scoped map is deliberately never filtered β€” everything stays playable everywhere. +- The resolver is **pin-unaware by construction**: star attribution and the effective-threshold clamp both derive from the outline's per-Mission activity sets, so a filtered outline scopes attribution and clamps against the pinned set with no resolver changes. +- Previews and non-joined contexts pass no pins β€” there is no learner progress to scope, and fail-open is the default everywhere. +- The teacher editing surface is deferred to the admin panel ([admin-dash#30](https://github.com/pangeachat/admin-dash/issues/30)); until it ships, pins are written to course room state directly. + ## Future Work File GitHub issues for these and link them here (use the `update-future-work` skill). - A persisted per-Mission star total (server-side rollup) once reading every session room client-side becomes too costly at catalog scale. -- Teacher-set **hard** restrictions (an opt-in gate on top of the soft default), if classroom demand appears β€” deliberately not built today (see the org doc). +- Teacher-set **hard** restrictions (an opt-in gate on top of the soft default), if classroom demand appears β€” deliberately not built today (see the org doc). Distinct from per-course activity pinning (above), which is built and restricts *which activities satisfy*, not *when Missions are reachable*. - Implement the joinable/open activity card design β€” [pangeachat/client#7669](https://github.com/pangeachat/client/issues/7669). - Design hint indicating an activity needs more people to start β€” [pangeachat/client#6810](https://github.com/pangeachat/client/issues/6810). diff --git a/lib/features/quests/quest_objectives_loader.dart b/lib/features/quests/quest_objectives_loader.dart index 5aa729a015..6f1d2280ab 100644 --- a/lib/features/quests/quest_objectives_loader.dart +++ b/lib/features/quests/quest_objectives_loader.dart @@ -71,7 +71,14 @@ class QuestObjectivesLoader { } } - Future loadOutline(String? questId) async { + /// [pinnedActivitiesByObjective] is the course's per-Mission activity pin + /// (room.teacherMode) β€” passed by callers with a joined course room in hand; + /// null (previews, no room) means unrestricted, the fail-open default. + /// Applied as a pure copy so the shared quest-outline cache is untouched. + Future loadOutline( + String? questId, { + Map>? pinnedActivitiesByObjective, + }) async { if (_disposed) return; _loadGeneration++; @@ -91,7 +98,9 @@ class QuestObjectivesLoader { _updateQuest(AsyncLoading(), loadGen); final outlineResult = await QuestRepo.outline(questId); - final outline = outlineResult.result; + final outline = outlineResult.result?.restrictedTo( + pinnedActivitiesByObjective, + ); if (_disposed) return; diff --git a/lib/features/quests/repo/quest_repo.dart b/lib/features/quests/repo/quest_repo.dart index 2768a2b6af..3336bcc6ca 100644 --- a/lib/features/quests/repo/quest_repo.dart +++ b/lib/features/quests/repo/quest_repo.dart @@ -19,6 +19,21 @@ import 'package:fluffychat/widgets/matrix.dart'; class MissingQuestException implements Exception {} +/// The single home of the per-course activity-pin rule (org quests doc, +/// client#7748): which of a Mission's [available] activity ids count when the +/// course pins [pinned] for it. Fail-open by design β€” no pin, an empty pin, or +/// a pin whose ids are all stale (no intersection) yields [available] +/// unchanged, so a pin can never make a Mission unsatisfiable. Consumers: +/// [QuestOutline.restrictedTo] and the course-scoped map's marker filter. +Set effectivePinnedActivityIds( + Set available, + List? pinned, +) { + if (pinned == null || pinned.isEmpty) return available; + final intersection = available.intersection(pinned.toSet()); + return intersection.isEmpty ? available : intersection; +} + /// An activity in a quest outline: its id plus the full plan, so the card /// renders and a session can open without a refetch. class QuestActivity { @@ -45,6 +60,42 @@ class QuestOutline { final List groups; const QuestOutline({required this.quest, required this.groups}); + /// A copy with each pinned Mission's activities filtered to its pinned set β€” + /// per-course activity pinning (org quests doc; client#7748). A PURE COPY: + /// the outline cache is shared across courses referencing the same quest, so + /// the same quest can run restricted in one course and open in another only + /// because this never mutates the source. + /// + /// Fail-open rules (a pin can never make a Mission unsatisfiable): a Mission + /// with no pin entry or an empty pinned list is unrestricted; pinned ids are + /// intersected with the Mission's actual activities, and an intersection + /// that comes up empty (all pins stale) falls back to unrestricted. + QuestOutline restrictedTo(Map>? pinnedByObjective) { + if (pinnedByObjective == null || pinnedByObjective.isEmpty) return this; + return QuestOutline( + quest: quest, + groups: [ + for (final group in groups) + _restrictGroup(group, pinnedByObjective[group.objective.id]), + ], + ); + } + + static QuestObjectiveGroup _restrictGroup( + QuestObjectiveGroup group, + List? pinned, + ) { + final available = group.activities.map((a) => a.activityId).toSet(); + final allowed = effectivePinnedActivityIds(available, pinned); + if (allowed.length == available.length) return group; + return QuestObjectiveGroup( + objective: group.objective, + activities: group.activities + .where((a) => allowed.contains(a.activityId)) + .toList(), + ); + } + /// Project this outline into the pure [CourseLoOutline] the progression gate /// consumes: the quest's ordered objective ids, and per objective the set of /// activity ids that satisfy it. [starsToUnlock] carries the course's teacher diff --git a/lib/routes/chat/chat_details/chat_details.dart b/lib/routes/chat/chat_details/chat_details.dart index 36e165322f..2388e7976f 100644 --- a/lib/routes/chat/chat_details/chat_details.dart +++ b/lib/routes/chat/chat_details/chat_details.dart @@ -82,7 +82,10 @@ class ChatDetailsController extends State activitiesToCompleteOverride: room?.teacherMode.activitiesToUnlockTopic, ); - _objectivesProvider.loadOutline(_questId); + _objectivesProvider.loadOutline( + _questId, + pinnedActivitiesByObjective: _pinnedActivitiesByObjective, + ); _loadSummaries(); if (room?.isSpace == true) { @@ -96,7 +99,10 @@ class ChatDetailsController extends State void didUpdateWidget(covariant ChatDetails oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.roomId != widget.roomId) { - _objectivesProvider.loadOutline(_questId); + _objectivesProvider.loadOutline( + _questId, + pinnedActivitiesByObjective: _pinnedActivitiesByObjective, + ); _loadSummaries(); } @@ -122,6 +128,12 @@ class ChatDetailsController extends State String? get _questId => Matrix.of(context).client.getRoomById(widget.roomId)?.coursePlan?.uuid; + /// The course's per-Mission activity pin (org quests doc, client#7748) β€” + /// null when unset, which is the unrestricted default. + Map>? get _pinnedActivitiesByObjective => Matrix.of( + context, + ).client.getRoomById(widget.roomId)?.teacherMode.pinnedActivitiesByObjective; + QuestObjectivesLoader get objectivesProvider => _objectivesProvider; void setDisplaynameAction() async { diff --git a/lib/routes/chat/chat_details/teacher_mode_model.dart b/lib/routes/chat/chat_details/teacher_mode_model.dart index 6c7ee3a3a9..8077bec7b4 100644 --- a/lib/routes/chat/chat_details/teacher_mode_model.dart +++ b/lib/routes/chat/chat_details/teacher_mode_model.dart @@ -8,16 +8,26 @@ class TeacherModeModel { /// [activitiesToUnlockTopic] count, which gated on completed activities. final int? starsToUnlockObjective; + /// Per-course activity pinning: Mission (LO) id β†’ the activity content ids + /// (`activity_id`, environment-stable β€” never CMS row ids) that satisfy the + /// Mission in this course's context. Null / missing key / empty list mean no + /// restriction β€” pinning is opt-in per Mission and fails open, so a pin can + /// never make a Mission unsatisfiable (org quests doc). Independent of + /// [enabled], which only toggles the teacher's own viewing mode. + final Map>? pinnedActivitiesByObjective; + const TeacherModeModel({ required this.enabled, this.activitiesToUnlockTopic, this.starsToUnlockObjective, + this.pinnedActivitiesByObjective, }); TeacherModeModel copyWith({ bool? enabled, int? activitiesToUnlockTopic, int? starsToUnlockObjective, + Map>? pinnedActivitiesByObjective, }) { return TeacherModeModel( enabled: enabled ?? this.enabled, @@ -25,6 +35,8 @@ class TeacherModeModel { activitiesToUnlockTopic ?? this.activitiesToUnlockTopic, starsToUnlockObjective: starsToUnlockObjective ?? this.starsToUnlockObjective, + pinnedActivitiesByObjective: + pinnedActivitiesByObjective ?? this.pinnedActivitiesByObjective, ); } @@ -32,13 +44,25 @@ class TeacherModeModel { 'enabled': enabled, 'activities_to_unlock_topic': activitiesToUnlockTopic, 'stars_to_unlock_objective': starsToUnlockObjective, + if (pinnedActivitiesByObjective != null) + 'pinned_activities_by_objective': pinnedActivitiesByObjective, }; factory TeacherModeModel.fromJson(Map json) { + final rawPins = json['pinned_activities_by_objective']; return TeacherModeModel( enabled: json['enabled'] ?? false, activitiesToUnlockTopic: json['activities_to_unlock_topic'], starsToUnlockObjective: json['stars_to_unlock_objective'], + pinnedActivitiesByObjective: rawPins is Map + ? { + for (final entry in rawPins.entries) + if (entry.key is String && entry.value is List) + entry.key as String: (entry.value as List) + .whereType() + .toList(), + } + : null, ); } } diff --git a/lib/routes/courses/own/selected_course_page.dart b/lib/routes/courses/own/selected_course_page.dart index e6569322e7..12856f8cbc 100644 --- a/lib/routes/courses/own/selected_course_page.dart +++ b/lib/routes/courses/own/selected_course_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' as sdk; @@ -14,6 +15,7 @@ import 'package:fluffychat/features/quests/quest_objectives_loader.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/spaces/client_spaces_extension.dart'; import 'package:fluffychat/routes/chat/chat_details/space_details_content.dart'; +import 'package:fluffychat/routes/world/world_map_client_extension.dart'; import 'package:fluffychat/routes/chat/events/constants/pangea_event_types.dart'; import 'package:fluffychat/routes/courses/own/selected_course_view.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -45,20 +47,35 @@ class SelectedCourse extends StatefulWidget { class SelectedCourseController extends State { late final QuestObjectivesLoader _objectivesProvider; + /// The joined course room for this course plan, if any β€” the home of the + /// per-Mission activity pin (org quests doc, client#7748). Null (not joined) + /// means unrestricted, the fail-open default. + Map>? get _pinnedActivitiesByObjective => + Matrix.of(context).client.joinedCourseRooms + .firstWhereOrNull((r) => r.coursePlan?.uuid == widget.courseId) + ?.teacherMode + .pinnedActivitiesByObjective; + @override initState() { super.initState(); _objectivesProvider = QuestObjectivesLoader( client: Matrix.of(context).client, ); - _objectivesProvider.loadOutline(widget.courseId); + _objectivesProvider.loadOutline( + widget.courseId, + pinnedActivitiesByObjective: _pinnedActivitiesByObjective, + ); } @override void didUpdateWidget(covariant SelectedCourse oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.courseId != widget.courseId) { - _objectivesProvider.loadOutline(widget.courseId); + _objectivesProvider.loadOutline( + widget.courseId, + pinnedActivitiesByObjective: _pinnedActivitiesByObjective, + ); } } diff --git a/lib/routes/world/joined_objective_cache.dart b/lib/routes/world/joined_objective_cache.dart index 4cd0b62b96..f45b2e1318 100644 --- a/lib/routes/world/joined_objective_cache.dart +++ b/lib/routes/world/joined_objective_cache.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/features/course_plans/courses/course_plan_room_extens import 'package:fluffychat/features/quests/lo_progression.dart'; import 'package:fluffychat/features/quests/quest_progression_resolver.dart'; import 'package:fluffychat/features/quests/repo/quest_repo.dart'; +import 'package:fluffychat/routes/chat/chat_details/teacher_mode_model.dart'; import 'package:fluffychat/routes/world/world_map_client_extension.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -88,33 +89,42 @@ class JoinedObjectiveCache { } /// [rebuild] from the client's joined courses β€” each course's quest uuid with - /// its teacher stars-to-unlock override. The single home for that mapping: - /// the world map's pins manager and the course panel's star display both - /// rebuild through here, so every surface resolves identical outlines. + /// its teacher config (stars-to-unlock override + per-Mission activity pins). + /// The single home for that mapping: the world map's pins manager and the + /// course panel's star display both rebuild through here, so every surface + /// resolves identical outlines. Pins are applied as a pure copy per course + /// (never to the shared quest-outline cache), so two courses sharing a quest + /// can restrict differently. Future rebuildFromJoinedCourses( Client client, { void Function(String uuid, Object error, StackTrace stack)? onError, }) { - final thresholds = { + final modes = { for (final room in client.joinedCourseRooms) - room.coursePlan!.uuid: - room.teacherMode.starsToUnlockObjective ?? - kDefaultStarsToUnlockObjective, + room.coursePlan!.uuid: room.teacherMode, }; return rebuild( - thresholds.keys.toList(), + modes.keys.toList(), + outlineOf: (uuid) => _outlineFromQuest( + uuid, + pinnedByObjective: modes[uuid]?.pinnedActivitiesByObjective, + ), starsToUnlockOf: (uuid) => - thresholds[uuid] ?? kDefaultStarsToUnlockObjective, + modes[uuid]?.starsToUnlockObjective ?? + kDefaultStarsToUnlockObjective, onError: onError, ); } - static Future _outlineFromQuest(String uuid) async { + static Future _outlineFromQuest( + String uuid, { + Map>? pinnedByObjective, + }) async { final outlineResult = await QuestRepo.outline(uuid); final outline = outlineResult.result; if (outline == null) { throw (outlineResult.error ?? MissingQuestException()); } - return outline.toCourseLoOutline(); + return outline.restrictedTo(pinnedByObjective).toCourseLoOutline(); } } diff --git a/lib/routes/world/world_map.dart b/lib/routes/world/world_map.dart index 71337ac16a..69071abadd 100644 --- a/lib/routes/world/world_map.dart +++ b/lib/routes/world/world_map.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/features/activity_sessions/activity_plan_repo.dart'; +import 'package:fluffychat/features/course_plans/courses/course_plan_room_extension.dart'; import 'package:fluffychat/features/languages/language_model.dart'; import 'package:fluffychat/features/navigation/route_facts.dart'; import 'package:fluffychat/features/navigation/workspace_nav.dart'; @@ -393,7 +395,18 @@ class WorldMapController extends State case CourseMapContext(): _ensureScopedCourseOutline(mapContext.coursePlanId); try { - await _pinsManager.loadCourseScopedPins(mapContext.coursePlanId); + // The joined course's per-Mission activity pin scopes this view's + // markers (org quests doc, client#7748); not joined / unset β†’ null β†’ + // unrestricted. + final courseRoom = Matrix.of(context).client.joinedCourseRooms + .firstWhereOrNull( + (r) => r.coursePlan?.uuid == mapContext.coursePlanId, + ); + await _pinsManager.loadCourseScopedPins( + mapContext.coursePlanId, + pinnedActivitiesByObjective: + courseRoom?.teacherMode.pinnedActivitiesByObjective, + ); if (!mounted) return; setState(() {}); diff --git a/lib/routes/world/world_map_pins_manager.dart b/lib/routes/world/world_map_pins_manager.dart index 3a0a6a72c9..884dd521d4 100644 --- a/lib/routes/world/world_map_pins_manager.dart +++ b/lib/routes/world/world_map_pins_manager.dart @@ -394,7 +394,10 @@ class WorldMapPinsManager { resolveProgression(); } - Future loadCourseScopedPins(String courseId) async { + Future loadCourseScopedPins( + String courseId, { + Map>? pinnedActivitiesByObjective, + }) async { final questResult = await QuestRepo.quest(courseId); final quest = questResult.result; if (quest == null) { @@ -412,7 +415,39 @@ class WorldMapPinsManager { return; } - _pins = activityCards; + _pins = _restrictCards(activityCards, pinnedActivitiesByObjective); + } + + /// Course-scoped marker filter for per-course activity pinning (org quests + /// doc, client#7748): a card stays when at least one of its Mission refs + /// allows it β€” the same fail-open rule as the outline restriction, via the + /// shared [effectivePinnedActivityIds]. Null pins (not joined / unset) show + /// everything; the WORLD-scoped map is deliberately never filtered. + static List _restrictCards( + List cards, + Map>? pinnedByObjective, + ) { + if (pinnedByObjective == null || pinnedByObjective.isEmpty) return cards; + final availableByLo = >{}; + for (final card in cards) { + for (final loId in card.learningObjectiveRefs) { + (availableByLo[loId] ??= {}).add(card.activityId); + } + } + final allowedByLo = { + for (final entry in availableByLo.entries) + entry.key: effectivePinnedActivityIds( + entry.value, + pinnedByObjective[entry.key], + ), + }; + return cards + .where( + (card) => card.learningObjectiveRefs.any( + (loId) => allowedByLo[loId]?.contains(card.activityId) ?? true, + ), + ) + .toList(); } Future loadWorldScopedPins({ diff --git a/test/pangea/quests/quest_outline_restriction_test.dart b/test/pangea/quests/quest_outline_restriction_test.dart new file mode 100644 index 0000000000..270f9aeb27 --- /dev/null +++ b/test/pangea/quests/quest_outline_restriction_test.dart @@ -0,0 +1,194 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/activity_sessions/activity_media_enum.dart'; +import 'package:fluffychat/features/activity_sessions/activity_plan_model.dart'; +import 'package:fluffychat/features/activity_sessions/activity_plan_request.dart'; +import 'package:fluffychat/features/quests/models/learning_objective_model.dart'; +import 'package:fluffychat/features/quests/models/quest_plan_model.dart'; +import 'package:fluffychat/features/quests/quest_progression_resolver.dart'; +import 'package:fluffychat/features/quests/repo/quest_repo.dart'; +import 'package:fluffychat/routes/settings/settings_learning/language_level_type_enum.dart'; + +/// Per-course activity pinning (client#7748): [QuestOutline.restrictedTo] is a +/// PURE COPY transform β€” the quest-outline cache is shared across courses that +/// pin the same quest, so restriction must never mutate the cached object. A +/// pin filters a Mission's activities to the pinned set; every fail-open rule +/// exists so a pin can never make a Mission unsatisfiable (org quests doc). +void main() { + ActivityPlanModel plan(String id, {int goals = 3}) => ActivityPlanModel( + req: ActivityPlanRequest( + topic: '', + mode: '', + objective: '', + media: MediaEnum.nan, + cefrLevel: LanguageLevelTypeEnum.a2, + languageOfInstructions: 'en', + targetLanguage: 'es', + numberOfParticipants: 2, + ), + title: '', + learningObjective: '', + instructions: '', + vocab: const [], + activityId: id, + roles: { + 'r1': ActivityRole( + id: 'r1', + name: 'r1', + goal: null, + goals: [ + for (var i = 0; i < goals; i++) + ActivityRoleGoal(id: '$id-g$i', description: 'g$i'), + ], + ), + }, + ); + + QuestActivity activity(String id, {int goals = 3}) => + QuestActivity(activityId: id, plan: plan(id, goals: goals)); + + QuestOutline outline(Map> activitiesByLo) => + QuestOutline( + quest: QuestPlan( + id: 'q1', + name: '', + description: '', + targetLanguage: 'es', + sequence: [ + for (final loId in activitiesByLo.keys) + QuestObjectiveStep( + objective: LearningObjective(id: loId, objective: loId), + wasMinted: false, + ), + ], + ), + groups: [ + for (final entry in activitiesByLo.entries) + QuestObjectiveGroup( + objective: LearningObjective( + id: entry.key, + objective: entry.key, + ), + activities: entry.value, + ), + ], + ); + + Set idsOf(QuestOutline o, String loId) => o.groups + .firstWhere((g) => g.objective.id == loId) + .activities + .map((a) => a.activityId) + .toSet(); + + group('QuestOutline.restrictedTo', () { + test('null pins leaves every group unfiltered', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2')], + }); + final restricted = o.restrictedTo(null); + expect(idsOf(restricted, 'lo-1'), {'a1', 'a2'}); + }); + + test('filters a pinned Mission to the pinned set', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2'), activity('a3')], + 'lo-2': [activity('b1'), activity('b2')], + }); + final restricted = o.restrictedTo({ + 'lo-1': ['a1', 'a3'], + }); + expect(idsOf(restricted, 'lo-1'), {'a1', 'a3'}); + // lo-2 has no pin entry β€” unchanged. + expect(idsOf(restricted, 'lo-2'), {'b1', 'b2'}); + }); + + test('drops stale pinned ids not in the Mission', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2')], + }); + final restricted = o.restrictedTo({ + 'lo-1': ['a1', 'gone-activity'], + }); + expect(idsOf(restricted, 'lo-1'), {'a1'}); + }); + + test('fails open when the pin intersects to nothing', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2')], + }); + final restricted = o.restrictedTo({ + 'lo-1': ['gone-1', 'gone-2'], + }); + expect(idsOf(restricted, 'lo-1'), {'a1', 'a2'}); + }); + + test('fails open on an empty pinned list', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2')], + }); + final restricted = o.restrictedTo({'lo-1': []}); + expect(idsOf(restricted, 'lo-1'), {'a1', 'a2'}); + }); + + test('never mutates the source outline (shared cache safety)', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2')], + }); + o.restrictedTo({ + 'lo-1': ['a1'], + }); + expect(idsOf(o, 'lo-1'), {'a1', 'a2'}); + }); + + test('projection derives filtered activity and earnable maps', () { + final o = outline({ + 'lo-1': [activity('a1', goals: 4), activity('a2', goals: 5)], + }); + final projected = o + .restrictedTo({ + 'lo-1': ['a1'], + }) + .toCourseLoOutline(); + expect(projected.activityIdsByLo['lo-1'], {'a1'}); + expect(projected.earnableByActivity.containsKey('a2'), isFalse); + expect(projected.earnableByActivity['a1'], 4); + }); + }); + + group('restricted outline through resolveProgression', () { + test('stars on an off-pin activity do not count toward the Mission', () { + final o = outline({ + 'lo-1': [activity('a1'), activity('a2')], + }); + final resolution = resolveProgression( + outlines: [ + o + .restrictedTo({ + 'lo-1': ['a1'], + }) + .toCourseLoOutline(), + ], + starsByActivity: {'a1': 2, 'a2': 5}, + ); + expect(resolution.rollup['lo-1']!.stars, 2); + }); + + test('the effective threshold clamps to the pinned set', () { + final o = outline({ + 'lo-1': [activity('a1', goals: 4), activity('a2', goals: 6)], + }); + final resolution = resolveProgression( + outlines: [ + o + .restrictedTo({ + 'lo-1': ['a1'], + }) + .toCourseLoOutline(starsToUnlock: 10), + ], + starsByActivity: const {}, + ); + // Unrestricted the ceiling would be 10 (4+6 = 10); pinned it is 4. + expect(resolution.rollup['lo-1']!.threshold, 4); + }); + }); +} From e33db073eec85e108ce57bba6e819eb86c423bd3 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:12:28 -0400 Subject: [PATCH 2/3] style: dart format quest pin files --- lib/routes/world/joined_objective_cache.dart | 3 +-- .../quest_outline_restriction_test.dart | 27 ++++++++----------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/lib/routes/world/joined_objective_cache.dart b/lib/routes/world/joined_objective_cache.dart index f45b2e1318..fb41205939 100644 --- a/lib/routes/world/joined_objective_cache.dart +++ b/lib/routes/world/joined_objective_cache.dart @@ -110,8 +110,7 @@ class JoinedObjectiveCache { pinnedByObjective: modes[uuid]?.pinnedActivitiesByObjective, ), starsToUnlockOf: (uuid) => - modes[uuid]?.starsToUnlockObjective ?? - kDefaultStarsToUnlockObjective, + modes[uuid]?.starsToUnlockObjective ?? kDefaultStarsToUnlockObjective, onError: onError, ); } diff --git a/test/pangea/quests/quest_outline_restriction_test.dart b/test/pangea/quests/quest_outline_restriction_test.dart index 270f9aeb27..c319e30e97 100644 --- a/test/pangea/quests/quest_outline_restriction_test.dart +++ b/test/pangea/quests/quest_outline_restriction_test.dart @@ -44,8 +44,10 @@ void main() { }, ); - QuestActivity activity(String id, {int goals = 3}) => - QuestActivity(activityId: id, plan: plan(id, goals: goals)); + QuestActivity activity(String id, {int goals = 3}) => QuestActivity( + activityId: id, + plan: plan(id, goals: goals), + ); QuestOutline outline(Map> activitiesByLo) => QuestOutline( @@ -65,10 +67,7 @@ void main() { groups: [ for (final entry in activitiesByLo.entries) QuestObjectiveGroup( - objective: LearningObjective( - id: entry.key, - objective: entry.key, - ), + objective: LearningObjective(id: entry.key, objective: entry.key), activities: entry.value, ), ], @@ -144,11 +143,9 @@ void main() { final o = outline({ 'lo-1': [activity('a1', goals: 4), activity('a2', goals: 5)], }); - final projected = o - .restrictedTo({ - 'lo-1': ['a1'], - }) - .toCourseLoOutline(); + final projected = o.restrictedTo({ + 'lo-1': ['a1'], + }).toCourseLoOutline(); expect(projected.activityIdsByLo['lo-1'], {'a1'}); expect(projected.earnableByActivity.containsKey('a2'), isFalse); expect(projected.earnableByActivity['a1'], 4); @@ -162,11 +159,9 @@ void main() { }); final resolution = resolveProgression( outlines: [ - o - .restrictedTo({ - 'lo-1': ['a1'], - }) - .toCourseLoOutline(), + o.restrictedTo({ + 'lo-1': ['a1'], + }).toCourseLoOutline(), ], starsByActivity: {'a1': 2, 'a2': 5}, ); From f8b9d4f3b359195ce0e5482766436f6cf4f096a6 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:55:37 -0400 Subject: [PATCH 3/3] style: sort imports in selected_course_page --- lib/routes/courses/own/selected_course_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/routes/courses/own/selected_course_page.dart b/lib/routes/courses/own/selected_course_page.dart index 12856f8cbc..cc0fbc1637 100644 --- a/lib/routes/courses/own/selected_course_page.dart +++ b/lib/routes/courses/own/selected_course_page.dart @@ -15,9 +15,9 @@ import 'package:fluffychat/features/quests/quest_objectives_loader.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/spaces/client_spaces_extension.dart'; import 'package:fluffychat/routes/chat/chat_details/space_details_content.dart'; -import 'package:fluffychat/routes/world/world_map_client_extension.dart'; import 'package:fluffychat/routes/chat/events/constants/pangea_event_types.dart'; import 'package:fluffychat/routes/courses/own/selected_course_view.dart'; +import 'package:fluffychat/routes/world/world_map_client_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; enum SelectedCourseMode { launch, addToSpace }