using System.Globalization; using System.Text; using System.Text.Json; using CsWeb.Services; using Microsoft.AspNetCore.Mvc.RazorPages; namespace CsWeb.Pages; public class TheoryModel(Fleet fleet) : PageModel { // Panel geometry in viewBox units (SVG scales to container width). public const int W = 720, PlotH = 134, L = 46, R = 10, T = 10; public int PlotW => W - L - R; public sealed record Panel(string Key, string Title, string Color, string Points, double YMax, double OneY); public string? ModeParam { get; private set; } public Cached? Balance { get; private set; } public BalancePayload? B => Balance?.Data; public List Panels { get; } = []; public string SimJson { get; private set; } = "null"; public double PrestigeBoostPercent { get; private set; } // server value, rendered into the input (no-JS honesty) // A requested mode with no live server of that mode falls back to whatever IS reachable — say so explicitly, // because the effective handicap genuinely differs per mode. public bool ModeMismatch => !string.IsNullOrEmpty(ModeParam) && B is not null && !string.Equals(ModeParam, B.Mode, StringComparison.OrdinalIgnoreCase); // Balance serializes StatsConfig with PascalCase property names; the domain (and the survival cards) key stats // by snake_case ids. This is the one place the site maps between them (mirror of the plugin's StatRegistry). private static readonly (string Prop, string Key, string Display)[] StatMap = [ ("Damage", "damage", "Damage"), ("CritChance", "crit_chance", "Crit Chance"), ("CritDamage", "crit_damage", "Crit Damage"), ("HeadshotDamage", "hs_damage", "Headshot Damage"), ("MaxHp", "max_hp", "Max HP"), ("MaxArmor", "max_armor", "Max Armor"), ("Lifesteal", "lifesteal", "Lifesteal"), ("ArmorLifesteal", "armor_lifesteal", "Armor Lifesteal"), ("HpRegen", "hp_regen", "HP Regen"), ("ArmorRegen", "armor_regen", "Armor Regen"), ("Thorns", "thorns", "Thorns"), ("XpBoost", "xp_boost", "XP Boost"), ]; public async Task OnGetAsync(string? mode) { ModeParam = mode; (_, Balance) = await fleet.BalanceForMode(mode ?? ""); var c = B?.Curves; if (c is null || c.T.Length < 2) return; // Fixed slot order (validated categorical palette, dark surface): identity is carried by each panel's title; // the hue only ties the panel to its slider readout. Panels.Add(BuildPanel("deal", "Damage dealt ×", "#3987e5", c.Deal)); Panels.Add(BuildPanel("take", "Damage taken ×", "#199e70", c.Take)); Panels.Add(BuildPanel("xp", "XP rate ×", "#c98500", c.Xp)); // The simulator's seed: everything the JS math needs, in one blob. Server values = the reset state. PrestigeBoostPercent = Fmt.NumOf(B!.Progression, "PrestigeXpBoostPercent"); SimJson = JsonSerializer.Serialize(new { Curves = c, Handicap = HandicapRows().ToDictionary(r => r.Name, r => r.Num), Stats = StatRows().Select(s => new { s.Key, s.Name, s.Base, s.PerLevel, s.MaxLevel }).ToList(), Cards = CardRows().ToList(), PrestigeBoostPercent, }); } // Survival cards that the simulator can apply: stat-keyed cards (card key == stat key) + the two team cards. public IEnumerable CardRows() { if (B is null || B.Survival.ValueKind != JsonValueKind.Object) yield break; if (!B.Survival.TryGetProperty("Cards", out var cards) || cards.ValueKind != JsonValueKind.Array) yield break; foreach (var e in cards.EnumerateArray()) { string key = Fmt.StrOf(e, "Key"); bool isTeam = Fmt.BoolOf(e, "IsTeam"); if (!isTeam && !StatMap.Any(m => m.Key == key)) continue; // effect cards (burn/explode/...) aren't stat math yield return new { Key = key, Name = Fmt.StrOf(e, "Name"), PerPick = Fmt.NumOf(e, "PerPick"), Cap = Fmt.IntOf(e, "Cap"), IsTeam = isTeam, }; } } private Panel BuildPanel(string key, string title, string color, double[] ys) { double max = ys.Max(); double step = max <= 2 ? 0.5 : max <= 5 ? 1 : 2; double ymax = Math.Max(step, Math.Ceiling(max / step) * step); var sb = new StringBuilder(ys.Length * 12); for (int i = 0; i < ys.Length; i++) { double x = L + (double)i / (ys.Length - 1) * PlotW; double y = T + PlotH * (1 - ys[i] / ymax); if (i > 0) sb.Append(' '); sb.Append(x.ToString("0.#", CultureInfo.InvariantCulture)).Append(',') .Append(y.ToString("0.#", CultureInfo.InvariantCulture)); } double oneY = T + PlotH * (1 - 1.0 / ymax); // the ×1 neutral reference line return new Panel(key, title, color, sb.ToString(), ymax, oneY); } public double YFor(Panel p, double value) => T + PlotH * (1 - value / p.YMax); // The table view of the chart (accessibility + skim value): the five canonical t anchors. public IEnumerable<(double T, double Deal, double Take, double Xp)> KeyRows() { var c = B?.Curves; if (c is null || c.T.Length < 201) yield break; foreach (int i in new[] { 0, 50, 100, 150, 200 }) yield return (c.T[i], c.Deal[i], c.Take[i], c.Xp[i]); } // Stat-tree rows in registry order, joined against the live Stats block (unmapped/new stats fall back to raw name). public IEnumerable<(string Key, string Name, double Base, double PerLevel, int MaxLevel)> StatRows() { if (B is null || B.Stats.ValueKind != JsonValueKind.Object) yield break; foreach (var p in B.Stats.EnumerateObject()) { if (p.Value.ValueKind != JsonValueKind.Object || !p.Value.TryGetProperty("MaxLevel", out _)) continue; var m = StatMap.FirstOrDefault(m => m.Prop == p.Name); yield return (m.Key ?? p.Name.ToLowerInvariant(), m.Display ?? p.Name, Fmt.NumOf(p.Value, "Base"), Fmt.NumOf(p.Value, "PerLevel"), Fmt.IntOf(p.Value, "MaxLevel")); } } // Every scalar knob of the effective (mode-resolved) handicap block, as-is — drift-proof by construction. // Bools ride as 0/1 so the whole block is one editable numeric dictionary for the simulator. public IEnumerable<(string Name, double Num, bool IsBool)> HandicapRows() { if (B is null || B.EffectiveHandicap.ValueKind != JsonValueKind.Object) yield break; foreach (var p in B.EffectiveHandicap.EnumerateObject()) { switch (p.Value.ValueKind) { case JsonValueKind.Number: yield return (p.Name, p.Value.GetDouble(), false); break; case JsonValueKind.True: yield return (p.Name, 1, true); break; case JsonValueKind.False: yield return (p.Name, 0, true); break; } } } // Knob display grouping — name-shape rules so future knobs sort themselves (unknowns land in Thresholds). private static string GroupOf(string name) => name is "Enabled" or "MasterDifficulty" or "Curve" ? "Core" : name.EndsWith("Weight", StringComparison.Ordinal) ? "Factor weights" : name.StartsWith("MDeal", StringComparison.Ordinal) || name.StartsWith("MTake", StringComparison.Ordinal) || name.StartsWith("Xp", StringComparison.Ordinal) ? "Bands" : "Thresholds"; private static readonly string[] GroupOrder = ["Core", "Thresholds", "Factor weights", "Bands"]; public IEnumerable<(string Group, List<(string Name, double Num, bool IsBool)> Rows)> HandicapGroups() => HandicapRows().GroupBy(r => GroupOf(r.Name)) .OrderBy(g => Array.IndexOf(GroupOrder, g.Key) is var i && i < 0 ? int.MaxValue : i) .Select(g => (g.Key, g.ToList())); // Spinner step by magnitude, so Curve 0.8 steps by 0.05 instead of the browser default 1 (typing stays free-form). public static string StepFor(double v) => Math.Abs(v) < 1 ? "0.05" : Math.Abs(v) < 10 ? "0.1" : "1"; }