diff --git a/rhythm_coach/assets/career/phrases.json b/rhythm_coach/assets/career/phrases.json index 0e40f57..e43a63d 100644 --- a/rhythm_coach/assets/career/phrases.json +++ b/rhythm_coach/assets/career/phrases.json @@ -435,5 +435,54 @@ "Moins profond, là.", "Reste plus haute." ] + }, + "break_entry": [ + "On souffle. Deux minutes pour toi.", + "Pause. Tu récupères, mais tu restes à moi.", + "On marque une pause. Respire.", + "Stop. On relâche un instant." + ], + "break_orders": [ + "Bois une gorgée d'eau.", + "Respire à fond, par le ventre.", + "Regarde-toi dans la glace.", + "Détends ta mâchoire.", + "Essuie-toi, remets-toi en place.", + "Roule les épaules, relâche la nuque.", + "Hydrate-toi.", + "Ferme les yeux, respire calmement." + ], + "break_resume": [ + "On reprend.", + "Fini de souffler. On y retourne.", + "Pause terminée, reprends.", + "Allez, on reprend le travail." + ], + "break_posture": { + "sitting": [ + "Assieds-toi, bien droite.", + "Pose-toi, assise.", + "Mets-toi assise, dos droit." + ], + "standing": [ + "Lève-toi. Debout.", + "Mets-toi debout.", + "Debout, bien droite." + ], + "kneeling": [ + "À genoux, maintenant.", + "Mets-toi à genoux.", + "À genoux, c'est ta place." + ], + "all_fours": [ + "À quatre pattes.", + "Mets-toi à quatre pattes.", + "À quatre pattes, comme une chienne." + ], + "on_back": [ + "Allonge-toi sur le dos.", + "Sur le dos, maintenant.", + "Sur le dos, la tête en arrière." + ] } } diff --git a/rhythm_coach/assets/career/phrases_de.json b/rhythm_coach/assets/career/phrases_de.json index 0792c41..8fb5ef1 100644 --- a/rhythm_coach/assets/career/phrases_de.json +++ b/rhythm_coach/assets/career/phrases_de.json @@ -435,5 +435,54 @@ "Weniger tief, so.", "Bleib weiter oben." ] + }, + "break_entry": [ + "Durchatmen. Zwei Minuten für dich.", + "Pause. Du erholst dich, aber du bleibst mein.", + "Wir machen kurz Pause. Atme.", + "Stopp. Lass einen Moment locker." + ], + "break_orders": [ + "Trink einen Schluck Wasser.", + "Atme tief, aus dem Bauch.", + "Sieh dich im Spiegel an.", + "Entspann deinen Kiefer.", + "Wisch dich ab, mach dich zurecht.", + "Roll die Schultern, lockere den Nacken.", + "Trink etwas.", + "Schließ die Augen, atme ruhig." + ], + "break_resume": [ + "Weiter geht's.", + "Genug verschnauft. Zurück an die Arbeit.", + "Pause vorbei, mach weiter.", + "Los, weiter." + ], + "break_posture": { + "sitting": [ + "Setz dich hin, schön gerade.", + "Setz dich, sitzend.", + "Setz dich, Rücken gerade." + ], + "standing": [ + "Steh auf. Aufrecht.", + "Stell dich hin.", + "Hoch, steh gerade." + ], + "kneeling": [ + "Auf die Knie, jetzt.", + "Knie dich hin.", + "Auf die Knie, da gehörst du hin." + ], + "all_fours": [ + "Auf alle viere.", + "Geh auf alle viere.", + "Auf alle viere, wie eine Hündin." + ], + "on_back": [ + "Leg dich auf den Rücken.", + "Auf den Rücken, jetzt.", + "Auf den Rücken, Kopf nach hinten." + ] } } diff --git a/rhythm_coach/assets/career/phrases_en.json b/rhythm_coach/assets/career/phrases_en.json index fa97ea5..a6f48f2 100644 --- a/rhythm_coach/assets/career/phrases_en.json +++ b/rhythm_coach/assets/career/phrases_en.json @@ -435,5 +435,54 @@ "Less deep, there.", "Stay higher." ] + }, + "break_entry": [ + "Breathe. Two minutes for you.", + "Break. You recover, but you stay mine.", + "We pause here. Breathe.", + "Stop. Ease off for a moment." + ], + "break_orders": [ + "Take a sip of water.", + "Breathe deep, from the belly.", + "Look at yourself in the mirror.", + "Relax your jaw.", + "Wipe yourself, set yourself right.", + "Roll your shoulders, loosen your neck.", + "Hydrate.", + "Close your eyes, breathe slowly." + ], + "break_resume": [ + "We resume.", + "Done catching your breath. Back to it.", + "Break's over, pick it up.", + "Come on, back to work." + ], + "break_posture": { + "sitting": [ + "Sit down, nice and straight.", + "Settle down, seated.", + "Sit, back straight." + ], + "standing": [ + "Get up. On your feet.", + "Stand up.", + "Up, stand straight." + ], + "kneeling": [ + "On your knees, now.", + "Get on your knees.", + "On your knees, where you belong." + ], + "all_fours": [ + "On all fours.", + "Get on all fours.", + "On all fours, like a bitch." + ], + "on_back": [ + "Lie on your back.", + "On your back, now.", + "On your back, head tipped back." + ] } } diff --git a/rhythm_coach/assets/career/phrases_es.json b/rhythm_coach/assets/career/phrases_es.json index 0a65f8f..849dc4a 100644 --- a/rhythm_coach/assets/career/phrases_es.json +++ b/rhythm_coach/assets/career/phrases_es.json @@ -435,5 +435,54 @@ "Menos profundo, así.", "Quédate más arriba." ] + }, + "break_entry": [ + "Respira. Dos minutos para ti.", + "Pausa. Te recuperas, pero sigues siendo mía.", + "Paramos un momento. Respira.", + "Para. Afloja un instante." + ], + "break_orders": [ + "Bebe un trago de agua.", + "Respira hondo, desde el vientre.", + "Mírate en el espejo.", + "Relaja la mandíbula.", + "Límpiate, recolócate.", + "Gira los hombros, suelta el cuello.", + "Hidrátate.", + "Cierra los ojos, respira tranquila." + ], + "break_resume": [ + "Seguimos.", + "Ya has respirado. Volvemos.", + "Se acabó la pausa, sigue.", + "Vamos, de vuelta al trabajo." + ], + "break_posture": { + "sitting": [ + "Siéntate, bien recta.", + "Ponte sentada.", + "Siéntate, espalda recta." + ], + "standing": [ + "Levántate. De pie.", + "Ponte de pie.", + "Arriba, bien recta." + ], + "kneeling": [ + "De rodillas, ahora.", + "Ponte de rodillas.", + "De rodillas, tu sitio." + ], + "all_fours": [ + "A cuatro patas.", + "Ponte a cuatro patas.", + "A cuatro patas, como una perra." + ], + "on_back": [ + "Túmbate de espaldas.", + "De espaldas, ahora.", + "Boca arriba, la cabeza hacia atrás." + ] } } diff --git a/rhythm_coach/lib/career/models/coach.dart b/rhythm_coach/lib/career/models/coach.dart index 6aca13d..32b2d10 100644 --- a/rhythm_coach/lib/career/models/coach.dart +++ b/rhythm_coach/lib/career/models/coach.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import '../../models/posture.dart'; import '../../models/session.dart'; import '../../models/session_step.dart'; import '../../services/random_comments_loader.dart'; @@ -1125,4 +1126,21 @@ class _CoachComposedPhraseBank extends PhraseBank { // déclinés par coach. return fallback.pickSwallowOrder(rng); } + + @override + String? pickBreakEntry(Random rng) { + // Délégation au pool global : phrases de break (issue #77) non encore + // déclinées par coach. + return fallback.pickBreakEntry(rng); + } + + @override + String? pickBreakOrder(Random rng) => fallback.pickBreakOrder(rng); + + @override + String? pickBreakResume(Random rng) => fallback.pickBreakResume(rng); + + @override + String? pickPostureChange(Posture pose, Random rng) => + fallback.pickPostureChange(pose, rng); } diff --git a/rhythm_coach/lib/career/models/phrase_bank.dart b/rhythm_coach/lib/career/models/phrase_bank.dart index bb07410..1ed6eda 100644 --- a/rhythm_coach/lib/career/models/phrase_bank.dart +++ b/rhythm_coach/lib/career/models/phrase_bank.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import '../../models/posture.dart'; import '../../models/session.dart'; import '../../models/session_step.dart'; import 'phrase_entry.dart'; @@ -99,6 +100,25 @@ class PhraseBank { /// varier le ton (impératif sec) sans polluer les autres pools beg. final List _swallowOrders; + /// Phrase d'entrée d'un break scénarisé (issue #77) — « on souffle deux + /// minutes ». Jouée à l'entrée de la fenêtre de break. + final List _breakEntry; + + /// Ordres intercalés pendant un break (hors changement de posture) — « bois + /// une gorgée », « respire à fond ». Tirés espacés par le runtime. Ne + /// doivent jamais contenir « tiens/hold/halten » (réservés au pool hold). + final List _breakOrders; + + /// Phrase de reprise d'un break qui ne change PAS de posture (récup pure) — + /// « on reprend ». Quand le break change de posture, le runtime préfère + /// `_breakPostureChange[newPose]`. + final List _breakResume; + + /// Ordre de changement de posture à la reprise d'un break, par posture + /// imposée (« mets-toi à quatre pattes », « à genoux maintenant »). Jamais + /// de clé pour [Posture.free] (aucune imposition). + final Map> _breakPostureChange; + const PhraseBank({ required Map>> byMode, required List congrats, @@ -113,6 +133,10 @@ class PhraseBank { List postFinalBeg = const [], List postFinalLick = const [], List swallowOrders = const [], + List breakEntry = const [], + List breakOrders = const [], + List breakResume = const [], + Map> breakPostureChange = const {}, }) : _byMode = byMode, _congrats = congrats, _intros = intros, @@ -125,7 +149,11 @@ class PhraseBank { _postFinal = postFinal, _postFinalBeg = postFinalBeg, _postFinalLick = postFinalLick, - _swallowOrders = swallowOrders; + _swallowOrders = swallowOrders, + _breakEntry = breakEntry, + _breakOrders = breakOrders, + _breakResume = breakResume, + _breakPostureChange = breakPostureChange; /// Tire une phrase pour [mode] dans le tier demandé. Si le tier est absent, /// fallback sur 'medium' puis 'any' puis première liste non vide. @@ -279,4 +307,27 @@ class PhraseBank { /// Retourne `null` si le pool est vide — l'appelant peut alors /// retomber sur le tier `hard` du mode beg comme fallback. String? pickSwallowOrder(Random rng) => pickPhraseEntry(_swallowOrders, rng); + + /// Tire la phrase d'entrée d'un break scénarisé (issue #77). Retourne `null` + /// si la banque n'a pas de pool `break_entry` — le runtime reste alors + /// silencieux à l'entrée. + String? pickBreakEntry(Random rng) => pickPhraseEntry(_breakEntry, rng); + + /// Tire un ordre intercalé pendant un break (hors posture). Retourne `null` + /// si le pool `break_orders` est vide. + String? pickBreakOrder(Random rng) => pickPhraseEntry(_breakOrders, rng); + + /// Tire la phrase de reprise d'un break sans changement de posture. Retourne + /// `null` si le pool `break_resume` est vide. + String? pickBreakResume(Random rng) => pickPhraseEntry(_breakResume, rng); + + /// Tire l'ordre de changement de posture à la reprise d'un break, pour la + /// [pose] imposée. Retourne `null` pour [Posture.free] ou si aucun pool + /// n'existe pour cette posture — le runtime retombe alors sur + /// [pickBreakResume]. + String? pickPostureChange(Posture pose, Random rng) { + final list = _breakPostureChange[pose]; + if (list == null) return null; + return pickPhraseEntry(list, rng); + } } diff --git a/rhythm_coach/lib/career/screens/career_screen.dart b/rhythm_coach/lib/career/screens/career_screen.dart index f8d93bd..3e05ed6 100644 --- a/rhythm_coach/lib/career/screens/career_screen.dart +++ b/rhythm_coach/lib/career/screens/career_screen.dart @@ -29,6 +29,7 @@ import '../services/career_difficulty_resolver.dart'; import '../services/career_encore_gate.dart'; import '../services/career_progress_service.dart'; import '../services/challenge_service.dart'; +import '../services/debug_settings_service.dart'; import '../services/generation/career_session_generator.dart'; import '../services/phrase_bank_loader.dart'; import '../services/specialization_service.dart'; @@ -102,6 +103,7 @@ class _CareerScreenState extends State { _challengeService.tutorialSeen(), _progress.getLastLengthChoice(), _stats.getTotalSeconds(), + DebugSettingsService().getScriptedBreaks(), ]); final capabilityProfile = results[8] as CapabilityProfile; final totalSeconds = results[12] as int; @@ -141,6 +143,7 @@ class _CareerScreenState extends State { lastLengthChoice: results[11] as SessionLengthChoice, totalSeconds: totalSeconds, synthLevel: CareerDifficultyResolver.synthLevelFor(completedSessions), + scriptedBreaks: results[13] as bool, ); } @@ -461,6 +464,7 @@ class _CareerScreenState extends State { // figé de plafond. capability: CapabilityInputs(profile: bundle.capabilityProfile), challenge: ChallengeInputs(challenges: challenges), + scriptedBreaks: bundle.scriptedBreaks, ); final introText = coachBank.pickIntro(Random()); @@ -1059,6 +1063,7 @@ class _CareerScreenState extends State { profile: bundle.capabilityProfile, sessionCeilings: previousSessionCeilings, ), + scriptedBreaks: bundle.scriptedBreaks, ); final camService = CameraMotionService(); @@ -2047,6 +2052,11 @@ class _CareerBundle { /// Sert au déblocage des coachs par investissement (Phase 19.10). final int totalSeconds; + /// Flag debug `debug.scripted_breaks` (issue #77). Quand `true`, les + /// postures imposées + breaks scénarisés sont activés : passé à + /// `generate(scriptedBreaks:)` à tous les call sites. + final bool scriptedBreaks; + const _CareerBundle({ required this.bank, required this.punishments, @@ -2062,5 +2072,6 @@ class _CareerBundle { required this.lastLengthChoice, required this.totalSeconds, required this.synthLevel, + required this.scriptedBreaks, }); } diff --git a/rhythm_coach/lib/career/services/debug_settings_service.dart b/rhythm_coach/lib/career/services/debug_settings_service.dart index 40468c4..f841757 100644 --- a/rhythm_coach/lib/career/services/debug_settings_service.dart +++ b/rhythm_coach/lib/career/services/debug_settings_service.dart @@ -14,6 +14,7 @@ class DebugSettingsService { static const String _kShowBackgroundMedia = 'pref.show_background_media'; static const String _kShowSessionRemainingTime = 'pref.show_session_remaining_time'; + static const String _kScriptedBreaks = 'debug.scripted_breaks'; Future getShowStaminaBar() async { final prefs = await SharedPreferences.getInstance(); @@ -157,4 +158,19 @@ class DebugSettingsService { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kShowSessionRemainingTime, value); } + + /// Quand true, active les postures imposées + breaks scénarisés en carrière + /// (issue #77) : posture tirée à l'intro et pauses actives de récup sur les + /// sessions longues. Off par défaut le temps de calibrer (cf. spec + /// `specs/scripted_breaks.md`). Lu par `career_screen` et passé au + /// générateur via `generate(scriptedBreaks:)`. + Future getScriptedBreaks() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_kScriptedBreaks) ?? false; + } + + Future setScriptedBreaks(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kScriptedBreaks, value); + } } diff --git a/rhythm_coach/lib/career/services/phrase_bank_loader.dart b/rhythm_coach/lib/career/services/phrase_bank_loader.dart index c716775..a7747a2 100644 --- a/rhythm_coach/lib/career/services/phrase_bank_loader.dart +++ b/rhythm_coach/lib/career/services/phrase_bank_loader.dart @@ -3,6 +3,7 @@ import 'dart:ui' show Locale; import 'package:flutter/services.dart' show rootBundle; +import '../../models/posture.dart'; import '../../models/session.dart'; import '../../services/locale_service.dart'; import '../models/phrase_bank.dart'; @@ -74,6 +75,19 @@ class PhraseBankLoader { }); } + // Breaks scénarisés (issue #77) : phrases d'entrée / ordres / reprise + + // changement de posture par posture (clé = `Posture.serialized`). + final breakPostureChange = >{}; + final breakPostureNode = data['break_posture']; + if (breakPostureNode is Map) { + breakPostureNode.forEach((key, phrases) { + final pose = Posture.fromString(key); + if (pose == Posture.free) return; // pas d'imposition pour free + final list = PhraseEntry.listFromJson(phrases); + if (list.isNotEmpty) breakPostureChange[pose] = list; + }); + } + return PhraseBank( byMode: byMode, congrats: PhraseEntry.listFromJson(data['congrats']), @@ -88,6 +102,10 @@ class PhraseBankLoader { postFinalBeg: PhraseEntry.listFromJson(data['post_final_beg']), postFinalLick: PhraseEntry.listFromJson(data['post_final_lick']), swallowOrders: PhraseEntry.listFromJson(data['swallow_order']), + breakEntry: PhraseEntry.listFromJson(data['break_entry']), + breakOrders: PhraseEntry.listFromJson(data['break_orders']), + breakResume: PhraseEntry.listFromJson(data['break_resume']), + breakPostureChange: breakPostureChange, ); } diff --git a/rhythm_coach/lib/controllers/session_controller.dart b/rhythm_coach/lib/controllers/session_controller.dart index dfbb83d..83795d7 100644 --- a/rhythm_coach/lib/controllers/session_controller.dart +++ b/rhythm_coach/lib/controllers/session_controller.dart @@ -14,6 +14,7 @@ import '../career/services/specialization_service.dart'; import '../l10n/app_localizations.dart'; import '../main.dart' show milestoneService; import '../models/punishment.dart'; +import '../models/posture.dart'; import '../models/session.dart'; import '../services/ambience_engine.dart'; import '../services/backgrounds_service.dart'; @@ -35,6 +36,7 @@ import '../services/tts_service.dart'; part 'session_controller_challenge.dart'; part 'session_controller_fail_flow.dart'; part 'session_controller_career_hooks.dart'; +part 'session_controller_break.dart'; enum SessionState { idle, running, paused, finished, failing } @@ -177,6 +179,10 @@ class SessionController extends ChangeNotifier { /// post-fail est inutile. static const double _breathSkipStaminaThreshold = 60.0; + /// Intervalle minimal (s) entre deux ordres énoncés pendant un break + /// scénarisé (issue #77). ~1 ordre toutes les 25 s sur une pause de 60-120 s. + static const int _breakOrderIntervalSeconds = 25; + final Stopwatch _stopwatch = Stopwatch(); /// Offset cumulatif ajouté à `_stopwatch.elapsed` pour calculer le temps @@ -375,6 +381,38 @@ class SessionController extends ChangeNotifier { /// timeout atSeuil) indépendamment du gel du timer session. double get _realSec => _stopwatch.elapsedMilliseconds / 1000.0; + // ─── État du break scénarisé (issue #77) ────────────────────────────── + // Pause active de récup sur les sessions longues (cf. spec + // `specs/scripted_breaks.md`). Contrairement au flow fail, l'horloge + // `elapsed` continue (le générateur a laissé un trou d'effort dans + // l'enveloppe) : la machine est pilotée par tick (`_updateBreakPhase`), + // pas par un flow async. Les méthodes vivent dans + // `session_controller_break.dart` (part of). + + /// True tant qu'on est dans la fenêtre `[break.time, break.endTime)` d'un + /// break. Gèle l'accrual d'effort et suspend les commentaires aléatoires. + bool _breakActive = false; + bool get breakActive => _breakActive; + + /// Break en cours, ou `null` hors fenêtre de break. Exposé pour l'UI + /// (overlay PAUSE + décompte — PR5). + ScriptedBreak? _activeBreak; + ScriptedBreak? get activeBreak => _activeBreak; + + /// Index du prochain break à déclencher dans `session.breaks` (ordonnés + /// par `time`). Avancé à chaque entrée de break. + int _nextBreakIndex = 0; + + /// `elapsedSeconds` du dernier ordre de break énoncé. Sert à espacer les + /// ordres (`_breakOrderIntervalSeconds`). + int _breakOrderLastAtSec = 0; + + /// Posture courante imposée (issue #77). Initialisée à + /// `session.initialPose` au `start()`, mise à jour à la reprise de chaque + /// break qui change de pose. Exposée pour l'indicateur de posture (PR5). + Posture _currentPose = Posture.free; + Posture get currentPose => _currentPose; + // ─── Commentaires aléatoires ─────────────────────────────────────────── Timer? _randomCommentTimer; @@ -900,6 +938,13 @@ class SessionController extends ChangeNotifier { _miniPunishmentTickAccumulator = 0; _miniPunishmentsTriggered = 0; _announcedProgressMarkers.clear(); + // Break scénarisé (issue #77) : repart de la posture initiale tirée + // par le générateur (`free` hors carrière / flag off). + _breakActive = false; + _activeBreak = null; + _nextBreakIndex = 0; + _breakOrderLastAtSec = 0; + _currentPose = _session.initialPose; _capabilityTracker?.onSessionStart(); // Seed neutre : remplacé par les valeurs persistées dès que la // lecture async (plus bas) revient. `seedHumiliationSession` @@ -1072,9 +1117,19 @@ class SessionController extends ChangeNotifier { // on enchaîne sur autre chose (rythme → hold head…) alors que l'UI // attend toujours la décision joueuse au seuil. _updateChallengePhase(); + // Break scénarisé (issue #77) : entrée/sortie + ordres espacés, AVANT + // `_checkSteps`. À l'entrée d'un break la machine pause le beep ; à la + // sortie elle relâche `_breakActive` → `_checkSteps` (ci-dessous) applique + // alors le step d'effort posé par le générateur juste après le trou. + _updateBreakPhase(); _checkSteps(); - _accrueHoldSecond(); - _checkProgressMarkers(); + // Gel de l'effort pendant un break : pas de crédit hold/saliva/stamina/ + // mini-punition ni de marqueurs de progression (la pause est de la récup + // mise en scène, pas de l'effort). L'horloge `elapsed`, elle, continue. + if (!_breakActive) { + _accrueHoldSecond(); + _checkProgressMarkers(); + } // Freeze la timeline session pendant TOUTE la durée du défi (breath // d'attente joueuse + countdown + step défi + atSeuil + extensions + // breath post-défi). Le défi est intégralement hors du décompte de @@ -1916,6 +1971,16 @@ class SessionController extends ChangeNotifier { return; } + // Pas de random pendant un break scénarisé (issue #77) : la dramaturgie + // est pilotée par le séquenceur du break (phrase d'entrée + ordres + // espacés + reprise). On reporte de 3 s ; la fenêtre se referme d'elle- + // même à la fin du break. + if (_breakActive) { + _randomCommentTimer = + Timer(const Duration(seconds: 3), _fireRandomComment); + return; + } + // Pas de random pendant tout un défi (breath d'annonce + countdown + // step défi + atSeuil + extensions + breath post-défi). Pendant cette // fenêtre, la dramaturgie est entièrement pilotée par les phrases diff --git a/rhythm_coach/lib/controllers/session_controller_break.dart b/rhythm_coach/lib/controllers/session_controller_break.dart new file mode 100644 index 0000000..5f5382a --- /dev/null +++ b/rhythm_coach/lib/controllers/session_controller_break.dart @@ -0,0 +1,109 @@ +part of 'session_controller.dart'; + +// ─── Break scénarisé (issue #77) ────────────────────────────────────────── +// +// Pause active de récup imposée sur les sessions longues (cf. spec locale +// `specs/scripted_breaks.md`). Le générateur a déjà laissé un *trou d'effort* +// dans l'enveloppe (aucun step entre `break.time` et `break.endTime`) ; le +// runtime se contente de geler le moteur d'effort sur cette fenêtre et de +// jouer une dramaturgie de récup : +// +// entrée → pause du beep + phrase « on souffle » +// pendant → ordres espacés (« bois une gorgée », « respire »…) +// reprise → application de la nouvelle posture + phrase de changement de +// pose (ou phrase de reprise neutre), puis le step d'effort posé +// par le générateur juste après le trou reconfigure le beep. +// +// Contrairement au flow fail, l'horloge `elapsed` continue de tourner : la +// machine est donc pilotée par tick (`_updateBreakPhase`, appelée dans +// `_onTick`), sans flow async ni manipulation du `_timelineOffset`. Les +// champs d'état (`_breakActive`, `_activeBreak`, `_nextBreakIndex`, +// `_breakOrderLastAtSec`, `_currentPose`) vivent sur `SessionController` (les +// extensions Dart ne portent pas de champs). + +extension BreakSequencer on SessionController { + /// Pilote la machine du break à chaque tick. Entrée dans la fenêtre du + /// prochain break, énoncé des ordres espacés en cours, sortie à + /// `break.endTime`. No-op si la session n'a pas de break. + void _updateBreakPhase() { + final breaks = _session.breaks; + if (breaks.isEmpty) return; + final now = elapsedSeconds; + + if (_breakActive) { + final b = _activeBreak!; + if (now >= b.endTime) { + _exitBreak(b); + } else { + _maybeFireBreakOrder(); + } + return; + } + + if (_nextBreakIndex >= breaks.length) return; + final next = breaks[_nextBreakIndex]; + if (now >= next.endTime) { + // Fenêtre déjà entièrement dépassée (cas dégénéré : saut de timeline, + // bouton debug). On ne gèle pas après coup — on jette ce break. + _nextBreakIndex++; + return; + } + if (now >= next.time) { + _enterBreak(next); + } + } + + /// Entrée dans un break : gèle le loop d'effort (pause beep + disarm caméra) + /// et énonce la phrase d'entrée. `_breakActive` coupe l'accrual d'effort et + /// les commentaires aléatoires (cf. `_onTick` / `_fireRandomComment`). + void _enterBreak(ScriptedBreak b) { + _breakActive = true; + _activeBreak = b; + _nextBreakIndex++; + _breakOrderLastAtSec = elapsedSeconds; + _disarmHoldVerifier(); + unawaited(_beep.pause()); + final entry = _phraseBank?.pickBreakEntry(_random); + if (entry != null) _speakScripted(entry); + _notify(); + } + + /// Énonce un ordre de break si l'intervalle est écoulé et que le TTS est + /// libre. Les ordres viennent du pool global `break_orders` (jamais + /// « tiens/hold/halten » — réservés au pool hold). + void _maybeFireBreakOrder() { + final bank = _phraseBank; + if (bank == null) return; + final now = elapsedSeconds; + if (now - _breakOrderLastAtSec < + SessionController._breakOrderIntervalSeconds) { + return; + } + if (_tts.isSpeaking) return; + final order = bank.pickBreakOrder(_random); + if (order == null) return; + _breakOrderLastAtSec = now; + _speakScripted(order); + } + + /// Sortie d'un break : applique la nouvelle posture, énonce la phrase de + /// changement de pose (ou de reprise neutre en récup pure), et relâche + /// `_breakActive`. Le beep n'est PAS restauré ici : `_checkSteps` (appelé + /// juste après dans `_onTick`) applique le step d'effort que le générateur + /// a posé à `break.endTime`, ce qui reconfigure et relance le loop. Tant + /// que la phrase de reprise parle, l'anti-coupure de `_checkSteps` diffère + /// ce step → silence propre pendant l'annonce. + void _exitBreak(ScriptedBreak b) { + _breakActive = false; + _activeBreak = null; + final newPose = b.newPose; + if (newPose != null) _currentPose = newPose; + final bank = _phraseBank; + final resume = newPose != null + ? (bank?.pickPostureChange(newPose, _random) ?? + bank?.pickBreakResume(_random)) + : bank?.pickBreakResume(_random); + if (resume != null) _speakScripted(resume); + _notify(); + } +} diff --git a/rhythm_coach/lib/controllers/session_controller_career_hooks.dart b/rhythm_coach/lib/controllers/session_controller_career_hooks.dart index 3dfad75..f4b281a 100644 --- a/rhythm_coach/lib/controllers/session_controller_career_hooks.dart +++ b/rhythm_coach/lib/controllers/session_controller_career_hooks.dart @@ -136,6 +136,15 @@ extension CareerHooksOrchestrator on SessionController { _nextStepIndex = 0; _lastConfigStep = null; + // Reset de l'état break (issue #77) : la session régénérée n'a pas de + // break (`scriptedBreaks` non propagé aux régens mi-séance). Si un break + // était actif au moment du Supplier, on le clôt ici — sinon `_breakActive` + // resterait coincé à true (la nouvelle `breaks` vide fait sortir + // `_updateBreakPhase` tôt) et l'effort serait gelé. `_currentPose` est + // préservée (continuité de pose à travers l'upgrade). + _breakActive = false; + _activeBreak = null; + _nextBreakIndex = 0; // Reset du flag chime : la régen apporte son propre step final + // apothéose. Si l'ancienne session avait déjà tiré son chime (cas // rare où Supplier est cliqué pile entre final et fin), on doit @@ -184,6 +193,11 @@ extension CareerHooksOrchestrator on SessionController { _nextStepIndex = 0; _lastConfigStep = null; + // Reset de l'état break (issue #77), même raison que `requestUpgrade` : + // la suite régénérée n'a pas de break, on évite un `_breakActive` coincé. + _breakActive = false; + _activeBreak = null; + _nextBreakIndex = 0; // Reset du flag chime : la régen apporte son propre step final + apothéose. _finalChimePlayed = false; _finaleChimeStarted = false; diff --git a/rhythm_coach/lib/l10n/app_de.arb b/rhythm_coach/lib/l10n/app_de.arb index 7e7247d..4e6ee17 100644 --- a/rhythm_coach/lib/l10n/app_de.arb +++ b/rhythm_coach/lib/l10n/app_de.arb @@ -612,6 +612,8 @@ "soundsDebugCameraHoldCheckSubtitle": "Während der Holds prüft die Frontkamera, ob die Position gehalten wird. Der Coach gibt einen kurzen Hinweis, wenn du abdriftest. Erfordert eine kalibrierte Kamera (Kamera-Symbol im SZENARIO-Bildschirm).", "soundsDebugSkipSession": "Knopf „als Erfolg beenden“", "soundsDebugSkipSessionSubtitle": "Zeigt einen Knopf in der Session, der sie sofort als vollen Erfolg beendet (Abzeichen, Meilensteine, Stufe). Praktisch, um an Inhalten zu feilen, ohne alles durchzuspielen.", + "soundsDebugScriptedBreaks": "Vorgegebene Posen + Breaks", + "soundsDebugScriptedBreaksSubtitle": "Aktiviert vorgegebene Posen (kniend, auf allen vieren…) und aktive Erholungspausen in langen Sessions. Experimentell, in Kalibrierung.", "soundsShowBackgroundMedia": "Hintergrundmedien in der Session", "soundsShowBackgroundMediaSubtitle": "Zeigt die Bilder/GIFs aus assets/backgrounds/ im Hintergrund, mit Wechsel bei jedem Step. Deaktivieren, um nur den Ambiente-Verlauf zu sehen.", "sessionDebugFinishButton": "DEBUG: als Erfolg beenden", diff --git a/rhythm_coach/lib/l10n/app_en.arb b/rhythm_coach/lib/l10n/app_en.arb index 07c14c5..bba4854 100644 --- a/rhythm_coach/lib/l10n/app_en.arb +++ b/rhythm_coach/lib/l10n/app_en.arb @@ -612,6 +612,8 @@ "soundsDebugCameraHoldCheckSubtitle": "During holds, the front camera checks that the position is being held. The coach gives a short cue if you drift. Requires the camera to be calibrated (camera icon in the SCENARIO screen).", "soundsDebugSkipSession": "“Finish as success” button", "soundsDebugSkipSessionSubtitle": "Shows a button in the session that ends it immediately as a full success (badges, milestones, level). Useful to iterate on content without playing through.", + "soundsDebugScriptedBreaks": "Scripted postures + breaks", + "soundsDebugScriptedBreaksSubtitle": "Enables imposed postures (kneeling, on all fours…) and active recovery breaks on long sessions. Experimental, being calibrated.", "soundsShowBackgroundMedia": "Background media in session", "soundsShowBackgroundMediaSubtitle": "Shows images/GIFs from assets/backgrounds/ in the background, rotating each step. Disable to see only the ambience gradient.", "sessionDebugFinishButton": "DEBUG: finish as success", diff --git a/rhythm_coach/lib/l10n/app_es.arb b/rhythm_coach/lib/l10n/app_es.arb index 76f314e..05e316c 100644 --- a/rhythm_coach/lib/l10n/app_es.arb +++ b/rhythm_coach/lib/l10n/app_es.arb @@ -612,6 +612,8 @@ "soundsDebugCameraHoldCheckSubtitle": "Durante los holds, la cámara frontal comprueba que se mantiene la posición. El coach te avisa breve si te desvías. Requiere calibrar la cámara antes (icono cámara en la pantalla ESCENARIO).", "soundsDebugSkipSession": "Botón «Terminar como éxito»", "soundsDebugSkipSessionSubtitle": "Muestra un botón en la sesión que la termina inmediatamente como éxito total (insignias, hitos, nivel). Útil para iterar sobre el contenido sin jugar.", + "soundsDebugScriptedBreaks": "Posturas + pausas guionizadas", + "soundsDebugScriptedBreaksSubtitle": "Activa las posturas impuestas (de rodillas, a cuatro patas…) y las pausas activas de recuperación en sesiones largas. Experimental, en calibración.", "soundsShowBackgroundMedia": "Medios de fondo en sesión", "soundsShowBackgroundMediaSubtitle": "Muestra imágenes/GIFs de assets/backgrounds/ en el fondo, rotando con cada paso. Desactívalo para ver solo el degradado de ambiente.", "sessionDebugFinishButton": "DEBUG: terminar como éxito", diff --git a/rhythm_coach/lib/l10n/app_fr.arb b/rhythm_coach/lib/l10n/app_fr.arb index 3126b49..7138351 100644 --- a/rhythm_coach/lib/l10n/app_fr.arb +++ b/rhythm_coach/lib/l10n/app_fr.arb @@ -612,6 +612,8 @@ "soundsDebugCameraHoldCheckSubtitle": "Pendant les holds, la caméra avant vérifie que la position est tenue. Le coach lance un rappel court si tu dérives. Nécessite d'avoir calibré la caméra (icône caméra de l'écran SCÉNARIO).", "soundsDebugSkipSession": "Bouton « terminer en succès »", "soundsDebugSkipSessionSubtitle": "Affiche un bouton dans la séance qui termine immédiatement comme un succès complet (badges, milestones, niveau). Pratique pour itérer sur le contenu sans tout jouer.", + "soundsDebugScriptedBreaks": "Postures + breaks scénarisés", + "soundsDebugScriptedBreaksSubtitle": "Active les postures imposées (à genoux, à quatre pattes…) et les pauses actives de récup sur les sessions longues. Expérimental, en cours de calibration.", "soundsShowBackgroundMedia": "Fonds média en séance", "soundsShowBackgroundMediaSubtitle": "Affiche les images/GIF présents dans assets/backgrounds/ en arrière-plan, avec rotation à chaque step. Désactive pour ne voir que le dégradé d'ambiance.", "sessionDebugFinishButton": "DEBUG : terminer en succès", diff --git a/rhythm_coach/lib/l10n/app_localizations.dart b/rhythm_coach/lib/l10n/app_localizations.dart index ec022ea..679f958 100644 --- a/rhythm_coach/lib/l10n/app_localizations.dart +++ b/rhythm_coach/lib/l10n/app_localizations.dart @@ -2232,6 +2232,18 @@ abstract class AppLocalizations { /// **'Affiche un bouton dans la séance qui termine immédiatement comme un succès complet (badges, milestones, niveau). Pratique pour itérer sur le contenu sans tout jouer.'** String get soundsDebugSkipSessionSubtitle; + /// No description provided for @soundsDebugScriptedBreaks. + /// + /// In fr, this message translates to: + /// **'Postures + breaks scénarisés'** + String get soundsDebugScriptedBreaks; + + /// No description provided for @soundsDebugScriptedBreaksSubtitle. + /// + /// In fr, this message translates to: + /// **'Active les postures imposées (à genoux, à quatre pattes…) et les pauses actives de récup sur les sessions longues. Expérimental, en cours de calibration.'** + String get soundsDebugScriptedBreaksSubtitle; + /// No description provided for @soundsShowBackgroundMedia. /// /// In fr, this message translates to: diff --git a/rhythm_coach/lib/l10n/app_localizations_de.dart b/rhythm_coach/lib/l10n/app_localizations_de.dart index b4954bc..8d99dd6 100644 --- a/rhythm_coach/lib/l10n/app_localizations_de.dart +++ b/rhythm_coach/lib/l10n/app_localizations_de.dart @@ -1259,6 +1259,13 @@ class AppLocalizationsDe extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Zeigt einen Knopf in der Session, der sie sofort als vollen Erfolg beendet (Abzeichen, Meilensteine, Stufe). Praktisch, um an Inhalten zu feilen, ohne alles durchzuspielen.'; + @override + String get soundsDebugScriptedBreaks => 'Vorgegebene Posen + Breaks'; + + @override + String get soundsDebugScriptedBreaksSubtitle => + 'Aktiviert vorgegebene Posen (kniend, auf allen vieren…) und aktive Erholungspausen in langen Sessions. Experimentell, in Kalibrierung.'; + @override String get soundsShowBackgroundMedia => 'Hintergrundmedien in der Session'; diff --git a/rhythm_coach/lib/l10n/app_localizations_en.dart b/rhythm_coach/lib/l10n/app_localizations_en.dart index 7441bbc..36832b1 100644 --- a/rhythm_coach/lib/l10n/app_localizations_en.dart +++ b/rhythm_coach/lib/l10n/app_localizations_en.dart @@ -1254,6 +1254,13 @@ class AppLocalizationsEn extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Shows a button in the session that ends it immediately as a full success (badges, milestones, level). Useful to iterate on content without playing through.'; + @override + String get soundsDebugScriptedBreaks => 'Scripted postures + breaks'; + + @override + String get soundsDebugScriptedBreaksSubtitle => + 'Enables imposed postures (kneeling, on all fours…) and active recovery breaks on long sessions. Experimental, being calibrated.'; + @override String get soundsShowBackgroundMedia => 'Background media in session'; diff --git a/rhythm_coach/lib/l10n/app_localizations_es.dart b/rhythm_coach/lib/l10n/app_localizations_es.dart index 367f2e4..2dfd2c6 100644 --- a/rhythm_coach/lib/l10n/app_localizations_es.dart +++ b/rhythm_coach/lib/l10n/app_localizations_es.dart @@ -1259,6 +1259,13 @@ class AppLocalizationsEs extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Muestra un botón en la sesión que la termina inmediatamente como éxito total (insignias, hitos, nivel). Útil para iterar sobre el contenido sin jugar.'; + @override + String get soundsDebugScriptedBreaks => 'Posturas + pausas guionizadas'; + + @override + String get soundsDebugScriptedBreaksSubtitle => + 'Activa las posturas impuestas (de rodillas, a cuatro patas…) y las pausas activas de recuperación en sesiones largas. Experimental, en calibración.'; + @override String get soundsShowBackgroundMedia => 'Medios de fondo en sesión'; diff --git a/rhythm_coach/lib/l10n/app_localizations_fr.dart b/rhythm_coach/lib/l10n/app_localizations_fr.dart index 187f72d..f298d8e 100644 --- a/rhythm_coach/lib/l10n/app_localizations_fr.dart +++ b/rhythm_coach/lib/l10n/app_localizations_fr.dart @@ -1262,6 +1262,13 @@ class AppLocalizationsFr extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Affiche un bouton dans la séance qui termine immédiatement comme un succès complet (badges, milestones, niveau). Pratique pour itérer sur le contenu sans tout jouer.'; + @override + String get soundsDebugScriptedBreaks => 'Postures + breaks scénarisés'; + + @override + String get soundsDebugScriptedBreaksSubtitle => + 'Active les postures imposées (à genoux, à quatre pattes…) et les pauses actives de récup sur les sessions longues. Expérimental, en cours de calibration.'; + @override String get soundsShowBackgroundMedia => 'Fonds média en séance'; diff --git a/rhythm_coach/lib/screens/sound_demo_screen.dart b/rhythm_coach/lib/screens/sound_demo_screen.dart index 031a575..729bccb 100644 --- a/rhythm_coach/lib/screens/sound_demo_screen.dart +++ b/rhythm_coach/lib/screens/sound_demo_screen.dart @@ -57,6 +57,7 @@ class _SoundDemoScreenState extends State { bool _showBackgroundMedia = true; bool _cameraHoldCheck = false; bool _skipSessionButton = false; + bool _scriptedBreaks = false; @override void initState() { @@ -79,6 +80,7 @@ class _SoundDemoScreenState extends State { final showBgMedia = await _debug.getShowBackgroundMedia(); final camCheck = await _debug.getCameraHoldCheck(); final skipSession = await _debug.getSkipSessionButton(); + final scriptedBreaks = await _debug.getScriptedBreaks(); if (!mounted) return; setState(() { _ready = true; @@ -92,6 +94,7 @@ class _SoundDemoScreenState extends State { _showBackgroundMedia = showBgMedia; _cameraHoldCheck = camCheck; _skipSessionButton = skipSession; + _scriptedBreaks = scriptedBreaks; }); }); } @@ -558,6 +561,29 @@ class _SoundDemoScreenState extends State { setState(() => _skipSessionButton = v); }, ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + t.soundsDebugScriptedBreaks, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + subtitle: Text( + t.soundsDebugScriptedBreaksSubtitle, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textMuted, + ), + ), + value: _scriptedBreaks, + onChanged: (v) async { + await _debug.setScriptedBreaks(v); + if (!mounted) return; + setState(() => _scriptedBreaks = v); + }, + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon( diff --git a/rhythm_coach/test/break_phrases_test.dart b/rhythm_coach/test/break_phrases_test.dart new file mode 100644 index 0000000..e155a35 --- /dev/null +++ b/rhythm_coach/test/break_phrases_test.dart @@ -0,0 +1,116 @@ +import 'dart:math'; +import 'dart:ui' show Locale; + +import 'package:beat_bitch/career/models/coach.dart'; +import 'package:beat_bitch/career/models/coach_catalog.dart'; +import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/phrase_bank_loader.dart'; +import 'package:beat_bitch/models/posture.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Phrases de break scénarisé (issue #77) : pools `break_entry` / `break_orders` +/// / `break_resume` / `break_posture` dans PhraseBank + délégation composed + +/// parsing du loader sur les assets réels (4 langues). +void main() { + final rng = Random(7); + + group('PhraseBank — pick break', () { + const bank = PhraseBank( + byMode: {}, + congrats: [], + intros: [], + breakEntry: [PhraseEntry(text: 'entry')], + breakOrders: [PhraseEntry(text: 'order')], + breakResume: [PhraseEntry(text: 'resume')], + breakPostureChange: { + Posture.kneeling: [PhraseEntry(text: 'à genoux')], + }, + ); + + test('entry / order / resume tirés du pool', () { + expect(bank.pickBreakEntry(rng), 'entry'); + expect(bank.pickBreakOrder(rng), 'order'); + expect(bank.pickBreakResume(rng), 'resume'); + }); + + test('pickPostureChange : pool présent → phrase ; absent → null', () { + expect(bank.pickPostureChange(Posture.kneeling, rng), 'à genoux'); + expect(bank.pickPostureChange(Posture.allFours, rng), isNull); + }); + + test('pickPostureChange(free) → null (jamais d\'imposition)', () { + expect(bank.pickPostureChange(Posture.free, rng), isNull); + }); + + test('banque vide → null partout', () { + const empty = PhraseBank(byMode: {}, congrats: [], intros: []); + expect(empty.pickBreakEntry(rng), isNull); + expect(empty.pickBreakOrder(rng), isNull); + expect(empty.pickBreakResume(rng), isNull); + expect(empty.pickPostureChange(Posture.kneeling, rng), isNull); + }); + }); + + group('Coach.toPhraseBank — délégation break au global', () { + const globalBank = PhraseBank( + byMode: {}, + congrats: [], + intros: [], + breakEntry: [PhraseEntry(text: 'G_entry')], + breakOrders: [PhraseEntry(text: 'G_order')], + breakResume: [PhraseEntry(text: 'G_resume')], + breakPostureChange: { + Posture.allFours: [PhraseEntry(text: 'G_quatre_pattes')], + }, + ); + + test('coach sans break → délégation au pool global', () { + final coach = + CoachCatalog.defaults.first.withPhrases(const CoachPhrasePack()); + final bank = coach.toPhraseBank(fallback: globalBank); + expect(bank.pickBreakEntry(rng), 'G_entry'); + expect(bank.pickBreakOrder(rng), 'G_order'); + expect(bank.pickBreakResume(rng), 'G_resume'); + expect(bank.pickPostureChange(Posture.allFours, rng), 'G_quatre_pattes'); + expect(bank.pickPostureChange(Posture.onBack, rng), isNull); + }); + }); + + group('PhraseBankLoader — parsing break (assets réels)', () { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + for (final lang in const ['fr', 'en', 'de', 'es']) { + test('$lang : break_entry / orders / resume / posture peuplés', () async { + final bank = await PhraseBankLoader().load(locale: Locale(lang)); + expect(bank.pickBreakEntry(rng), isNotNull, + reason: '$lang break_entry vide'); + expect(bank.pickBreakOrder(rng), isNotNull, + reason: '$lang break_orders vide'); + expect(bank.pickBreakResume(rng), isNotNull, + reason: '$lang break_resume vide'); + // Les 5 postures non-free ont un pool de changement de pose. + for (final pose in Posture.values) { + if (pose == Posture.free) { + expect(bank.pickPostureChange(pose, rng), isNull, + reason: '$lang : free ne doit jamais avoir de pool'); + } else { + expect(bank.pickPostureChange(pose, rng), isNotNull, + reason: '$lang : posture ${pose.serialized} sans pool'); + } + } + }); + + test('$lang : aucun ordre de break ne contient « tiens/hold/halten »', + () async { + final bank = await PhraseBankLoader().load(locale: Locale(lang)); + // 50 tirages : couvre largement le pool d'ordres. + for (var i = 0; i < 50; i++) { + final order = bank.pickBreakOrder(rng)!.toLowerCase(); + expect(order.contains('tiens'), isFalse, reason: order); + expect(order.contains('hold'), isFalse, reason: order); + expect(order.contains('halten'), isFalse, reason: order); + } + }); + } + }); +}