cs2-web/Pages/Theory.cshtml.cs
Kamal Tufekcic 2d966b8198 initial commit
2026-07-05 12:14:39 +03:00

170 lines
8.1 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<BalancePayload>? Balance { get; private set; }
public BalancePayload? B => Balance?.Data;
public List<Panel> 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<object> 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";
}