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

34
Pages/Faq.cshtml Normal file
View 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
View 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
View 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>

View 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
View 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
View 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
View 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>
}

View 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
View 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
View 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
View 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>
&nbsp; 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
View 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();
}
}

View 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
View 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>&nbsp;=&nbsp;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 &nbsp;·&nbsp; t &nbsp;·&nbsp; dominant → &nbsp; (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
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";
}

View file

@ -0,0 +1,3 @@
@using CsWeb
@namespace CsWeb.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

3
Pages/_ViewStart.cshtml Normal file
View file

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}