initial commit
This commit is contained in:
commit
2d966b8198
28 changed files with 2901 additions and 0 deletions
34
Pages/Faq.cshtml
Normal file
34
Pages/Faq.cshtml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
@page "/faq"
|
||||
@{
|
||||
ViewData["Title"] = "FAQ";
|
||||
}
|
||||
|
||||
<h1>FAQ</h1>
|
||||
|
||||
<h2>The game warns me the server is "not secure" / has no VAC. Is my account safe?</h2>
|
||||
<p>
|
||||
Yes. VAC bans come from <em>cheat software on your computer</em> — never from joining any server.
|
||||
Our servers deliberately run without VAC because the mod rewrites a large part of the game server-side
|
||||
(damage scaling, stats, abilities, bot behavior), which is exactly the kind of deep modification VAC-secured
|
||||
servers can't run. There are no human opponents here to cheat against anyway — it's you versus the bots.
|
||||
Joining, playing, and leaving changes nothing about your standing on official servers.
|
||||
</p>
|
||||
|
||||
<h2>Is my progress saved?</h2>
|
||||
<p>
|
||||
Yes — XP, levels, prestige, and your stat build are stored per Steam account and shared across all our servers.
|
||||
Match-scoped things (K/D, streaks, your Gun Game ladder position) reset when a match ends.
|
||||
</p>
|
||||
|
||||
<h2>How do I play? What do I press?</h2>
|
||||
<p>See the <a href="/guides">guides</a> — short version: kill bots, type <code>!skills</code>, spend points, repeat.</p>
|
||||
|
||||
<h2>Why can't I find the servers in the CS2 server browser?</h2>
|
||||
<p>
|
||||
Valve's community browser shows a small, ever-changing subset of thousands of servers — sometimes we're in it,
|
||||
usually not. Use the join buttons on the <a href="/">front page</a>, or add a favorite in-game with our
|
||||
address and the port of the server you like.
|
||||
</p>
|
||||
|
||||
<h2>Something's broken / I have an idea</h2>
|
||||
<p>The mod is open source — issues and PRs welcome at <a href="https://git.lo.sh/kamal/cs2-outnumbered">the repository</a>.</p>
|
||||
53
Pages/Guides/Index.cshtml
Normal file
53
Pages/Guides/Index.cshtml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
@page "/guides"
|
||||
@{
|
||||
ViewData["Title"] = "Guides";
|
||||
}
|
||||
|
||||
<h1>How to play</h1>
|
||||
<p>
|
||||
Every mode shares the same RPG core: kill bots for XP, level up, spend skill points on permanent stats,
|
||||
and at level 100, prestige — reset for a permanent XP boost and an unlocked ability. A per-player handicap
|
||||
continuously scales difficulty to your power: the stronger you play, the harder the bots hit and the faster
|
||||
you earn XP. Your HUD shows the live multipliers.
|
||||
</p>
|
||||
|
||||
<h2>Controls</h2>
|
||||
<div class="notice">
|
||||
Keys shown are the <strong>defaults</strong>: the shop opens on whatever <em>use healthshot</em> is bound to
|
||||
(default <kbd>X</kbd>), and abilities cast on your <em>grenade slot</em> binds (default <kbd>6</kbd>-<kbd>0</kbd>).
|
||||
If you've rebound those, your binds apply — the mod listens to the actions, not the keys.
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>Input</th><th>What it does</th></tr>
|
||||
<tr><td><kbd>X</kbd> (healthshot)</td><td>Opens/closes the quick-buy shop overlay (and the card draft in Survival)</td></tr>
|
||||
<tr><td><kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd></td><td>In the shop: pick primary / pistol / zeus option rows</td></tr>
|
||||
<tr><td><kbd>6</kbd>-<kbd>0</kbd> (grenade keys)</td><td>Cast an unlocked killstreak ability when it's ready</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Chat commands</h2>
|
||||
<p>
|
||||
You only need to remember one: <code title="List all player commands">!commands</code> prints this whole list
|
||||
in game. Hover any command for what it does.
|
||||
</p>
|
||||
<p>
|
||||
<code title="List all player commands">!commands</code>
|
||||
<code title="Show your rank, level and prestige">!rank</code>
|
||||
<code title="Show your live bonuses and handicap">!stats</code>
|
||||
<code title="Open the skill tree to spend points">!skills</code>
|
||||
<code title="Open the weapon selection menu">!guns</code>
|
||||
<code title="Prestige (at max level) for a permanent boost">!prestige</code>
|
||||
<code title="Show your killstreak abilities">!abilities</code>
|
||||
<code title="Show the top players">!top</code>
|
||||
<code title="Toggle the on-screen HUD on/off">!hud</code>
|
||||
<code title="Toggle a per-hit final-damage readout (tuning aid)">!dmg</code>
|
||||
<code title="How the Outnumbered RPG works">!about</code>
|
||||
</p>
|
||||
|
||||
<h2>Modes</h2>
|
||||
<div class="cards">
|
||||
<div class="card"><h3><a href="/guides/tdm">TDM</a></h3><p class="small muted">Straight team deathmatch against the horde. First to the kill goal wins — and the bots are chasing the same goal.</p></div>
|
||||
<div class="card"><h3><a href="/guides/gungame">Gun Game</a></h3><p class="small muted">Climb the weapon ladder; bots climb it too. Headshot deaths demote. Knife finale. The speedrun clock is on the <a href="/leaderboard">leaderboard</a>.</p></div>
|
||||
<div class="card"><h3><a href="/guides/survival">Survival</a></h3><p class="small muted">Endless waves, escalating difficulty, card drafts between waves. How deep can your squad go?</p></div>
|
||||
</div>
|
||||
103
Pages/Guides/Mode.cshtml
Normal file
103
Pages/Guides/Mode.cshtml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
@page "/guides/{mode}"
|
||||
@using CsWeb.Services
|
||||
@model CsWeb.Pages.Guides.ModeModel
|
||||
@{
|
||||
ViewData["Title"] = $"{Fmt.ModeName(Model.Mode)} guide";
|
||||
var b = Model.B;
|
||||
}
|
||||
|
||||
<h1>@Fmt.ModeName(Model.Mode)</h1>
|
||||
|
||||
@if (b is null)
|
||||
{
|
||||
<div class="notice">Live numbers unavailable right now (servers unreachable) — the prose still applies.</div>
|
||||
}
|
||||
else if (Model.Balance is { Online: false })
|
||||
{
|
||||
<div class="notice">Numbers below are from the last reachable server (@Fmt.Age(Model.Balance)).</div>
|
||||
}
|
||||
|
||||
@if (Model.Mode == "tdm")
|
||||
{
|
||||
<p>
|
||||
Team deathmatch against the horde. Everyone respawns; the map ends when one player reaches the kill goal
|
||||
@if (b is not null) { <text>(currently <strong>@Fmt.IntOf(b.Match, "KillGoal")</strong> kills)</text> }
|
||||
— <em>or</em> when the bots do. Bot kills pool per squad-sized batch toward the same goal, so an ignored
|
||||
horde genuinely wins the map: the pace scales with player count, and a lone player races exactly one batch.
|
||||
</p>
|
||||
<p>
|
||||
This is the mode with full loadout freedom — pick any primary and pistol with <code>!guns</code> or the
|
||||
quick-buy shop, and swap mid-match whenever. It's also the purest read on your build: no ladder, no waves,
|
||||
just you, the handicap, and a horde that shoots back harder the better you play. Brief spawn protection
|
||||
keeps respawns from being feeding; it breaks the moment you attack.
|
||||
</p>
|
||||
}
|
||||
else if (Model.Mode == "gungame")
|
||||
{
|
||||
<p>
|
||||
Classic arms race with the RPG core riding along: advance a rung by getting kills with the current gun;
|
||||
the ladder ends in a knife finale. <strong>Bots climb the same ladder</strong> — a bot topping it ends the
|
||||
map, so you're racing them, not just each other. A headshot death demotes the victim one kill of progress —
|
||||
and that cuts both ways: headshotting the bot pack slows their climb, catching a headshot costs you yours.
|
||||
</p>
|
||||
<p>
|
||||
Kills needed per rung follow the weapon's tier — weak guns take grinding, strong guns breeze — while the
|
||||
bots run the <em>inverse</em> curve, so the race stays tight whether you're stuck on a Nova or cruising an
|
||||
AK. Weapon selection is off (your rung <em>is</em> your gun), which makes Gun Game the skills-only mode:
|
||||
the shop sells stat levels, nothing else. Your run time (first damage → final knife) goes on the
|
||||
<a href="/leaderboard">leaderboard</a>; the clock survives reconnects, so there's no resetting a bad start.
|
||||
</p>
|
||||
var ladder = Model.Ladder().ToList();
|
||||
@if (ladder.Count > 0)
|
||||
{
|
||||
<h2>The ladder</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th class="num">Rung</th><th>Weapon</th><th class="num">Tier</th></tr>
|
||||
@foreach (var (r, i) in ladder.Select((r, i) => (r, i)))
|
||||
{
|
||||
<tr><td class="num">@(i + 1)</td><td>@Fmt.PrettyWeapon(r.Weapon)</td><td class="num">@r.Tier</td></tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted small">Kills needed per rung scale with the weapon's tier — weak guns grind, strong guns breeze. Bots get the inverse curve.</p>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
Co-op waves@(b is not null ? $" (up to {Fmt.IntOf(b.Survival, "WaveCount")})" : "") of escalating
|
||||
difficulty. Each wave has a kill budget; clear it and you get a breather — the fallen are revived, XP banks,
|
||||
and the squad drafts cards. Nobody respawns <em>mid</em>-wave, and XP banks <em>per cleared wave</em>:
|
||||
wipe halfway through and that wave's earnings are forfeit. Going down isn't the end (your squad can finish
|
||||
the wave and pick you back up), but a full wipe ends the run.
|
||||
</p>
|
||||
<p>
|
||||
Cards are run-scoped upgrades stacking on top of your permanent build — strong on purpose, because the
|
||||
difficulty ramp is relentless: the handicap's escalation floor climbs wave over wave until the bots hit you
|
||||
at full force, and only a drafted-up squad outpaces it. The two team cards share one squad-wide level —
|
||||
anyone's pick raises everyone's multiplier — so coordinating drafts beats hoarding. Your deepest cleared
|
||||
wave goes on the <a href="/leaderboard">leaderboard</a>.
|
||||
</p>
|
||||
var cards = Model.Cards().ToList();
|
||||
@if (cards.Count > 0)
|
||||
{
|
||||
<h2>Card catalog</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>Card</th><th class="num">Per pick</th><th class="num">Max picks</th><th>Notes</th></tr>
|
||||
@foreach (var c in cards)
|
||||
{
|
||||
<tr>
|
||||
<td>@Fmt.StrOf(c, "Name")</td>
|
||||
<td class="num">@Fmt.NumOf(c, "PerPick")@(Fmt.BoolOf(c, "Flat") ? "" : "%")</td>
|
||||
<td class="num">@Fmt.IntOf(c, "Cap")</td>
|
||||
<td class="muted small">@(Fmt.BoolOf(c, "IsTeam") ? "team-wide, shared level" : Fmt.CardDetail(Fmt.StrOf(c, "Detail"), Fmt.NumOf(c, "PerPick")))</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<p><a href="/theory">Theory</a> has the full stat tables and the handicap math behind all of this.</p>
|
||||
41
Pages/Guides/Mode.cshtml.cs
Normal file
41
Pages/Guides/Mode.cshtml.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Text.Json;
|
||||
using CsWeb.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace CsWeb.Pages.Guides;
|
||||
|
||||
public class ModeModel(Fleet fleet) : PageModel
|
||||
{
|
||||
private static readonly string[] Known = ["tdm", "gungame", "survival"];
|
||||
|
||||
public string Mode { get; private set; } = "";
|
||||
public Cached<BalancePayload>? Balance { get; private set; }
|
||||
public BalancePayload? B => Balance?.Data;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string mode)
|
||||
{
|
||||
mode = mode.ToLowerInvariant();
|
||||
if (!Known.Contains(mode)) return NotFound();
|
||||
Mode = mode;
|
||||
(_, Balance) = await fleet.BalanceForMode(mode);
|
||||
return Page();
|
||||
}
|
||||
|
||||
// The GG ladder rows from the balance payload's GunGame.Ladder ([{Weapon, Tier}]).
|
||||
public IEnumerable<(string Weapon, int Tier)> Ladder()
|
||||
{
|
||||
if (B is null || B.GunGame.ValueKind != JsonValueKind.Object) yield break;
|
||||
if (!B.GunGame.TryGetProperty("Ladder", out var l) || l.ValueKind != JsonValueKind.Array) yield break;
|
||||
foreach (var e in l.EnumerateArray())
|
||||
yield return (Fmt.StrOf(e, "Weapon"), Fmt.IntOf(e, "Tier"));
|
||||
}
|
||||
|
||||
// Survival card catalog rows ([{Key, Name, PerPick, Cap, Flat?, IsTeam?, Detail?}] — tolerant of shape drift).
|
||||
public IEnumerable<JsonElement> Cards()
|
||||
{
|
||||
if (B is null || B.Survival.ValueKind != JsonValueKind.Object) yield break;
|
||||
if (!B.Survival.TryGetProperty("Cards", out var c) || c.ValueKind != JsonValueKind.Array) yield break;
|
||||
foreach (var e in c.EnumerateArray()) yield return e;
|
||||
}
|
||||
}
|
||||
62
Pages/Index.cshtml
Normal file
62
Pages/Index.cshtml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
@page
|
||||
@using CsWeb.Services
|
||||
@model IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "";
|
||||
}
|
||||
@section Meta {
|
||||
<meta property="og:title" content="Outnumbered — CS2 PvE RPG servers" />
|
||||
<meta property="og:description" content="@Model.ServersUp servers up, @Model.HumansOnline player(s) in game. Level up, prestige, fight the horde — TDM, Gun Game, Survival." />
|
||||
<meta property="og:type" content="website" />
|
||||
}
|
||||
|
||||
<h1>Outnumbered</h1>
|
||||
<p>
|
||||
A players-vs-bots RPG mod for CS2: you and a few humans versus a scaling horde. Kill for XP, level up,
|
||||
spend points on permanent stats, prestige for keeps — across three modes. No matchmaking, no ranks lost,
|
||||
no teenagers screaming into your ear. Pick a server and shoot something.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
These servers run <a href="/faq">deliberately without VAC</a> (it's a PvE mod that rewrites half the game).
|
||||
Your account is safe — see the FAQ.
|
||||
</p>
|
||||
|
||||
@if (Model.Servers.Count == 0)
|
||||
{
|
||||
<div class="notice err">No servers found. If you just deployed, the game instances may still be starting.</div>
|
||||
}
|
||||
<div class="cards">
|
||||
@foreach (var (id, s) in Model.Servers)
|
||||
{
|
||||
var d = s.Data;
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<h3><a href="/s/@id">@(d?.Hostname is { Length: > 0 } h ? h : id)</a></h3>
|
||||
@if (!s.Online) { <span class="badge off">offline</span> }
|
||||
else { <span class="badge mode">@Fmt.ModeName(d?.Mode)</span> }
|
||||
</div>
|
||||
@if (d is not null)
|
||||
{
|
||||
<div class="row">
|
||||
<span class="muted">@d.Map</span>
|
||||
<span>@d.Humans.Count/@d.MaxHumans players · @d.Bots bots</span>
|
||||
</div>
|
||||
@if (Fmt.ExtraLine(d) is { Length: > 0 } extra)
|
||||
{
|
||||
<div class="muted small">@extra</div>
|
||||
}
|
||||
@if (d.Port > 0)
|
||||
{
|
||||
<div class="row">
|
||||
<span class="connect">connect @Model.Fleet.PublicHost:@d.Port</span>
|
||||
<a class="btn" href="steam://connect/@Model.Fleet.PublicHost:@d.Port">Join</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="muted small">@Fmt.Age(s)</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
21
Pages/Index.cshtml.cs
Normal file
21
Pages/Index.cshtml.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using CsWeb.Services;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace CsWeb.Pages;
|
||||
|
||||
public class IndexModel(Fleet fleet) : PageModel
|
||||
{
|
||||
public Fleet Fleet => fleet;
|
||||
public List<(string Id, Cached<StatusPayload> Status)> Servers { get; private set; } = [];
|
||||
public int HumansOnline { get; private set; }
|
||||
public int ServersUp { get; private set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var ids = fleet.Instances();
|
||||
var fetched = await Task.WhenAll(ids.Select(fleet.Status));
|
||||
Servers = ids.Zip(fetched, (id, s) => (id, s)).ToList();
|
||||
HumansOnline = Servers.Sum(s => s.Status.Online ? s.Status.Data?.Humans.Count ?? 0 : 0);
|
||||
ServersUp = Servers.Count(s => s.Status.Online);
|
||||
}
|
||||
}
|
||||
62
Pages/Leaderboard.cshtml
Normal file
62
Pages/Leaderboard.cshtml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
@page "/leaderboard"
|
||||
@using CsWeb.Services
|
||||
@model LeaderboardModel
|
||||
@{
|
||||
ViewData["Title"] = "Leaderboard";
|
||||
var t = Model.Top;
|
||||
var d = t?.Data;
|
||||
}
|
||||
|
||||
<h1>Leaderboard</h1>
|
||||
@if (t is null || d is null)
|
||||
{
|
||||
<div class="notice err">Leaderboards are unavailable right now (no server reachable).</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (!t.Online)
|
||||
{
|
||||
<div class="notice">Servers are offline — showing the last known standings (@Fmt.Age(t)).</div>
|
||||
}
|
||||
<div class="cols">
|
||||
<section>
|
||||
<h2>Progression</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>#</th><th>Player</th><th class="num">Prestige</th><th class="num">Level</th></tr>
|
||||
@foreach (var (r, i) in d.Levels.Select((r, i) => (r, i)))
|
||||
{
|
||||
<tr><td class="num">@(i + 1)</td><td>@r.Name</td><td class="num">@r.Prestige</td><td class="num">@r.Level</td></tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Deepest survival wave</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>#</th><th>Player</th><th class="num">Wave</th></tr>
|
||||
@foreach (var (r, i) in d.Waves.Select((r, i) => (r, i)))
|
||||
{
|
||||
<tr><td class="num">@(i + 1)</td><td>@r.Name</td><td class="num">@r.BestWave</td></tr>
|
||||
}
|
||||
@if (d.Waves.Count == 0) { <tr><td colspan="3" class="muted">No cleared waves recorded yet.</td></tr> }
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Fastest Gun Game</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>#</th><th>Player</th><th class="num">Time</th></tr>
|
||||
@foreach (var (r, i) in d.GgTimes.Select((r, i) => (r, i)))
|
||||
{
|
||||
<tr><td class="num">@(i + 1)</td><td>@r.Name</td><td class="num">@Fmt.RunTime(r.BestMs)</td></tr>
|
||||
}
|
||||
@if (d.GgTimes.Count == 0) { <tr><td colspan="3" class="muted">No full ladder runs recorded yet.</td></tr> }
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="muted small">The clock starts at your first damage dealt or taken after joining — reconnecting doesn't reset it.</p>
|
||||
}
|
||||
11
Pages/Leaderboard.cshtml.cs
Normal file
11
Pages/Leaderboard.cshtml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using CsWeb.Services;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace CsWeb.Pages;
|
||||
|
||||
public class LeaderboardModel(Fleet fleet) : PageModel
|
||||
{
|
||||
public Cached<TopPayload>? Top { get; private set; }
|
||||
|
||||
public async Task OnGetAsync() => Top = await fleet.Top();
|
||||
}
|
||||
12
Pages/Privacy.cshtml
Normal file
12
Pages/Privacy.cshtml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@page "/privacy"
|
||||
@{
|
||||
ViewData["Title"] = "Privacy";
|
||||
}
|
||||
|
||||
<h1>Privacy</h1>
|
||||
<p>
|
||||
The game servers store your SteamID, display name, and gameplay statistics (XP, levels, records) to make
|
||||
progression and leaderboards work; this website reads that data and stores nothing of its own, sets no cookies,
|
||||
and runs no trackers or analytics. To have your data removed, contact the operator via
|
||||
<a href="https://git.lo.sh/kamal/cs2-outnumbered">the repository</a>.
|
||||
</p>
|
||||
20
Pages/Rules.cshtml
Normal file
20
Pages/Rules.cshtml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
@page "/rules"
|
||||
@{
|
||||
ViewData["Title"] = "Rules";
|
||||
}
|
||||
|
||||
<h1>Rules</h1>
|
||||
<p>It's PvE. There isn't much to ruin — but the little there is, don't:</p>
|
||||
<ul>
|
||||
<li>No cheats or automation. Yes, even against bots — the leaderboards are shared.</li>
|
||||
<li>No leaderboard griefing (win-trading the Gun Game timer, farming an empty server with exploits).</li>
|
||||
<li>Keep chat civil. The entire point of this place is not having to mute anyone.</li>
|
||||
<li>Exploits and crashes: report them, don't farm them.</li>
|
||||
</ul>
|
||||
<p class="muted">Breaking these gets your progress wiped and/or a ban. There is no appeals process; there is one admin.</p>
|
||||
|
||||
<h2>Wipes</h2>
|
||||
<p>
|
||||
No scheduled wipes. Balance changes happen live and are listed in the <a href="/changelog">changelog</a>;
|
||||
if a change ever requires a reset (unlikely), it will be announced here first.
|
||||
</p>
|
||||
71
Pages/Server.cshtml
Normal file
71
Pages/Server.cshtml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
@page "/s/{id}"
|
||||
@using CsWeb.Services
|
||||
@model ServerModel
|
||||
@{
|
||||
var s = Model.Status!;
|
||||
var d = s.Data;
|
||||
var title = d?.Hostname is { Length: > 0 } h ? h : Model.Id;
|
||||
ViewData["Title"] = title;
|
||||
var ogDesc = d is null
|
||||
? "Outnumbered CS2 server"
|
||||
: $"{Fmt.ModeName(d.Mode)} on {d.Map} — {d.Humans.Count}/{d.MaxHumans} players, {d.Bots} bots."
|
||||
+ (Fmt.ExtraLine(d) is { Length: > 0 } ex ? $" {ex}." : "");
|
||||
}
|
||||
@section Meta {
|
||||
<meta property="og:title" content="@title" />
|
||||
<meta property="og:description" content="@ogDesc" />
|
||||
<meta property="og:type" content="website" />
|
||||
}
|
||||
|
||||
<h1>@title</h1>
|
||||
<p class="muted">
|
||||
<span class="badge mode">@Fmt.ModeName(d?.Mode)</span>
|
||||
@if (!s.Online) { <span class="badge off">@Fmt.Age(s)</span> }
|
||||
</p>
|
||||
|
||||
@if (d is null)
|
||||
{
|
||||
<div class="notice err">This server hasn't reported yet. If it just started, give it a few seconds.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<p>
|
||||
Map <code>@d.Map</code> · @d.Humans.Count/@d.MaxHumans players · @d.Bots bots
|
||||
@if (Fmt.ExtraLine(d) is { Length: > 0 } extra) { <span> · @extra</span> }
|
||||
</p>
|
||||
</div>
|
||||
@if (d.Port > 0)
|
||||
{
|
||||
<p>
|
||||
<a class="btn" href="steam://connect/@Model.Fleet.PublicHost:@d.Port">Join now</a>
|
||||
or paste in console: <span class="connect">connect @Model.Fleet.PublicHost:@d.Port</span>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (d.Humans.Count > 0)
|
||||
{
|
||||
<h2>Players</h2>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th><th class="num">Level</th><th class="num">Prestige</th>
|
||||
<th class="num">K</th><th class="num">D</th><th class="num">Streak</th>
|
||||
@if (d.Mode == "gungame") { <th>Weapon</th> }
|
||||
</tr>
|
||||
@foreach (var p in d.Humans)
|
||||
{
|
||||
<tr>
|
||||
<td>@p.Name</td><td class="num">@p.Level</td><td class="num">@p.Prestige</td>
|
||||
<td class="num">@p.Kills</td><td class="num">@p.Deaths</td><td class="num">@p.Streak</td>
|
||||
@if (d.Mode == "gungame") { <td>@(Fmt.RungWeapon(d, p) ?? "—")</td> }
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">Nobody in game right now — free server, all yours.</p>
|
||||
}
|
||||
}
|
||||
21
Pages/Server.cshtml.cs
Normal file
21
Pages/Server.cshtml.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using CsWeb.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace CsWeb.Pages;
|
||||
|
||||
public class ServerModel(Fleet fleet) : PageModel
|
||||
{
|
||||
public Fleet Fleet => fleet;
|
||||
public string Id { get; private set; } = "";
|
||||
public Cached<StatusPayload>? Status { get; private set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string id)
|
||||
{
|
||||
// The slug space is exactly the socket names — anything else 404s before touching a socket path.
|
||||
if (!fleet.Instances().Contains(id)) return NotFound();
|
||||
Id = id;
|
||||
Status = await fleet.Status(id);
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
36
Pages/Shared/_Layout.cshtml
Normal file
36
Pages/Shared/_Layout.cshtml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>@(ViewData["Title"] is string t && t.Length > 0 ? $"{t} — cs2-on.eu" : "cs2-on.eu — Outnumbered CS2 PvE RPG")</title>
|
||||
@RenderSection("Meta", required: false)
|
||||
<link rel="stylesheet" href="/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="wrap">
|
||||
<a class="brand" href="/">cs2-on<span>.eu</span></a>
|
||||
<a href="/">Servers</a>
|
||||
<a href="/leaderboard">Leaderboard</a>
|
||||
<a href="/guides">Guides</a>
|
||||
<a href="/theory">Theorycrafting</a>
|
||||
<a href="/faq">FAQ</a>
|
||||
<a href="/rules">Rules</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
@RenderBody()
|
||||
</main>
|
||||
<footer class="wrap">
|
||||
<span>Outnumbered — a players-vs-bots RPG mod for CS2.</span>
|
||||
<span>
|
||||
<a href="/changelog">Changelog</a> ·
|
||||
<a href="https://git.lo.sh/kamal/cs2-outnumbered">Source</a> ·
|
||||
<a href="/privacy">Privacy</a>
|
||||
</span>
|
||||
</footer>
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
217
Pages/Theory.cshtml
Normal file
217
Pages/Theory.cshtml
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
@page "/theory"
|
||||
@using CsWeb.Services
|
||||
@model TheoryModel
|
||||
@{
|
||||
ViewData["Title"] = "Theorycrafting";
|
||||
var b = Model.B;
|
||||
}
|
||||
|
||||
<h1>Theorycrafting</h1>
|
||||
<p>
|
||||
Numbers below are <strong>live</strong> — read from the running servers' effective config, including any
|
||||
hot-reloaded balance tuning. The curves are computed by the same compiled code that scales damage in game.
|
||||
Everything is also <strong>editable</strong>: change any knob to explore; <em>Reset</em> returns to server values.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Showing values for <strong>@Fmt.ModeName(b?.Mode)</strong> —
|
||||
view for <a href="/theory?mode=tdm">TDM</a> · <a href="/theory?mode=gungame">Gun Game</a> · <a href="/theory?mode=survival">Survival</a>
|
||||
(modes can override handicap bands).
|
||||
</p>
|
||||
|
||||
@if (b is null)
|
||||
{
|
||||
<div class="notice err">No server reachable right now — theory needs a live config to be honest. Try again shortly.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Model.ModeMismatch)
|
||||
{
|
||||
<div class="notice">No @Fmt.ModeName(Model.ModeParam) server is reachable right now — showing <strong>@Fmt.ModeName(b.Mode)</strong> values (the only live config available).</div>
|
||||
}
|
||||
@if (Model.Balance is { Online: false })
|
||||
{
|
||||
<div class="notice">Servers offline — showing the last known config (@Fmt.Age(Model.Balance!)).</div>
|
||||
}
|
||||
|
||||
<h2>The handicap</h2>
|
||||
<p>
|
||||
One signed index <em>t</em> summarizes how dominant you are (K/D, headshot rate, killstreak, level, and the
|
||||
mode's progress axis, weighted and eased). <em>t</em> = 0 is neutral; +1 is fully dominant;
|
||||
−1 is struggling. All three multipliers are driven by the same <em>t</em>, so they hit their extremes together:
|
||||
dominate and you deal less, take more, and level faster — all at once.
|
||||
</p>
|
||||
|
||||
@if (Model.Panels.Count > 0)
|
||||
{
|
||||
<figure id="curves-fig">
|
||||
<div class="readout" id="readout">
|
||||
<label>t <input type="range" id="tslider" min="-1" max="1" step="0.01" value="0" /></label>
|
||||
<span id="tval">t = 0.00</span>
|
||||
@foreach (var p in Model.Panels)
|
||||
{
|
||||
<span><i class="dot" style="background:@p.Color"></i> <span id="ro-@p.Key">×1.00</span></span>
|
||||
}
|
||||
<span class="muted small">dashed green line = your simulated player</span>
|
||||
</div>
|
||||
@foreach (var (p, i) in Model.Panels.Select((p, i) => (p, i)))
|
||||
{
|
||||
var last = i == Model.Panels.Count - 1;
|
||||
var height = TheoryModel.T + TheoryModel.PlotH + (last ? 26 : 8);
|
||||
<svg class="panel" viewBox="0 0 @TheoryModel.W @height" data-key="@p.Key" role="img" aria-label="@p.Title versus t">
|
||||
<text class="ptitle" x="@TheoryModel.L" y="@(TheoryModel.T - 1)">@p.Title</text>
|
||||
<line class="grid" x1="@TheoryModel.L" y1="@Model.YFor(p, 0)" x2="@(TheoryModel.W - TheoryModel.R)" y2="@Model.YFor(p, 0)" />
|
||||
<line class="grid" x1="@TheoryModel.L" y1="@Model.YFor(p, p.YMax)" x2="@(TheoryModel.W - TheoryModel.R)" y2="@Model.YFor(p, p.YMax)" />
|
||||
<line class="grid one" x1="@TheoryModel.L" y1="@p.OneY" x2="@(TheoryModel.W - TheoryModel.R)" y2="@p.OneY" />
|
||||
<text class="tick ymax" x="@(TheoryModel.L - 5)" y="@(Model.YFor(p, p.YMax) + 4)">@p.YMax.ToString("0.#")</text>
|
||||
<text class="tick" x="@(TheoryModel.L - 5)" y="@(p.OneY + 4)">×1</text>
|
||||
<text class="tick" x="@(TheoryModel.L - 5)" y="@(Model.YFor(p, 0) + 4)">0</text>
|
||||
<polyline class="series" points="@p.Points" style="stroke:@p.Color" />
|
||||
<line class="simt" x1="@(TheoryModel.L + Model.PlotW / 2.0)" y1="@TheoryModel.T" x2="@(TheoryModel.L + Model.PlotW / 2.0)" y2="@(TheoryModel.T + TheoryModel.PlotH)" />
|
||||
<line class="cross" x1="@(TheoryModel.L + Model.PlotW / 2.0)" y1="@TheoryModel.T" x2="@(TheoryModel.L + Model.PlotW / 2.0)" y2="@(TheoryModel.T + TheoryModel.PlotH)" />
|
||||
@if (last)
|
||||
{
|
||||
@* SVG <text> collides with Razor's literal pseudo-tag inside code blocks -> raw emit *@
|
||||
foreach (var tv in new[] { -1.0, -0.5, 0.0, 0.5, 1.0 })
|
||||
{
|
||||
var x = TheoryModel.L + (tv + 1) / 2 * Model.PlotW;
|
||||
@Html.Raw($"<text class=\"tick mid\" x=\"{x:0.#}\" y=\"{TheoryModel.T + TheoryModel.PlotH + 16}\">{tv:0.#}</text>")
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
<figcaption class="muted small">← struggling · t · dominant → (drag the slider or hover the chart; curves follow your edits below)</figcaption>
|
||||
</figure>
|
||||
}
|
||||
|
||||
<h2 id="sim">Simulator</h2>
|
||||
<p class="small muted">
|
||||
Seeded from the live server config. Edit anything — player state, stat levels, card picks, handicap knobs —
|
||||
and every readout and curve recomputes. Abilities, crits and headshots are excluded (parity with the in-game
|
||||
HUD's base readout). Edited fields get an amber outline.
|
||||
</p>
|
||||
|
||||
<div class="sim">
|
||||
<div class="simout" id="simout">
|
||||
<span><span class="lbl">index</span><b id="sim-t">0.00</b></span>
|
||||
<span><span class="lbl">deal band</span><b id="sim-deal">×1.00</b></span>
|
||||
<span><span class="lbl">take band</span><b id="sim-take">×1.00</b></span>
|
||||
<span><span class="lbl">xp band</span><b id="sim-xpband">×1.00</b></span>
|
||||
<span><span class="lbl">Out × (with stats)</span><b id="sim-out">×1.00</b></span>
|
||||
<span><span class="lbl">In ×</span><b id="sim-in">×1.00</b></span>
|
||||
<span><span class="lbl">HS ×</span><b id="sim-hs">×1.00</b></span>
|
||||
<span><span class="lbl">XP × (total)</span><b id="sim-xp">×1.00</b></span>
|
||||
<button class="btn" id="sim-reset" type="button">Reset to server</button>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Player state</legend>
|
||||
<div class="fields">
|
||||
<label>Level <input type="number" data-sim="level" min="0" max="100" value="0" /></label>
|
||||
<label>Prestige <input type="number" data-sim="prestige" min="0" value="0" /></label>
|
||||
<label>Kills <input type="number" data-sim="kills" min="0" value="0" /></label>
|
||||
<label>Deaths <input type="number" data-sim="deaths" min="0" value="0" /></label>
|
||||
<label>HS kills <input type="number" data-sim="hsk" min="0" value="0" /></label>
|
||||
<label>Streak <input type="number" data-sim="streak" min="0" value="0" /></label>
|
||||
<label>Mode progress (0-1) <input type="number" data-sim="progress" min="0" max="1" step="0.05" value="0" /></label>
|
||||
<label>Escalation floor t <input type="number" data-sim="floor" min="-1" max="1" step="0.05" value="-1" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="cols">
|
||||
<fieldset>
|
||||
<legend>Stat tree (invested levels)</legend>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>Stat</th><th class="num">Base</th><th class="num">Per lvl</th><th class="num">Invested</th><th class="num">Effective</th></tr>
|
||||
@foreach (var s in Model.StatRows())
|
||||
{
|
||||
<tr>
|
||||
<td>@s.Name</td>
|
||||
<td class="num">@s.Base</td>
|
||||
<td class="num">@s.PerLevel</td>
|
||||
<td class="num"><input type="number" data-stat="@s.Key" min="0" max="@s.MaxLevel" value="0" title="0-@s.MaxLevel" aria-label="@s.Name invested levels" /></td>
|
||||
<td class="num" id="eff-@s.Key">@s.Base</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend>Survival cards (run-scoped picks)</legend>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th>Card</th><th class="num">Per pick</th><th class="num">Picks</th></tr>
|
||||
@foreach (var c in Model.CardRows())
|
||||
{
|
||||
dynamic card = c;
|
||||
<tr>
|
||||
<td>@card.Name @(card.IsTeam ? Html.Raw("<span class=\"badge\">team</span>") : Html.Raw(""))</td>
|
||||
<td class="num">@card.PerPick</td>
|
||||
<td class="num"><input type="number" data-card="@card.Key" data-team="@(card.IsTeam ? 1 : 0)" data-perpick="@card.PerPick" min="0" max="@card.Cap" value="0" title="0-@card.Cap" aria-label="@card.Name picks" /></td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>XP</legend>
|
||||
<div class="fields">
|
||||
<label>Prestige boost %/prestige <input type="number" data-sim="prestigeBoost" step="1" value="@Model.PrestigeBoostPercent" /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Handicap knobs (@Fmt.ModeName(b.Mode) effective)</legend>
|
||||
<div class="kgroups">
|
||||
@foreach (var (group, rows) in Model.HandicapGroups())
|
||||
{
|
||||
<section class="kgroup">
|
||||
<h4>@group</h4>
|
||||
@foreach (var r in rows)
|
||||
{
|
||||
if (r.IsBool)
|
||||
{
|
||||
<label>@r.Name <input type="checkbox" data-h="@r.Name" checked="@(r.Num != 0)" /></label>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label>@r.Name <input type="number" data-h="@r.Name" step="@TheoryModel.StepFor(r.Num)" value="@r.Num.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)" /></label>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<h3>Key points (server values)</h3>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<tr><th class="num">t</th><th class="num">Deal ×</th><th class="num">Take ×</th><th class="num">XP ×</th></tr>
|
||||
@foreach (var r in Model.KeyRows())
|
||||
{
|
||||
<tr>
|
||||
<td class="num">@r.T.ToString("0.0")</td>
|
||||
<td class="num">@r.Deal.ToString("0.00")</td>
|
||||
<td class="num">@r.Take.ToString("0.00")</td>
|
||||
<td class="num">@r.Xp.ToString("0.00")</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="muted small">
|
||||
Weapon-by-weapon time-to-kill tables and per-weapon damage simulation are planned on top of this.
|
||||
</p>
|
||||
|
||||
<script type="application/json" id="sim-data">@Html.Raw(Model.SimJson)</script>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="/js/theory.js" defer></script>
|
||||
}
|
||||
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";
|
||||
}
|
||||
3
Pages/_ViewImports.cshtml
Normal file
3
Pages/_ViewImports.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@using CsWeb
|
||||
@namespace CsWeb.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
Pages/_ViewStart.cshtml
Normal file
3
Pages/_ViewStart.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue