diff --git a/rhythm_coach/lib/career/services/career_level_gates.dart b/rhythm_coach/lib/career/services/career_level_gates.dart index 02f2ee5..417a53a 100644 --- a/rhythm_coach/lib/career/services/career_level_gates.dart +++ b/rhythm_coach/lib/career/services/career_level_gates.dart @@ -86,8 +86,9 @@ class CareerLevelGates { static int defaultMaxDepthIndex() => Position.full.index; /// Plafond de profondeur dérivé du `CapabilityProfile` (Phase 19.7). - /// Lit `rhythm.depth_max.comfort` (en cran, double), l'arrondit et le - /// borne dans `[mid, full]`. + /// Lit `rhythm.depth_max.comfort` (en cran, double), l'arrondit, ajoute + /// **un cran de sonde** tant que le `best` prouvé est plus profond, et + /// borne le tout dans `[mid, full]`. /// /// Plancher à `mid` (= idx 2) : les basics enseignent déjà la /// progression jusqu'à `mid` (cf. `intro_deeper_basics`, `intro_hold_mid`, @@ -107,7 +108,25 @@ class CareerLevelGates { if (profile == null) return defaultMaxDepthIndex(); final comfort = profile.comfortOf(CapabilityAxis.rhythmDepthMax); if (comfort == null) return defaultMaxDepthIndex(); + // Sonde vers le `best` prouvé : quand la profondeur déjà atteinte est plus + // profonde que la cible courante (compétence rabaissée par un tap-out + // imputé ou par le decay), on repropose **un cran** au-dessus du comfort. + // Sans ça le tirage reste borné au comfort, donc `reached` ne dépasse + // jamais `comfort`, donc le régulateur n'a jamais l'overshoot qui le ferait + // remonter : descente facile, remontée verrouillée. Jamais au-delà du + // `best` — on ne sonde que du territoire déjà prouvé. + // + // Écrit en ternaire, et **pas** `min(rounded + 1, best.round())` : les + // deux formules ne coïncident que tant que `best >= comfort`. Sur un + // profil incohérent (`best < comfort`, atteignable par un import de + // profil, qui écrit les deux clés sans passer par le régulateur), le + // `min` rabaisserait le cap SOUS le comfort — l'inverse de ce qu'on + // corrige. C'est le pendant du `max(comfort, …)` explicite de + // `capability_clamps.dart`, ici porté par la forme. Le test `cap(3, 2)` + // de `capability_depth_comfort_vs_best_test.dart` sépare les deux. final rounded = comfort.round(); - return rounded.clamp(Position.mid.index, Position.full.index); + final best = profile.bestOf(CapabilityAxis.rhythmDepthMax) ?? comfort; + final probe = best.round() > rounded ? rounded + 1 : rounded; + return probe.clamp(Position.mid.index, Position.full.index); } } diff --git a/rhythm_coach/lib/career/services/generation/capability_clamps.dart b/rhythm_coach/lib/career/services/generation/capability_clamps.dart index 964c3a6..e563ff0 100644 --- a/rhythm_coach/lib/career/services/generation/capability_clamps.dart +++ b/rhythm_coach/lib/career/services/generation/capability_clamps.dart @@ -168,8 +168,8 @@ class CapabilityClamps implements CapabilityClampSurface { static const double kIntenseComfortBoost = 1.30; /// Bonus de cran de profondeur appliqué à `rhythmDepthMax` quand - /// `intense=true` (séance d'escalade). Cumulé avec le +1 cran de - /// surcharge si l'axe est aussi `overloadAxis`. Le clamp `to <= + /// `intense=true` (séance d'escalade). Cumulé avec le +1 cran accordé en + /// amont par [capabilityCapFor] (surcharge ou sonde). Le clamp `to <= /// maxDepthIndex` (Position.full) et les milestones acquittées bornent /// en amont via `_maxDepthIndex`/`_milestoneRhythmCeilingIdx`, donc /// "+1 cran" ne fait jamais sauter une milestone non débloquée. @@ -177,7 +177,8 @@ class CapabilityClamps implements CapabilityClampSurface { /// Plafond effectif (= le plus contraignant) d'un axe de capacité pour /// la génération en cours : minimum de `comfort` (éventuellement - /// **surchargé** si c'est [overloadAxis] de la séance, et **boosté** + /// **surchargé** si c'est [overloadAxis] de la séance, **sondé d'un cran + /// vers le `best` prouvé** pour `rhythmDepthMax`, et **boosté** /// par `kIntenseComfortBoost` si la séance est `intense`) et du plafond /// figé sur un FAIL de cette session ([ceilings], §6 — qui plafonne /// *même* l'axe surchargé : pas de re-fail dans la même séance). @@ -189,18 +190,30 @@ class CapabilityClamps implements CapabilityClampSurface { final p = config.capProfile; if (p == null) return null; var comfort = p.comfortOf(axis); - if (comfort != null && axis == config.overloadAxis) { - if (axis == CapabilityAxis.rhythmDepthMax) { - // Profondeur = cran discret : on autorise +1 cran, et seulement si - // la confiance au cran courant est là (cf. asymétries §5). - // « Humiliation l'autorise » + « milestone d'unlock acquittée » - // sont déjà garantis par `_maxDepthIndex` (qui borne `to` en amont). - if (p.stateOf(axis).successRate >= CapabilityRegulator.kDepthCranGate) { - comfort = comfort + 1; - } - } else { - comfort = comfort * config.overloadFactor; - } + if (comfort != null && axis == CapabilityAxis.rhythmDepthMax) { + // Profondeur = cran discret, deux régimes distincts pour le +1 cran : + // + // - **Escalade** (la profondeur EST l'axe surchargé et la confiance au + // cran courant est là) : le cran peut dépasser le `best`. C'est ce + // qui fait grandir le `best` — comportement d'origine, inchangé. + // - **Sonde** (tout le reste) : le cran est borné par le `best` déjà + // prouvé. On ne pousse pas au-delà de ce qu'elle a tenu, donc pas + // besoin d'attendre que l'axe gagne la loterie de surcharge ni que + // `successRate` repasse le seuil — deux conditions qui verrouillaient + // la remontée : tant que le clamp rabat `to` au comfort, `reached` ne + // dépasse jamais le comfort, donc le régulateur ne voit jamais + // l'overshoot qui le ferait remonter. + // + // `max(comfort, …)` : garde-fou pour un profil où `best < comfort` + // (état persisté incohérent) — la sonde ne doit jamais RABAISSER le cap. + // « Milestone d'unlock acquittée » reste garanti en amont par + // `_maxDepthIndex` / `milestoneRhythmCeilingIdx`. + final best = p.bestOf(axis) ?? comfort; + final escalating = axis == config.overloadAxis && + p.stateOf(axis).successRate >= CapabilityRegulator.kDepthCranGate; + comfort = escalating ? comfort + 1 : max(comfort, min(comfort + 1, best)); + } else if (comfort != null && axis == config.overloadAxis) { + comfort = comfort * config.overloadFactor; } // Boost intense (Supplier / Encore) : cumulatif avec la surcharge // de l'axe. Pour `rhythmDepthMax` (cran discret), bonus exprimé en diff --git a/rhythm_coach/test/capability_depth_comfort_loop_test.dart b/rhythm_coach/test/capability_depth_comfort_loop_test.dart new file mode 100644 index 0000000..e515977 --- /dev/null +++ b/rhythm_coach/test/capability_depth_comfort_loop_test.dart @@ -0,0 +1,302 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +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/services/capability_service.dart'; +import 'package:beat_bitch/services/capability_tracker.dart'; +import 'package:beat_bitch/services/saliva_engine.dart'; + +/// Boucle complète séance → régulateur → séance suivante, sur la profondeur +/// rythmée. Les autres sondes de profondeur injectent un profil **figé** dans +/// le générateur : elles montrent que la tranche throat redevient visible, +/// jamais que le `comfort` remonte. Celle-ci referme le circuit — le profil +/// persisté est relu par le générateur, la séance produite est rejouée dans le +/// vrai `CapabilityTracker`, et son rapport repasse par le vrai +/// `CapabilityService.commit` avant la séance suivante. +/// +/// Simplifications assumées du rejeu (le `SessionController` n'est pas +/// instancié) : +/// - les `chainAction` ne sont pas déroulées ; +/// - après un tap-out la séance s'arrête, là où l'app régénère la suite bornée +/// par les `sessionCeilings` ; +/// - le temps avance d'une seconde exacte par tick, sans TTS ni différé. + +List _p(List texts) => + texts.map((t) => PhraseEntry(text: t)).toList(); + +PhraseBank _bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': _p(['s']), + 'medium': _p(['m']), + 'hard': _p(['h']), + 'boost': _p(['b']), + 'finale': _p(['f']), + }, + }, + congrats: _p(['bravo']), + intros: _p(['intro']), + ); + +/// Milestones d'une joueuse qui a prouvé jusqu'à throat, jamais full. +final Set _throatProvenUnlocks = { + UnlockKey.holdMid, + UnlockKey.throatHold, + UnlockKey.throatPulse, +}; + +/// Les 14 axes surchargeables renseignés, comme un profil d'historique — pour +/// que `rhythmDepthMax` entre en concurrence pour le tirage d'axe surchargé. +const Map _otherAxes = { + 'gorge.apnee.streak': 24, + 'gorge.engagement.streak': 35, + 'gorge.crossings.bpm.throat': 90, + 'gorge.crossings.bpm.full': 80, + 'rhythm.bpm_ceil.shallow': 100, + 'rhythm.bpm_ceil.throat': 90, + 'rhythm.bpm_ceil.full': 80, + 'rhythm.motion.streak': 200, + 'hold.throat.streak': 30, + 'hold.full.streak': 25, + 'noswallow.streak': 120, + 'biffle.streak': 30, + 'biffle.bpm_max': 90, +}; + +/// Profil persisté de départ : `comfort` rabaissé sous le `best` prouvé, et +/// une confiance sous `kDepthCranGate` — l'état exact du défaut. +Map _seed({ + required double comfort, + required double best, + double successRate = 0.50, +}) { + final values = {}; + void put(String key, double best, double comfort, double sr) { + values['cap.$key.best'] = best; + values['cap.$key.comfort'] = comfort; + values['cap.$key.sr'] = sr; + values['cap.$key.seen'] = 0; + } + + put(CapabilityAxis.rhythmDepthMax.storageKey, best, comfort, successRate); + _otherAxes.forEach((key, v) => put(key, v, v, 0.5)); + return values; +} + +/// Ce qu'une séance a produit une fois rejouée. +typedef _Played = ({ + SessionCapabilityReport report, + int deepestRhythm, + bool tappedOut, +}); + +/// Génère une séance depuis [profile] et la rejoue seconde par seconde dans un +/// `CapabilityTracker`, comme le fait `SessionController`. +/// +/// [tapOut] : la joueuse tape out dès qu'un step rythmé vise plus profond que +/// son `comfort` — elle ne tient pas le cran sondé. Le tap-out tombe 2 s après +/// le step, donc avant le seuil de record soutenu (3 s) : elle ne pose aucun +/// record à ce cran, seulement un plafond de session. +_Played _play({ + required CapabilityProfile profile, + required int seed, + required bool tapOut, +}) { + final result = CareerSessionGenerator(seed: seed).generate( + level: 14, + bank: _bank(), + durationSeconds: 1500, + humiliationCareer: 100.0, + obedience: 100.0, + unlockedKeys: _throatProvenUnlocks, + capability: CapabilityInputs(profile: profile), + ); + final session = result.session; + final comfort = profile.comfortOf(CapabilityAxis.rhythmDepthMax) ?? 0; + + final tracker = CapabilityTracker()..onSessionStart(); + final byTime = >{}; + var lastTime = 0; + for (final step in session.steps) { + byTime.putIfAbsent(step.time, () => []).add(step); + if (step.time > lastTime) lastTime = step.time; + } + final totalSeconds = + session.durationSeconds > lastTime ? session.durationSeconds : lastTime; + + var swallow = SwallowMode.allowed; + var deepestRhythm = 0; + var tappedOut = false; + var failAt = -1; + + for (var t = 0; t <= totalSeconds; t++) { + for (final step in byTime[t] ?? const []) { + final stepSwallow = step.swallowMode; + if (stepSwallow != null) swallow = stepSwallow; + if (step.isTextOnly) continue; + final mode = step.mode ?? session.defaultMode; + tracker.onStepApplied( + mode: mode, + from: step.from, + to: step.to, + bpm: step.bpm, + duration: step.duration, + ); + final to = step.to; + if (mode == SessionMode.rhythm && to != null) { + if (to.index > deepestRhythm) deepestRhythm = to.index; + if (tapOut && failAt < 0 && to.index > comfort) { + failAt = t + 2; + } + } + } + tracker.onTickSecond(swallowMode: swallow); + if (failAt >= 0 && t >= failAt) { + tracker.onFail(); + tappedOut = true; + break; + } + } + + return ( + report: tracker.finalizeReport(), + deepestRhythm: deepestRhythm, + tappedOut: tappedOut, + ); +} + +/// Une séance de la boucle, telle qu'observée de l'extérieur : le `comfort` +/// qui a servi à la générer, la profondeur rythmée qu'elle a effectivement +/// proposée, et l'état du profil une fois le rapport régulé. +typedef _Round = ({ + double comfortBefore, + double comfort, + double best, + double successRate, + int deepestRhythm, + bool tappedOut, +}); + +/// Enchaîne [sessions] séances en repassant chaque rapport au régulateur. +/// Renvoie l'état du profil **après** chaque séance. +Future> _loop({ + required double comfort, + required double best, + required int sessions, + Set tapOutOn = const {}, + int seedBase = 0, +}) async { + SharedPreferences.setMockInitialValues(_seed(comfort: comfort, best: best)); + final service = CapabilityService(); + final rounds = <_Round>[]; + + for (var i = 1; i <= sessions; i++) { + final profile = await service.snapshotProfile(); + final played = _play( + profile: profile, + seed: seedBase + i, + tapOut: tapOutOn.contains(i), + ); + await service.commit(played.report, sessionIndex: i); + final after = await service.snapshotProfile(); + final state = after.stateOf(CapabilityAxis.rhythmDepthMax); + rounds.add(( + comfortBefore: profile.comfortOf(CapabilityAxis.rhythmDepthMax) ?? 0, + comfort: state.comfort ?? 0, + best: state.best ?? 0, + successRate: state.successRate, + deepestRhythm: played.deepestRhythm, + tappedOut: played.tappedOut, + )); + } + return rounds; +} + +const double _head = 1; +const double _mid = 2; +const double _throat = 3; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('boucle complète — le comfort remonte vers le best', () { + test('comfort=mid / best=throat : throat récupéré à la 2ᵉ séance', + () async { + for (final seedBase in [0, 100, 200, 300, 400]) { + final rounds = await _loop( + comfort: _mid, + best: _throat, + sessions: 6, + seedBase: seedBase, + ); + expect(rounds[0].deepestRhythm, Position.throat.index, + reason: 'graines $seedBase : la sonde rend la tranche throat ' + 'visible dès la 1ʳᵉ séance — c\'est la condition nécessaire, ' + 'sur origin/develop la profondeur proposée plafonne à mid'); + expect(rounds[0].comfort, _mid, + reason: 'séance 1 : l\'overshoot est vu, mais le ratchet ↑ d\'un ' + 'axe depthCran est en plus gaté par kDepthCranGate — la ' + 'confiance (0,50) n\'y est pas encore'); + expect(rounds[0].successRate, + closeTo(CapabilityRegulator.kDepthCranGate, 1e-9), + reason: 'l\'EMA de succès passe 0,50 → 0,65 = le seuil, tout ' + 'juste : c\'est ce qui coûte la 1ʳᵉ séance'); + expect(rounds[1].comfort, _throat, + reason: 'séance 2 : le seuil est atteint, le cran est rendu'); + for (final r in rounds.skip(1)) { + expect(r.comfort, _throat, reason: 'et il ne rebouge plus'); + expect(r.best, _throat, + reason: 'la sonde ne pousse jamais au-delà du territoire prouvé'); + } + } + }); + + test('chute de deux crans : un cran par séance, throat à la 3ᵉ', () async { + final rounds = await _loop(comfort: _head, best: _throat, sessions: 6); + expect( + rounds.map((r) => r.comfort).take(3).toList(), [_head, _mid, _throat], + reason: 'la remontée reste graduelle — jamais de retour direct au ' + 'best (sur origin/develop elle s\'arrête à mid, plancher de ' + 'maxDepthIndexForProfile, et n\'en repart jamais)'); + }); + }); + + group('boucle complète — l\'échec redescend, il ne coince pas', () { + test('tap-out à chaque séance : la profondeur proposée suit', () async { + final rounds = await _loop( + comfort: _mid, + best: _throat, + sessions: 4, + tapOutOn: const {1, 2, 3, 4}, + ); + expect(rounds[0].tappedOut, isTrue, + reason: 'le cran sondé l\'expose vraiment — sur origin/develop rien ' + 'ne dépasse son comfort, elle n\'est jamais mise en difficulté'); + expect(rounds.map((r) => r.comfort).toList(), [_head, 0, 0, 0], + reason: 'un tap-out imputé coûte un cran, plancher à 0'); + for (final r in rounds) { + expect(r.deepestRhythm, lessThanOrEqualTo(r.comfortBefore + 1), + reason: 'la sonde ne propose jamais plus d\'un cran au-dessus du ' + 'comfort courant : ce qu\'elle ne tient pas redescend'); + } + }); + + test('tap-out puis reprise : throat récupéré 4 séances plus tard', + () async { + final rounds = await _loop( + comfort: _mid, + best: _throat, + sessions: 6, + tapOutOn: const {1}, + ); + expect(rounds.map((r) => r.comfort).toList(), + [_head, _head, _head, _mid, _throat, _throat], + reason: 'le tap-out casse la confiance (EMA vers 0) autant qu\'il ' + 'baisse le cran : il faut deux séances propres pour repasser ' + 'kDepthCranGate avant que les crans reviennent'); + }); + }); +} diff --git a/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart new file mode 100644 index 0000000..16f94dd --- /dev/null +++ b/rhythm_coach/test/capability_depth_comfort_vs_best_test.dart @@ -0,0 +1,281 @@ +import 'package:flutter_test/flutter_test.dart'; +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/career_level_gates.dart'; +import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; +import 'package:beat_bitch/services/capability_service.dart'; + +/// Verrou de profondeur — le `comfort` rabaissé sous le `best` prouvé ne doit +/// pas faire disparaître la tranche throat/full des séances normales. +/// +/// Origine : sonde adversariale du tri du 2026-08-18, reprise telle quelle +/// puis retournée. Dans sa version d'origine elle *caractérisait* le défaut +/// (elle attendait `rhythm == 0` en séance normale sous le seuil, et passait +/// au vert sur `origin/develop`). Les deux attentes qui figeaient le défaut +/// sont réécrites ici pour exprimer le comportement voulu — les deux mesures +/// brutes correspondantes sont conservées telles quelles dans le rapport. +/// +/// Ce que le correctif fait, tel que mesuré : la profondeur rythmée vise **un +/// cran** au-dessus du `comfort`, borné par le `best`. Donc : +/// - `comfort=mid` / `best=throat` → cap throat : la tranche revient ; +/// - `comfort=head` / `best=throat` → cap mid : un cran, pas le retour à +/// throat — la remontée reste graduelle ; +/// - `best == comfort` → no-op strict. + +List _p(List texts) => + texts.map((t) => PhraseEntry(text: t)).toList(); + +PhraseBank _bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': _p(['s']), + 'medium': _p(['m']), + 'hard': _p(['h']), + 'boost': _p(['b']), + 'finale': _p(['f']), + }, + }, + congrats: _p(['bravo']), + intros: _p(['intro']), + ); + +CapabilityAxisState _s(double v, {double sr = 0.5}) => + CapabilityAxisState(best: v, comfort: v, successRate: sr); + +/// Profil réaliste : les 14 axes surchargeables sont tous renseignés (comme +/// une joueuse avec un historique), pour que `rhythmDepthMax` entre en +/// concurrence pour le tirage d'axe surchargé au lieu d'être le seul +/// candidat (ce qui gonflerait artificiellement son taux de sélection). +CapabilityProfile _profile( + double depthSuccessRate, { + double comfort = 2.0, // mid + double best = 3.0, // throat +}) { + return CapabilityProfile({ + CapabilityAxis.rhythmDepthMax: CapabilityAxisState( + best: best, + comfort: comfort, + successRate: depthSuccessRate, + ), + CapabilityAxis.gorgeApneeStreak: _s(24), + CapabilityAxis.gorgeEngagementStreak: _s(35), + CapabilityAxis.gorgeCrossingsBpmThroat: _s(90), + CapabilityAxis.gorgeCrossingsBpmFull: _s(80), + CapabilityAxis.rhythmBpmCeilShallow: _s(100), + CapabilityAxis.rhythmBpmCeilThroat: _s(90), + CapabilityAxis.rhythmBpmCeilFull: _s(80), + CapabilityAxis.rhythmMotionStreak: _s(200), + CapabilityAxis.holdThroatStreak: _s(30), + CapabilityAxis.holdFullStreak: _s(25), + CapabilityAxis.noswallowStreak: _s(120), + CapabilityAxis.biffleStreak: _s(30), + CapabilityAxis.biffleBpmMax: _s(90), + }); +} + +/// Sur [seedCount] graines, compte séparément les séances qui contiennent +/// au moins un step **rhythm** (resp. **hold**) dont `to >= throat`, et la +/// profondeur maximale jamais atteinte par une **tenue**. +/// +/// Séparation cruciale : `pickHoldPosition` (position_pickers.dart:160) est +/// documenté **« pas de cap par maxDepthIndex »** — la position d'un hold +/// est gatée par les milestones (`fullHold`/`throatHold`/`holdMid`), pas +/// par `rhythm.depth_max`. Un comptage fondu rhythm+hold confond deux +/// mécanismes différents et ne peut pas trancher CE constat. +({int rhythm, int hold, Position? deepestHold}) _countReachingThroat({ + required double depthSuccessRate, + required bool intense, + required bool useMe, + required int seedCount, + required Set unlockedKeys, + double comfort = 2.0, + double best = 3.0, +}) { + final profile = _profile(depthSuccessRate, comfort: comfort, best: best); + var rhythmCount = 0; + var holdCount = 0; + Position? deepestHold; + for (var seed = 0; seed < seedCount; seed++) { + final r = CareerSessionGenerator(seed: seed).generate( + level: 14, + bank: _bank(), + durationSeconds: 1500, // format "moyenne" (25 min) + intense: intense, + useMe: useMe, + humiliationCareer: 100.0, + obedience: 100.0, + unlockedKeys: unlockedKeys, + capability: CapabilityInputs(profile: profile), + ); + var rhythmHit = false; + var holdHit = false; + for (final s in r.session.steps) { + if (s.isTextOnly) continue; + final to = s.to; + if (to == null) continue; + if (s.mode == SessionMode.hold && + (deepestHold == null || to.index > deepestHold.index)) { + deepestHold = to; + } + if (to.index < Position.throat.index) continue; + if (s.mode == SessionMode.rhythm) rhythmHit = true; + if (s.mode == SessionMode.hold) holdHit = true; + } + if (rhythmHit) rhythmCount++; + if (holdHit) holdCount++; + } + return (rhythm: rhythmCount, hold: holdCount, deepestHold: deepestHold); +} + +/// Milestones réalistes pour un profil `comfort=mid/best=throat` : elle a +/// prouvé jusqu'à throat, donc les unlocks jusqu'à throat sont acquis — +/// mais PAS `fullHold`/`fullPulse` (jamais prouvé full). +final Set _throatProvenUnlocks = { + UnlockKey.holdMid, + UnlockKey.throatHold, + UnlockKey.throatPulse, +}; + +void main() { + const seedCount = 300; + + group('sonde vers le best prouvé', () { + test('comfort=mid/best=throat, sr sous le seuil — throat revient', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount, + reason: 'la sonde vise le best PROUVÉ : reproposer throat ne pousse ' + 'pas au-delà de ce qu\'elle a tenu, donc elle ne doit dépendre ' + 'ni de la loterie de surcharge ni de kDepthCranGate — sinon ' + 'aucun overshoot n\'est possible et le comfort ne remonte ' + 'jamais (le verrou d\'origine : 0/300 ici)'); + }); + + test('comfort=mid/best=throat, sr au-dessus du seuil — inchangé', () { + final n = _countReachingThroat( + depthSuccessRate: 0.80, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount); + }); + + test('la sonde rend UN cran, pas le best : comfort=head reste borné à mid', + () { + final n = _countReachingThroat( + depthSuccessRate: 0.80, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + comfort: Position.head.index.toDouble(), + ); + expect(n.rhythm, 0, + reason: 'comfort=head + 1 cran = mid : une chute de deux crans ne ' + 'se rattrape pas d\'un coup, même best=throat prouvé'); + }); + + test('best == comfort — no-op strict', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + best: Position.mid.index.toDouble(), + ); + expect(n.rhythm, 0, + reason: 'axe consolidé : rien à sonder, comportement Phase 19.7'); + }); + + test('best < comfort (profil incohérent) — la sonde n\'abaisse pas', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + comfort: Position.throat.index.toDouble(), + best: Position.mid.index.toDouble(), + ); + expect(n.rhythm, seedCount, + reason: 'un état persisté où best < comfort ne doit pas faire ' + 'RABAISSER le cap sous le comfort'); + }); + + test('maxDepthIndexForProfile suit la même règle', () { + int cap(double comfort, double best) => + CareerLevelGates.maxDepthIndexForProfile( + _profile(0.5, comfort: comfort, best: best)); + expect(cap(2, 3), Position.throat.index); // mid + 1 cran, borné au best + expect(cap(2, 4), Position.throat.index); // un seul cran, pas full + expect(cap(2, 2), Position.mid.index); // consolidé : no-op + expect(cap(1, 3), Position.mid.index); // head + 1 cran, planché mid + expect( + cap(3, 2), Position.throat.index); // incohérent : pas d'abaissement + expect(cap(4, 4), Position.full.index); // plafond full conservé + }); + }); + + group('escalade — inchangée', () { + test('Encore (intense, sans surcharge ni seuil)', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: true, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount); + }); + + test('Utilise-moi', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: true, + useMe: true, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + ); + expect(n.rhythm, seedCount); + }); + }); + + group('non-régression des tenues', () { + // Les tenues sont gatées par les milestones hold, pas par + // `rhythm.depth_max` : le correctif ne doit rien y changer. Les compteurs + // bruts ne sont pas comparables step à step (déplacer un step rythmé fait + // diverger toute la séquence RNG en aval) — d'où deux invariants qui, eux, + // le sont : la tranche throat sort toujours, et `full` reste fermé. + for (final (label, comfort, best) in [ + ('sonde active (mid/throat)', 2.0, 3.0), + ('no-op (mid/mid)', 2.0, 2.0), + ('chute de deux crans (head/throat)', 1.0, 3.0), + ]) { + test('$label — tenues throat présentes, full jamais ouvert', () { + final n = _countReachingThroat( + depthSuccessRate: 0.50, + intense: false, + useMe: false, + seedCount: seedCount, + unlockedKeys: _throatProvenUnlocks, + comfort: comfort, + best: best, + ); + expect(n.hold, seedCount, + reason: 'les tenues throat ne dépendent pas du profil rythme'); + expect(n.deepestHold, Position.throat, + reason: 'sans `fullHold` acquise, aucune tenue ne doit atteindre ' + 'full — le correctif ne doit pas ouvrir de palier de tenue'); + }); + } + }); +}