initial commit
This commit is contained in:
commit
2d966b8198
28 changed files with 2901 additions and 0 deletions
170
Pages/Theory.cshtml.cs
Normal file
170
Pages/Theory.cshtml.cs
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue