using System.Text.Json; using System.Text.Json.Nodes; using Server.WatchBody.Core.Dal.Src.Domain; using Server.WatchBody.Core.Dal.Src.Repository; namespace Server.WatchBody.Core.Programs; /// /// Детерминированные правила прогрессии (Ф-12.2) и делоада (Ф-12.4): /// результат — предложение draft-версии (author=rules) через /// ProgramStore; активация — только человеком (🛡 vision 2.4). /// Ограничения атлета — через персональную библиотеку: is_active=false /// не попадает в draft, пара-замена подставляется при наличии /// (module_reqs разд. 2.4). 🛡 Активная версия не меняется; в факт /// ничего не пишет. /// public class ProgressionRules( IWorkoutRepository workouts, IExerciseRepository exercises, ProgramStore programs, ProgressionConfig config) { private static readonly string[] DayNames = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; /// Микроправка весов после тренировки (spec 02): правила 1–5 /// по порядку, первое сработавшее; draft одна на активную версию. public ProgramVersion? EvaluateAfterWorkout(int athleteId, Guid workoutId) { var active = programs.GetActive(athleteId); if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; if (HasRulesDraft(athleteId, active.Version)) return null; var workout = workouts.Get(athleteId, workoutId); if (workout == null) return null; var byName = NameIndex(athleteId); var history = workouts .Select(new WorkoutFilter { AthleteId = athleteId }) .OrderByDescending(w => w.StartedAt) .ToList(); var body = JsonNode.Parse(active.Body) as JsonObject ?? throw new InvalidOperationException("body активной версии не является объектом"); var rationale = new List(); foreach (var week in Weeks(body)) foreach (var session in Sessions(week)) { if (session["exercises"] is not JsonArray exerciseNodes) continue; foreach (var exerciseNode in exerciseNodes.OfType().ToList()) { var planId = exerciseNode["exercise_id"]?.GetValue(); if (planId == null || !byName.TryGetValue(planId, out var ex)) { rationale.Add($"{planId}: нет в библиотеке атлета — решение не генерируется"); continue; } if (!ex.IsActive) { ApplyExclusion(exerciseNodes, exerciseNode, ex, byName, rationale); continue; } var decision = Decide(workout, ex, history, Sets(exerciseNode)); if (decision == null) continue; ApplyDecision(exerciseNode, decision.Value, rationale); } // сессия только из исключённых упражнений → из плана целиком if (session["exercises"] is JsonArray remaining && remaining.Count == 0 && session.Parent is JsonArray sessionList) sessionList.Remove(session); } if (rationale.Count == 0) return null; var draft = programs.CreateDraft(athleteId, body.ToJsonString(), ProgramVersionAuthors.Rules, "Микроправка после тренировки: " + string.Join("; ", rationale)); return draft; } /// Делоад (spec 03): DeloadMaxWeeks без делаода → draft /// (веса ×0.6, подходы −1, session_kind=deload в week 1). public ProgramVersion? CheckDeload(int athleteId, DateOnly today) { var active = programs.GetActive(athleteId); if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; if (HasRulesDraft(athleteId, active.Version)) return null; var all = workouts.Select(new WorkoutFilter { AthleteId = athleteId }) .OrderByDescending(w => w.StartedAt).ToList(); if (all.Count == 0) return null; // атлет давно не тренируется — это домен break, не делоад if (today > DateOnly.FromDateTime(all[0].StartedAt).AddDays(config.DeloadMaxWeeks * 7)) return null; var lastDeload = all.FirstOrDefault(w => w.SessionKind == SessionKinds.Deload); var anchor = lastDeload != null ? DateOnly.FromDateTime(lastDeload.StartedAt) : DateOnly.FromDateTime(active.CreatedAt); if (today < anchor.AddDays(config.DeloadMaxWeeks * 7)) return null; var body = JsonNode.Parse(active.Body) as JsonObject ?? throw new InvalidOperationException("body активной версии не является объектом"); var firstWeek = Weeks(body).FirstOrDefault(); if (firstWeek == null) return null; foreach (var session in Sessions(firstWeek)) { session["session_kind"] = SessionKinds.Deload; if (session["exercises"] is not JsonArray exerciseNodes) continue; foreach (var exerciseNode in exerciseNodes.OfType()) foreach (var set in Sets(exerciseNode)) if (WeightOf(set) is decimal weight) set["weight_kg"] = Halves(weight * 0.6m); TrimSets(session, remove: 1); } return programs.CreateDraft(athleteId, body.ToJsonString(), ProgramVersionAuthors.Rules, $"Делоад: {config.DeloadMaxWeeks} недель без лёгкой недели — объём −40–50 %"); } // ---------- микроправка: решение по одному упражнению ---------- private (decimal? newWeight, int? newReps, string why)? Decide( Workout workout, Exercise ex, List history, List planSets) { if (planSets.Count == 0) return null; var planWeight = WeightOf(planSets[0]); var planReps = planSets[0]["reps"]?.GetValue(); if (planWeight == null || planReps == null) return null; // калибровка var setsOf = (Workout w) => w.Sets .Where(s => s.ExerciseId == ex.Id && s.Source != WorkoutSetSources.Edit) .ToList(); var current = setsOf(workout); if (current.Count == 0) return null; var step = ex.IsBase ? config.BaseStepKg : config.IsoStepKg; // правило 1: 2 тренировки подряд все подходы «по плану» при RPE ≤ 7 var withExercise = history.Where(w => setsOf(w).Count > 0).Take(2).ToList(); if (withExercise.Count == 2 && withExercise.All(ClosedEasy)) return (Halves(planWeight.Value + step), null, $"+{step} (2 тренировки подряд закрыты при RPE≤7)"); // правило 2: RPE ≥ 9 в 2+ сетах if (current.Count(s => s.Rpe >= 9) >= 2) return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null, $"−{config.BackoffPercent}% (RPE≥9 в {current.Count(s => s.Rpe >= 9)} сетах)"); // правило 3: верхняя граница диапазона повторений (15–20) if (current.All(s => s.InPlan) && planReps >= 15) return (Halves(planWeight.Value + step), 12, $"+{step}, повторения к 12 (верх диапазона достигнут)"); // правило 4: ≥ 2 подходов не дотянуты по повторениям var shortfalls = current.Count(s => s.Reps < planReps); if (shortfalls >= 2) return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null, $"−{config.BackoffPercent}% ({shortfalls} подходов не дотянуты)"); return null; // правило 5: без изменения } private static bool ClosedEasy(Workout w) => w.Sets.Where(s => s.Source != WorkoutSetSources.Edit).All( s => s.InPlan && s.Rpe != null && s.Rpe <= 7); private static void ApplyDecision( JsonObject exerciseNode, (decimal? newWeight, int? newReps, string why) decision, List rationale) { var name = exerciseNode["exercise_id"]?.GetValue(); var from = WeightOf(Sets(exerciseNode).FirstOrDefault()); foreach (var set in Sets(exerciseNode)) { if (decision.newWeight != null) set["weight_kg"] = decision.newWeight; if (decision.newReps != null) set["reps"] = decision.newReps; } rationale.Add($"{name}: {from} → {decision.newWeight ?? from} ({decision.why})"); } /// Ограничения (spec 02 §4): неактивное упражнение → замена /// из пары (активная, та же группа) или удаление из сессии. private void ApplyExclusion( JsonArray siblings, JsonObject exerciseNode, Exercise ex, Dictionary byName, List rationale) { var replacement = ex.ReplacementIds .Select(id => byName.Values.FirstOrDefault(e => e.Id == id)) .FirstOrDefault(r => r != null && r.IsActive); if (replacement != null) { exerciseNode["exercise_id"] = replacement.Name; rationale.Add($"{ex.Name}: исключено (is_active=false) → замена {replacement.Name}"); return; } siblings.Remove(exerciseNode); rationale.Add($"{ex.Name}: исключено (is_active=false), замены нет"); } // ---------- делоад/общие помощники ---------- private static void TrimSets(JsonObject session, int remove) { if (session["exercises"] is not JsonArray exerciseNodes) return; foreach (var exerciseNode in exerciseNodes.OfType()) { if (exerciseNode["sets"] is not JsonArray sets) continue; while (sets.Count > 1 && remove > 0) { sets.RemoveAt(sets.Count - 1); remove--; } } } // материализуем: ApplyExclusion удаляет узлы во время обхода private static List Weeks(JsonObject body) => body["weeks"] is JsonArray weeks ? weeks.OfType().ToList() : []; private static List Sessions(JsonObject week) => week?["sessions"] is JsonArray sessions ? sessions.OfType().ToList() : []; private static List Sets(JsonObject exerciseNode) => exerciseNode["sets"] is JsonArray sets ? sets.OfType().ToList() : []; private static decimal? WeightOf(JsonObject? set) { if (set?["weight_kg"] is not JsonValue value) return null; return value.TryGetValue(out decimal weight) ? weight : null; } private static decimal Halves(decimal value) => Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m; private Dictionary NameIndex(int athleteId) => exercises.Select(new ExerciseFilter { AthleteId = athleteId }) .GroupBy(e => e.Name, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); /// Rules-draft, основанный на активной версии или новее /// (spec 02 §5.3) — пока он есть, новых предложений не создаём. private bool HasRulesDraft(int athleteId, int activeVersion) => programs.ListVersions(athleteId).Any(v => v.Author == ProgramVersionAuthors.Rules && v.Status == ProgramVersionStatuses.Draft && (v.BasedOnVersion ?? 0) >= activeVersion); }