Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
"skipSessionButton": false,
"showBackgroundMedia": true,
"showSessionRemainingTime": false,
"scriptedBreaks": false
"scriptedBreaks": true
},
"consent": {
"adultConsentAccepted": true,
Expand Down
2 changes: 1 addition & 1 deletion rhythm_coach/assets/debug/profiles/reset.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
"skipSessionButton": false,
"showBackgroundMedia": true,
"showSessionRemainingTime": false,
"scriptedBreaks": false
"scriptedBreaks": true
},
"consent": {
"adultConsentAccepted": true,
Expand Down
9 changes: 6 additions & 3 deletions rhythm_coach/lib/career/screens/career_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2096,9 +2096,12 @@ 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.
/// Préférence utilisateur `pref.scripted_breaks` (issue #77), on par défaut.
/// Quand `true`, les postures imposées + breaks scénarisés sont activés.
/// Passé à `generate(scriptedBreaks:)` sur les deux entrées qui construisent
/// un contrôleur frais (`_start`, `_handleEncore`) — pas aux régens
/// mi-séance, qui swappent `_session` en vol et repartent sans break (cf.
/// reset d'état dans `requestUpgrade`).
final bool scriptedBreaks;

const _CareerBundle({
Expand Down
19 changes: 12 additions & 7 deletions rhythm_coach/lib/career/services/debug_settings_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +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';
static const String _kScriptedBreaks = 'pref.scripted_breaks';

Future<bool> getShowStaminaBar() async {
final prefs = await SharedPreferences.getInstance();
Expand Down Expand Up @@ -159,14 +159,19 @@ class DebugSettingsService {
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:)`.
/// Quand true (défaut), 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. Lu par `career_screen` et passé au générateur
/// via `generate(scriptedBreaks:)`.
///
/// Préférence utilisateur assumée (clé `pref.`, réglage dans le Profil) et
/// non plus flag debug : le gate métier de la fonctionnalité, ce sont les
/// milestones `intro_posture_*` qui débloquent chaque posture. L'ancienne
/// clé `debug.scripted_breaks` est délibérément abandonnée — la reprendre
/// rendrait le off historique (= le défaut d'avant) collant.
Future<bool> getScriptedBreaks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kScriptedBreaks) ?? false;
return prefs.getBool(_kScriptedBreaks) ?? true;
}

Future<void> setScriptedBreaks(bool value) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,19 +229,80 @@ class CareerSessionGenerator {
return 0;
}

/// Marge (s) laissée entre une pause scénarisée et le créneau d'un défi.
/// Absorbe l'écart entre l'horaire *planifié* d'un bloc et son émission
/// réelle : la boucle main pose la pause à la première frontière de bloc qui
/// suit son trigger, jamais pile dessus.
static const int _breakChallengeGapSeconds = 60;

/// Plancher de décalage d'une pause, en fraction de `genUntil`. Les
/// fractions nominales (0.34 / 0.5) garantissaient seules « jamais dans les
/// 5 premières minutes » ; dès qu'on décale, il faut le dire.
static const double _breakEarliestFraction = 0.25;

/// `triggerTime` d'insertion des breaks dans la fenêtre de génération
/// `genUntil` (≈ durée hors phase finish). 1 break → vers la moitié ;
/// 2 breaks → vers le tiers et les deux tiers. Le placement aux frontières
/// de blocs (insertion dans la boucle main) et `genUntil < effectiveDuration`
/// garantissent structurellement « jamais dans les 5 premières min ni dans
/// la phase finish » pour les durées éligibles (≥ 28 min).
///
/// [challengeWindows] : créneaux `(début, fin)` déjà réservés par les défis
/// planifiés. Sans eux, les deux plannings visent le même milieu de séance —
/// une pause et un défi médian tombent alors au même endroit, le trou
/// d'effort de la pause avale le trigger du défi et la boucle main le
/// réémet à `break.endTime` : la joueuse reçoit l'ordre de se mettre à
/// quatre pattes et l'annonce du défi au même instant. Les horaires de défi
/// ne bougent pas (ils portent la dramaturgie du défi et se coordonnent
/// avec la durée de séance) — c'est la pause qui cède le passage.
static List<int> _computeBreakTriggerTimes({
required int count,
required int genUntil,
List<(int, int)> challengeWindows = const [],
}) {
if (count == 0) return const [];
if (count == 1) return [(genUntil * 0.5).round()];
return [(genUntil * 0.34).round(), (genUntil * 0.67).round()];
final ideals = count == 1
? [(genUntil * 0.5).round()]
: [(genUntil * 0.34).round(), (genUntil * 0.67).round()];
// Une pause déjà placée devient elle-même un créneau occupé : deux pauses
// décalées vers le même trou libre se recouvriraient.
final busy = [...challengeWindows];
final times = <int>[];
for (final ideal in ideals) {
final t = _breakSlotClearOf(ideal, genUntil: genUntil, busy: busy);
times.add(t);
busy.add((t, t + _breakMaxDurationSeconds));
}
return times;
}

/// Renvoie l'horaire de pause le plus proche de [ideal] dont la fenêtre
/// (`[t, t + durée max]`, marge comprise) ne touche aucun créneau de [busy].
/// Retourne [ideal] tel quel s'il est déjà libre, ou si aucun créneau
/// acceptable n'existe — mieux vaut une collision que perdre la pause ou la
/// jeter au début de la séance.
static int _breakSlotClearOf(
int ideal, {
required int genUntil,
required List<(int, int)> busy,
}) {
const span = _breakMaxDurationSeconds + _breakChallengeGapSeconds;
bool collides(int t) => busy
.any((w) => t < w.$2 + _breakChallengeGapSeconds && t + span > w.$1);
if (!collides(ideal)) return ideal;
final lowest = (genUntil * _breakEarliestFraction).round();
final highest = genUntil - span;
int? best;
for (final w in busy) {
for (final candidate in [w.$2 + _breakChallengeGapSeconds, w.$1 - span]) {
if (candidate < lowest || candidate > highest) continue;
if (collides(candidate)) continue;
if (best == null || (candidate - ideal).abs() < (best - ideal).abs()) {
best = candidate;
}
}
}
return best ?? ideal;
}

/// Enveloppe temporelle conservative réservée par le générateur après
Expand Down Expand Up @@ -560,11 +621,9 @@ class CareerSessionGenerator {
/// sont insérés vers 60 % du temps planifié (cf. spec § 4.3).
ChallengeInputs challenge = ChallengeInputs.none,

/// Active les postures imposées + breaks scénarisés (issue #77, flag
/// debug `debug.scripted_breaks`). `false` = posture `free`, aucun
/// break (comportement historique). En PR3 seule la posture initiale
/// est tirée ; l'insertion des breaks suit. Cf. spec
/// `specs/scripted_breaks.md`.
/// Active les postures imposées + breaks scénarisés (issue #77, préférence
/// utilisateur `pref.scripted_breaks`). `false` = posture `free`, aucun
/// break. Cf. spec `specs/scripted_breaks.md`.
bool scriptedBreaks = false,
}) {
// Invariants `milestones` : on ne peut pas les déplacer dans le
Expand Down Expand Up @@ -825,13 +884,22 @@ class CareerSessionGenerator {
// longues. Comme les défis : trigger times planifiés, insertion aux
// frontières de blocs dans la boucle main, réservation de l'enveloppe
// (trou d'effort = `durationSeconds`, le runtime gèle le moteur — cf.
// PR4). Gaté par le flag debug `scriptedBreaks` ; 0 break si off.
// PR4). Gaté par `_config.scriptedBreaks` ; 0 break si off.
// `currentPose` suit la posture en cours (départ = posture initiale) pour
// que le 1ᵉʳ break impose une pose *différente* ; le 2ᵉ tend vers récup
// pure (`newPose == null`, continuité de pose).
final plannedBreakTimes = _computeBreakTriggerTimes(
count: _config.scriptedBreaks ? _computeBreakCount(effectiveDuration) : 0,
genUntil: ctx.genUntil,
challengeWindows: [
for (var i = 0; i < plannedTriggerTimes.length; i++)
(
plannedTriggerTimes[i],
plannedTriggerTimes[i] +
kChallengeBreathDurationSeconds +
_estimatedChallengeDuration(challengeQueue[i]),
),
],
);
final sessionBreaks = <ScriptedBreak>[];
var nextBreakIndex = 0;
Expand Down Expand Up @@ -1626,12 +1694,11 @@ class CareerSessionGenerator {

/// Posture imposée au démarrage de la séance courante. Tirée en début de
/// `generate()` (où `unlockedKeys` est typé `Set<UnlockKey>`) et lue par
/// `_assembleResult`. `free` tant que le flag debug `scriptedBreaks` est
/// off.
/// `_assembleResult`. `free` tant que `_config.scriptedBreaks` est off.
Posture _initialPose = Posture.free;

/// Tire la posture imposée au démarrage (issue #77). [Posture.free] tant
/// que le flag debug `scriptedBreaks` est off ; sinon tirage uniforme
/// que `_config.scriptedBreaks` est off ; sinon tirage uniforme
/// parmi les postures débloquées (`free` incluse dans le pool). Bas niveau
/// / rien de débloqué ⇒ `free` de toute façon. Déterministe sous seed via
/// `_rng`.
Expand Down
10 changes: 7 additions & 3 deletions rhythm_coach/lib/career/services/generation/session_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,13 @@ class SessionConfig {
/// `intense` est actif sur l'encore (cf. `generate()`).
final int encoreChainIndex;

/// Active les postures imposées + breaks scénarisés (issue #77, flag debug
/// `debug.scripted_breaks`). `false` = comportement historique (posture
/// `free`, aucun break). Cf. spec `specs/scripted_breaks.md`.
/// Active les postures imposées + breaks scénarisés (issue #77, préférence
/// utilisateur `pref.scripted_breaks`). `false` = posture `free` et aucun
/// break. Cf. spec `specs/scripted_breaks.md`.
///
/// Défaut `false` ici (et non `true` comme la préférence) : les appelants
/// hors carrière — Custom, Music, tests de génération — n'ont pas de
/// postures à imposer et ne passent pas l'argument.
final bool scriptedBreaks;

/// Mode « Utilise-moi » (remplace Supplier en carrière) : escalade
Expand Down
75 changes: 75 additions & 0 deletions rhythm_coach/lib/controllers/posture_gate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:flutter/foundation.dart';

import '../models/session.dart';

/// Ce qui **justifie** le gel de posture (issue #77), et rien d'autre.
///
/// Le gel n'est pas un drapeau qu'on arme d'un côté et qu'on pense à baisser
/// de l'autre : c'est une conséquence de la scène qui l'a ordonné, et il
/// cesse d'exister dès que cette scène n'est plus la scène courante.
/// [stillHolds] rejoue la justification à chaque lecture — un chemin qui
/// rebat la timeline n'a rien à lever, le gel tombe parce que ce qui le
/// tenait debout a disparu. C'est la propriété qui compte : elle vaut pour
/// les chemins qui existent aujourd'hui **comme pour ceux qu'on écrira
/// demain**, sans qu'ils aient à connaître ce gel.
///
/// Les trois premières composantes sont **les seules variables qui portent la
/// progression d'une séance** (`elapsed = _stopwatch + _timelineOffset`, step
/// courant = `_session.steps[_nextStepIndex]`) : rebattre la timeline sans en
/// toucher aucune est impossible. La quatrième couvre le flow d'échec, qui
/// n'a pas besoin de rebattre la timeline pour annuler la mise en scène de la
/// pause (cf. `triggerFail`).
///
/// **Cette impossibilité repose sur une hypothèse** : une [Session] et ses
/// listes (`steps`, `breaks`, `challenges`) ne sont jamais modifiées après
/// construction — toute réécriture de la timeline en vol produit une nouvelle
/// instance (cf. `_excisChallengeFromSession`). La clause d'identité ne voit
/// pas une mutation en place : un `session.steps[i] = …` sur une séance déjà
/// démarrée rebattrait la timeline sans faire tomber le gel, et rien ne le
/// signalerait.
@immutable
class PostureGate {
const PostureGate({
required this.session,
required this.nextStepIndex,
required this.timelineOffset,
required this.failGeneration,
});

/// Identité — pas égalité — de la timeline sur laquelle l'ordre a été
/// donné. Toute régénération en vol construit une nouvelle `Session`.
final Session session;

/// Prochain step à consommer au moment de l'ordre. Le gel interdit
/// justement toute consommation : s'il a bougé, c'est que quelqu'un
/// d'autre a déplacé la tête de lecture.
final int nextStepIndex;

/// Décalage d'horloge au moment de l'ordre. Un gel ne fait que le
/// **décrémenter** (un tick par battement, comme le report TTS) : toute
/// valeur supérieure signe un saut en avant dans la timeline.
final Duration timelineOffset;

/// Numéro du dernier flow d'échec démarré (monotone croissant, jamais
/// remis à zéro).
final int failGeneration;

/// Rejoue la justification contre la situation courante.
///
/// [otherSceneActive] : une autre mise en scène tient l'écran (défi en
/// cours, respiration post-défi). L'ordre de posture n'est alors plus la
/// scène courante — deux consignes concurrentes ne s'empilent pas.
bool stillHolds({
required Session session,
required int nextStepIndex,
required Duration timelineOffset,
required int failGeneration,
required bool otherSceneActive,
}) {
if (!identical(session, this.session)) return false;
if (nextStepIndex != this.nextStepIndex) return false;
if (timelineOffset > this.timelineOffset) return false;
if (failGeneration != this.failGeneration) return false;
return !otherSceneActive;
}
}
Loading
Loading