cs2-web/Pages/Theory.cshtml.cs
2026-07-07 20:23:39 +03:00

226 lines
11 KiB
C#
Raw 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.Net;
using System.Text;
using System.Text.Json;
using CsWeb.Services;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CsWeb.Pages;
public class TheoryModel(Fleet fleet, Weapons weapons) : 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;
BuildWeapons(); // file-backed, independent of the sockets — the STK table renders even with the fleet down
(_, 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";
// ---- weapons / STK table (numbers from extract-weapons.sh via the Weapons store; math in WeaponMath) ----
public WeaponsDoc? Weap { get; private set; }
public string WeaponsJson { get; private set; } = "null";
private void BuildWeapons()
{
Weap = weapons.Doc;
if (Weap is null) return;
WeaponsJson = JsonSerializer.Serialize(new
{
Weap.GameVersion,
Weap.Weapons,
// Parity target: the default table state (distance 0, armored, vanilla) that weapons.js recomputes at
// load. Distance 0 keeps pow() out of the picture (x^0 = 1 exactly), so with the same operation order
// C# and JS produce bit-identical doubles and the comparison is exact-integer, no tolerance needed.
Ssr = Weap.Weapons.Select(w => new
{
w.Id,
Shot = WeaponMath.Hitgroups.Select(hg => WeaponMath.Shot(w, 0, hg, true)).ToArray(),
}).ToList(),
});
}
private static readonly (string Key, string Label)[] CatOrder =
[("rifles", "Rifles"), ("pistols", "Pistols"), ("smgs", "SMGs"), ("snipers", "Snipers"), ("shotguns", "Shotguns"), ("heavy", "Heavy")];
public IEnumerable<(string Label, List<WeaponRow> Rows)> WeaponGroups()
{
if (Weap is null) yield break;
foreach (var (key, label) in CatOrder)
{
var rows = Weap.Weapons.Where(w => w.Cat == key).OrderBy(w => w.Name, StringComparer.Ordinal).ToList();
if (rows.Count > 0) yield return (label, rows);
}
var known = CatOrder.Select(c => c.Key).ToHashSet();
var other = Weap.Weapons.Where(w => !known.Contains(w.Cat)).OrderBy(w => w.Name, StringComparer.Ordinal).ToList();
if (other.Count > 0) yield return ("Other", other); // a future extractor category still gets rendered
}
// Inline the extractor's cleaned SVG with img semantics — role + aria-label carry the weapon name (the "alt").
public string IconFor(WeaponRow w)
{
var svg = weapons.IconSvg(w.Icon);
if (svg is null) return "";
int i = svg.IndexOf("<svg", StringComparison.Ordinal);
return i < 0 ? "" : svg.Insert(i + 4, $" class=\"wicon\" role=\"img\" aria-label=\"{WebUtility.HtmlEncode(w.Name)}\"");
}
public static string StkStr(int shot) => WeaponMath.Stk(shot) is var s && s > 0 ? s.ToString(CultureInfo.InvariantCulture) : "—";
public static string TtkStr(WeaponRow w, int shot) =>
WeaponMath.Stk(shot) is var s && s > 0 ? WeaponMath.Ttk(w, s).ToString("0.00", CultureInfo.InvariantCulture) + " s" : "—";
}