diff --git a/rhythm_coach/lib/career/services/generation/career_session_generator.dart b/rhythm_coach/lib/career/services/generation/career_session_generator.dart index 0379bcc..e2ab619 100644 --- a/rhythm_coach/lib/career/services/generation/career_session_generator.dart +++ b/rhythm_coach/lib/career/services/generation/career_session_generator.dart @@ -11,8 +11,10 @@ import '../../../services/capability_axis.dart'; import '../../../services/capability_service.dart'; import '../../models/career_generation_inputs.dart'; import '../../models/challenge.dart'; +import '../../../models/posture.dart'; import '../../models/level_milestone.dart'; import '../../models/phrase_bank.dart'; +import '../../models/posture_unlock.dart'; import '../../models/session_length_choice.dart'; import '../../models/specialization.dart'; import '../../models/unlock_key.dart'; @@ -521,6 +523,13 @@ class CareerSessionGenerator { /// Quand un défi est passé, un step breath de countdown + un step défi /// 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`. + bool scriptedBreaks = false, }) { // Invariants `milestones` : on ne peut pas les déplacer dans le // constructeur de `MilestonePlan` car `.placement` n'est pas @@ -577,8 +586,10 @@ class CareerSessionGenerator { lengthChoice: lengthChoice, intense: intense, encoreChainIndex: encoreChainIndex, + scriptedBreaks: scriptedBreaks, ); _initScratchpad(unlockedKeys: unlockedKeys, clearPatternBuffer: true); + _initialPose = _pickInitialPose(unlockedKeys); // Mode "Session bâclée" : 6 min par défaut, intense tout du long. Floor // d'intensité appliqué au tirage de difficulté + on saute l'intro douce // et la pré-finition. Une durée explicite reste prioritaire (cas de la @@ -1473,6 +1484,23 @@ class CareerSessionGenerator { ctx.stamina = s; } + /// Posture imposée au démarrage de la séance courante. Tirée en début de + /// `generate()` (où `unlockedKeys` est typé `Set`) et lue par + /// `_assembleResult`. `free` tant que le flag debug `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 + /// 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`. + Posture _pickInitialPose(Set unlockedKeys) { + if (!_config.scriptedBreaks) return Posture.free; + final available = availablePostures(unlockedKeys); + return available[_rng.nextInt(available.length)]; + } + /// Construit le [CareerGenerationResult] final à partir des accumulateurs /// `ctx.steps` / `ctx.profile` et du curseur `time`. Tronque le profil à la /// durée effective (= `time + 2`), assemble la [Session] avec toutes ses @@ -1534,6 +1562,7 @@ class CareerSessionGenerator { noStats: ctx.noStats, challenges: challenges, challengeTriggerTimes: challengeTriggerTimes, + initialPose: _initialPose, ), staminaProfile: trimmedProfile, overloadAxis: _config.overloadAxis, diff --git a/rhythm_coach/lib/career/services/generation/session_config.dart b/rhythm_coach/lib/career/services/generation/session_config.dart index 0db9166..9737bcf 100644 --- a/rhythm_coach/lib/career/services/generation/session_config.dart +++ b/rhythm_coach/lib/career/services/generation/session_config.dart @@ -54,6 +54,7 @@ class SessionConfig { this.lengthChoice, this.intense = false, this.encoreChainIndex = 0, + this.scriptedBreaks = false, }); // Bornes globales @@ -100,6 +101,11 @@ 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`. + final bool scriptedBreaks; + // ─── Méthodes dérivées (pures, lisent uniquement les fields ci-dessus) ─── /// True si le mode est exclu : diff --git a/rhythm_coach/test/posture_generation_test.dart b/rhythm_coach/test/posture_generation_test.dart new file mode 100644 index 0000000..c6b88b3 --- /dev/null +++ b/rhythm_coach/test/posture_generation_test.dart @@ -0,0 +1,88 @@ +import 'package:beat_bitch/career/models/career_generation_inputs.dart'; +import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; +import 'package:beat_bitch/models/posture.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _p(List t) => + t.map((s) => PhraseEntry(text: s)).toList(); + +PhraseBank _bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': _p(['s']), + 'medium': _p(['m']), + 'hard': _p(['h']), + 'finale': _p(['f']), + }, + }, + congrats: _p(['bravo']), + intros: _p(['intro']), + ); + +void main() { + group('Posture initiale — generate()', () { + test('flag off → free, même avec des postures débloquées', () { + final gen = CareerSessionGenerator(seed: 7); + final result = gen.generate( + level: 12, + bank: _bank(), + durationSeconds: 18 * 60, + unlockedKeys: UnlockKey.values.toSet(), + // scriptedBreaks: false (défaut) + ); + expect(result.session.initialPose, Posture.free); + }); + + test('flag on mais rien de débloqué → free', () { + final gen = CareerSessionGenerator(seed: 7); + final result = gen.generate( + level: 3, + bank: _bank(), + durationSeconds: 12 * 60, + unlockedKeys: const {}, + scriptedBreaks: true, + ); + expect(result.session.initialPose, Posture.free); + }); + + test('flag on + postures débloquées → posture du set disponible', () { + final gen = CareerSessionGenerator(seed: 7); + final unlocked = { + UnlockKey.postureKneeling, + UnlockKey.postureAllFours, + }; + final result = gen.generate( + level: 12, + bank: _bank(), + durationSeconds: 18 * 60, + unlockedKeys: unlocked, + scriptedBreaks: true, + ); + expect( + result.session.initialPose, + anyOf(Posture.free, Posture.kneeling, Posture.allFours), + ); + }); + + test('tirage déterministe sous seed identique', () { + final unlocked = { + UnlockKey.postureSitting, + UnlockKey.postureKneeling, + UnlockKey.postureAllFours, + }; + Posture run() => CareerSessionGenerator(seed: 99) + .generate( + level: 12, + bank: _bank(), + durationSeconds: 18 * 60, + unlockedKeys: unlocked, + scriptedBreaks: true, + ) + .session + .initialPose; + expect(run(), run()); + }); + }); +}