initial commit

This commit is contained in:
Kamal Tufekcic 2026-07-05 12:14:39 +03:00
commit 2d966b8198
28 changed files with 2901 additions and 0 deletions

170
Pages/Theory.cshtml.cs Normal file
View file

@ -0,0 +1,170 @@
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";
}