From d88ad677e88013692c6fd95d75717d67999fdcca Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sun, 31 May 2026 15:15:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(milestones):=20consolide=20les=20parent?= =?UTF-8?q?s=20quand=20un=20enfant=20est=20acquitt=C3=A9=20par=20d=C3=A9fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le défi tuto acquitte des milestones enfants (ex. intro_hold_throat, requires basics) via l'unlock provisoire de la séance (basics, fourni par intro_basics insérée mais pas encore consolidée). intro_basics n'étant jamais acquittée par le défi (pas d'acquittableByCapability), elle restait "à faire" alors que ses enfants étaient validés — ré-insérée comme tutoriel aux séances suivantes. - MilestoneService.consolidatePrerequisites() : back-fill des parents dont un enfant complété dépend (point fixe, idempotent). - Appelé après l'acquittement par défi (corrige la cause) et au start de séance (répare les états déjà cassés sans reset). - Test dédié reproduisant l'état cassé + chaîne transitive. --- .../lib/career/screens/career_screen.dart | 8 ++ .../career/services/milestone_service.dart | 50 +++++++ .../session_controller_challenge.dart | 9 ++ .../milestone_consolidate_prereq_test.dart | 131 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 rhythm_coach/test/milestone_consolidate_prereq_test.dart diff --git a/rhythm_coach/lib/career/screens/career_screen.dart b/rhythm_coach/lib/career/screens/career_screen.dart index e40224d..37f87a3 100644 --- a/rhythm_coach/lib/career/screens/career_screen.dart +++ b/rhythm_coach/lib/career/screens/career_screen.dart @@ -113,6 +113,14 @@ class _CareerScreenState extends State { // d'une capacité prouvée si la joueuse n'a pas atteint le palier. final synthLevel = CareerDifficultyResolver.synthLevelFor(completedSessions); + // Réparation à froid : consolide les milestones-parents dont un enfant + // déjà complété dépend mais qui sont restées « à faire » (acquittement + // par défi sur un unlock provisoire non consolidé — cf. + // `MilestoneService.consolidatePrerequisites`). Sans ça, le tutoriel + // `intro_basics` est ré-inséré tant qu'il n'est pas consolidé alors que + // ses enfants sont faits. Avant le reconcile : back-filler `basics` + // permet ensuite à `reconcileFromCapability` d'acquitter correctement. + await milestoneService.consolidatePrerequisites(); // Rattrapage à froid : acquitte les milestones que le profil de // capacités prouve déjà (cas typique : la cascade transitive du défi // a été livrée après que la joueuse l'ait joué — sans rattrapage, diff --git a/rhythm_coach/lib/career/services/milestone_service.dart b/rhythm_coach/lib/career/services/milestone_service.dart index b804869..a304398 100644 --- a/rhythm_coach/lib/career/services/milestone_service.dart +++ b/rhythm_coach/lib/career/services/milestone_service.dart @@ -715,6 +715,56 @@ class MilestoneService extends ChangeNotifier { } } + /// Consolide les milestones-**parents** dont l'unlock conditionne une + /// milestone déjà complétée. Répare l'incohérence « enfant acquitté mais + /// parent jamais consolidé » : l'acquittement par défi + /// ([markCompletedViaChallenge]) peut valider un enfant + /// (ex. `intro_hold_throat`, `requires: [basics]`) en s'appuyant sur un + /// unlock **provisoire** de la séance (`basics`, fourni par `intro_basics` + /// insérée mais pas encore consolidée — cf. [effectiveUnlockKeysForChallenge]). + /// Si le parent n'est jamais consolidé (séance non terminée proprement, + /// fail…), il reste candidat et est ré-inséré aux séances suivantes alors + /// que ses enfants sont faits — le tutoriel « revient ». + /// + /// Algorithme : pour chaque milestone complétée, tout `requires` non + /// couvert par un unlock déjà acquis lifetime ([acquiredUnlockKeys]) est + /// comblé en consolidant la/les milestone(s) du catalogue qui l'accordent + /// (invariant 1 milestone → 1 unlock, mais on traite N par robustesse). + /// Itère jusqu'à point fixe (un parent peut lui-même avoir des `requires`). + /// Idempotent ; persiste + notifie une seule fois si quelque chose a bougé. + /// Retourne le nombre de parents consolidés. + Future consolidatePrerequisites() async { + if (!_loaded) return 0; + final added = {}; + var changed = true; + while (changed) { + changed = false; + final granted = acquiredUnlockKeys(); + for (final completedId in _completed.toList()) { + final m = findById(completedId); + if (m == null) continue; + for (final req in m.requires) { + if (granted.contains(req)) continue; + for (final parent in _catalog) { + if (_completed.contains(parent.id)) continue; + if (!parent.unlocks.contains(req)) continue; + _completed.add(parent.id); + added.add(parent.id); + changed = true; + } + } + } + } + if (added.isEmpty) return 0; + await _persist(); + for (final id in added) { + await resetRetryCount(id); + await resetCandidacyAge(id); + } + notifyListeners(); + return added.length; + } + /// Rattrapage à froid : acquitte les milestones que le [profile] de /// capacités prouve **déjà**, indépendamment d'un défi récent. Réutilise /// `milestonesAcquittableByChallenge` sur chaque axe du profil qui a une diff --git a/rhythm_coach/lib/controllers/session_controller_challenge.dart b/rhythm_coach/lib/controllers/session_controller_challenge.dart index 6df1387..d78d997 100644 --- a/rhythm_coach/lib/controllers/session_controller_challenge.dart +++ b/rhythm_coach/lib/controllers/session_controller_challenge.dart @@ -1072,6 +1072,15 @@ extension ChallengeOrchestrator on SessionController { for (final m in acquittable) { await milestoneService.markCompletedViaChallenge(m.id); } + // L'acquittement ci-dessus a pu s'appuyer sur un unlock **provisoire** + // de la séance (cf. `effectiveUnlockKeysForChallenge`) — typiquement + // `basics`, fourni par `intro_basics` insérée mais pas encore + // consolidée. On consolide donc ses parents pour ne pas laisser des + // enfants acquittés sans leur prérequis (sinon le tutoriel est + // ré-inséré aux séances suivantes). No-op si rien à combler. + if (acquittable.isNotEmpty) { + await milestoneService.consolidatePrerequisites(); + } } /// Phase finale défis — consume la tête de la file showcase si la diff --git a/rhythm_coach/test/milestone_consolidate_prereq_test.dart b/rhythm_coach/test/milestone_consolidate_prereq_test.dart new file mode 100644 index 0000000..99113d6 --- /dev/null +++ b/rhythm_coach/test/milestone_consolidate_prereq_test.dart @@ -0,0 +1,131 @@ +import 'package:beat_bitch/career/models/level_milestone.dart'; +import 'package:beat_bitch/career/models/unlock_key.dart'; +import 'package:beat_bitch/career/services/milestone_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +LevelMilestone _milestone({ + required String id, + List unlocks = const [], + List requires = const [], +}) { + return LevelMilestone( + id: id, + humilRequired: 0, + displayLabel: id, + sequence: const [], + durationSeconds: 1, + unlocks: unlocks, + requires: requires, + minLevel: 1, + ); +} + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + }); + + tearDownAll(() { + debugDefaultTargetPlatformOverride = null; + }); + + group('consolidatePrerequisites', () { + test('enfants complétés sans le parent (intro_basics) → parent consolidé', + () async { + // Reproduit le bug : un défi tuto acquitte `intro_hold_throat` / + // `intro_final_hold_head` (requires `basics`) via l'unlock provisoire + // de `intro_basics` insérée mais jamais consolidée. État persisté = + // enfants faits, parent en attente → tutoriel ré-inséré. + final basics = + _milestone(id: 'intro_basics', unlocks: [UnlockKey.basics]); + final throat = _milestone( + id: 'intro_hold_throat', + unlocks: [UnlockKey.throatHold], + requires: [UnlockKey.basics], + ); + final finalHead = _milestone( + id: 'intro_final_hold_head', + unlocks: [UnlockKey.finalHoldHead], + requires: [UnlockKey.basics], + ); + final svc = MilestoneService(); + svc.seedForTest( + catalog: [basics, throat, finalHead], + completed: {'intro_hold_throat', 'intro_final_hold_head'}, + ); + + expect(svc.isCompleted('intro_basics'), isFalse); + final added = await svc.consolidatePrerequisites(); + + expect(added, 1); + expect(svc.isCompleted('intro_basics'), isTrue); + // Et donc plus jamais candidat à la ré-insertion : son unlock est acquis. + expect(svc.hasUnlock(UnlockKey.basics), isTrue); + }); + + test('idempotent : 2e appel ne consolide rien', () async { + final basics = + _milestone(id: 'intro_basics', unlocks: [UnlockKey.basics]); + final throat = _milestone( + id: 'intro_hold_throat', + unlocks: [UnlockKey.throatHold], + requires: [UnlockKey.basics], + ); + final svc = MilestoneService(); + svc.seedForTest( + catalog: [basics, throat], + completed: {'intro_hold_throat'}, + ); + + expect(await svc.consolidatePrerequisites(), 1); + expect(await svc.consolidatePrerequisites(), 0); + }); + + test('chaîne transitive : point fixe consolide tous les ancêtres', + () async { + // grandParent → parent → child : seul `child` est complété. + final grandParent = _milestone(id: 'gp', unlocks: [UnlockKey.basics]); + final parent = _milestone( + id: 'p', + unlocks: [UnlockKey.rhythmMidBasic], + requires: [UnlockKey.basics], + ); + final child = _milestone( + id: 'c', + unlocks: [UnlockKey.holdMid], + requires: [UnlockKey.rhythmMidBasic], + ); + final svc = MilestoneService(); + svc.seedForTest( + catalog: [grandParent, parent, child], + completed: {'c'}, + ); + + final added = await svc.consolidatePrerequisites(); + expect(added, 2); + expect(svc.isCompleted('p'), isTrue); + expect(svc.isCompleted('gp'), isTrue); + }); + + test('rien à réparer : set cohérent → 0', () async { + final basics = + _milestone(id: 'intro_basics', unlocks: [UnlockKey.basics]); + final throat = _milestone( + id: 'intro_hold_throat', + unlocks: [UnlockKey.throatHold], + requires: [UnlockKey.basics], + ); + final svc = MilestoneService(); + svc.seedForTest( + catalog: [basics, throat], + completed: {'intro_basics', 'intro_hold_throat'}, + ); + + expect(await svc.consolidatePrerequisites(), 0); + }); + }); +} From 8c0647f4335aee82e1c8bcedee121b6f8a5eeddf Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sun, 31 May 2026 15:45:26 +0200 Subject: [PATCH 2/2] chore(release): bump version 0.5.3+14 --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- CHANGELOG.md | 10 +++++++++- README.de.md | 2 +- README.fr.md | 2 +- README.md | 2 +- rhythm_coach/README.de.md | 2 +- rhythm_coach/README.fr.md | 2 +- rhythm_coach/README.md | 2 +- rhythm_coach/pubspec.yaml | 2 +- 9 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e269565..5374d8b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ assignees: '' ## Environnement / Environment -- **Version BeatBitch** : +- **Version BeatBitch** : - **Plateforme / Platform** : - **OS** : - **Langue dans l'app / App language** : diff --git a/CHANGELOG.md b/CHANGELOG.md index c026898..8fc4165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Non publié] +## [0.5.3] — 2026-05-31 + +Correctif de progression carrière : le tutoriel pouvait revenir alors qu'il était déjà fait. + +### Corrigé +- **Le tutoriel d'introduction revenait à la séance suivante** — quand un défi validait une compétence en cours de première séance (ex. tenir la gorge), il consolidait les paliers qui en dépendent mais pas le palier d'introduction lui-même, resté « à faire ». Résultat : la séance d'après reproposait le tutoriel déjà accompli, sans jamais pouvoir s'en débarrasser. Les paliers-prérequis sont désormais consolidés avec leurs enfants, et un rattrapage au démarrage répare les profils déjà touchés (sans remise à zéro). + ## [0.5.2] — 2026-05-31 Correctifs ciblés sur les **défis intra-séance** (deux retours iOS) plus un nouveau geste d'input pour les défis dynamiques. @@ -187,7 +194,8 @@ Grosse mise à jour du mode carrière : nouvelle enveloppe de difficulté, nouve ## [0.1.0] — 2026-05-08 - Premier release public : coach vocal rythmique hors-ligne pour Android, adult gate 18+, onboarding, mode carrière + scénarios, badges, profil/réputation. -[Non publié]: https://github.com/bbstudioapp/beatbitch/compare/v0.5.2...develop +[Non publié]: https://github.com/bbstudioapp/beatbitch/compare/v0.5.3...develop +[0.5.3]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.3 [0.5.2]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.2 [0.5.1]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.1 [0.5.0]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.0 diff --git a/README.de.md b/README.de.md index b235c28..764190d 100644 --- a/README.de.md +++ b/README.de.md @@ -1,6 +1,6 @@ # BeatBitch -![version](https://img.shields.io/badge/version-0.5.2-orange) +![version](https://img.shields.io/badge/version-0.5.3-orange) ![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux%20%7C%20iOS%20%7C%20Web-blue) ![offline](https://img.shields.io/badge/100%25-offline-blue) ![no tracking](https://img.shields.io/badge/no-tracking-success) diff --git a/README.fr.md b/README.fr.md index 6a9702c..c9a91ea 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,6 +1,6 @@ # BeatBitch -![version](https://img.shields.io/badge/version-0.5.2-orange) +![version](https://img.shields.io/badge/version-0.5.3-orange) ![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux%20%7C%20iOS%20%7C%20Web-blue) ![offline](https://img.shields.io/badge/100%25-offline-blue) ![no tracking](https://img.shields.io/badge/no-tracking-success) diff --git a/README.md b/README.md index a25dc89..531c911 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BeatBitch -![version](https://img.shields.io/badge/version-0.5.2-orange) +![version](https://img.shields.io/badge/version-0.5.3-orange) ![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux%20%7C%20iOS%20%7C%20Web-blue) ![offline](https://img.shields.io/badge/100%25-offline-blue) ![no tracking](https://img.shields.io/badge/no-tracking-success) diff --git a/rhythm_coach/README.de.md b/rhythm_coach/README.de.md index 93a28a4..1b09d7c 100644 --- a/rhythm_coach/README.de.md +++ b/rhythm_coach/README.de.md @@ -1,6 +1,6 @@ # BeatBitch -![version](https://img.shields.io/badge/version-0.5.2-orange) +![version](https://img.shields.io/badge/version-0.5.3-orange) ![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux-blue) ![offline](https://img.shields.io/badge/100%25-offline-blue) diff --git a/rhythm_coach/README.fr.md b/rhythm_coach/README.fr.md index 9231e90..1a37ee3 100644 --- a/rhythm_coach/README.fr.md +++ b/rhythm_coach/README.fr.md @@ -1,6 +1,6 @@ # BeatBitch -![version](https://img.shields.io/badge/version-0.5.2-orange) +![version](https://img.shields.io/badge/version-0.5.3-orange) ![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux-blue) ![offline](https://img.shields.io/badge/100%25-offline-blue) diff --git a/rhythm_coach/README.md b/rhythm_coach/README.md index c657186..65facbd 100644 --- a/rhythm_coach/README.md +++ b/rhythm_coach/README.md @@ -1,6 +1,6 @@ # BeatBitch -![version](https://img.shields.io/badge/version-0.5.2-orange) +![version](https://img.shields.io/badge/version-0.5.3-orange) ![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux-blue) ![offline](https://img.shields.io/badge/100%25-offline-blue) diff --git a/rhythm_coach/pubspec.yaml b/rhythm_coach/pubspec.yaml index f9dfdae..c43402b 100644 --- a/rhythm_coach/pubspec.yaml +++ b/rhythm_coach/pubspec.yaml @@ -1,7 +1,7 @@ name: beat_bitch description: An offline rhythmic voice coach for Android. publish_to: 'none' -version: 0.5.2+13 +version: 0.5.3+14 environment: sdk: '>=3.3.0 <4.0.0'