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

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