minor optimizations and weapon sim
This commit is contained in:
parent
2d966b8198
commit
fc7887ce11
10 changed files with 419 additions and 11 deletions
|
|
@ -83,6 +83,12 @@ else
|
||||||
</figure>
|
</figure>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="notice err" id="sim-drift" hidden>
|
||||||
|
The simulator's local math no longer matches the server-computed curves (site/mod version drift).
|
||||||
|
The charts and key points shown are still server truth, but <strong>edited-knob results may be inaccurate</strong>
|
||||||
|
until the site is updated. Details in the browser console.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 id="sim">Simulator</h2>
|
<h2 id="sim">Simulator</h2>
|
||||||
<p class="small muted">
|
<p class="small muted">
|
||||||
Seeded from the live server config. Edit anything — player state, stat levels, card picks, handicap knobs —
|
Seeded from the live server config. Edit anything — player state, stat levels, card picks, handicap knobs —
|
||||||
|
|
@ -205,13 +211,83 @@ else
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted small">
|
<script type="application/json" id="sim-data">@Html.Raw(Model.SimJson)</script>
|
||||||
Weapon-by-weapon time-to-kill tables and per-weapon damage simulation are planned on top of this.
|
}
|
||||||
|
|
||||||
|
<h2 id="weapons">Weapons — shots to kill</h2>
|
||||||
|
@if (Model.Weap is null)
|
||||||
|
{
|
||||||
|
<div class="notice">Weapon data hasn't been generated on this host yet (deploy-web/extract-weapons.sh).</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
Vanilla CS2 bullet math, computed from numbers extracted out of the <strong>installed game's own files</strong>
|
||||||
|
(re-extracted on every Valve update — never hand-copied). Shots to kill a <strong>100 HP</strong> target per
|
||||||
|
hitgroup, with distance falloff, per-weapon armor penetration and the per-weapon headshot multipliers
|
||||||
|
(the Deagle's ×3.9 and M4A1-S's ×3.475 included). Tick <em>apply my build & handicap</em> to layer your
|
||||||
|
simulated player from above onto the same math.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<script type="application/json" id="sim-data">@Html.Raw(Model.SimJson)</script>
|
<div class="notice err" id="wpn-drift" hidden>
|
||||||
|
This table's local math no longer matches the server-computed values (site version drift). The numbers shown
|
||||||
|
are still server truth, but <strong>the controls below may compute inaccurate results</strong> until the site
|
||||||
|
is updated. Details in the browser console.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sim">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Target & range</legend>
|
||||||
|
<div class="fields">
|
||||||
|
<label>Distance <input type="range" id="wpn-dist" min="0" max="3000" step="25" value="0" autocomplete="off" /> <b id="wpn-distv">0 u</b></label>
|
||||||
|
<label>Kevlar + helmet <input type="checkbox" id="wpn-armor" checked autocomplete="off" /></label>
|
||||||
|
<label title="Out × and HS × from the simulator above, versus a bot (100 HP, assault suit — always armored). Crits and abilities stay excluded.">
|
||||||
|
Apply my build & handicap <input type="checkbox" id="wpn-mod" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted small">500 units ≈ one falloff step · a — cell means the weapon's bullets don't reach that distance at all (shotguns cut off earliest) · AWP mid ≈ 1500–2500 units.</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table id="wpn-table">
|
||||||
|
<tr>
|
||||||
|
<th>Weapon</th>
|
||||||
|
<th class="num" title="damage per shot into the chest at the chosen distance/armor">Body dmg</th>
|
||||||
|
<th class="num">Head</th><th class="num">Chest</th><th class="num">Stomach</th><th class="num">Legs</th>
|
||||||
|
<th class="num" title="(shots − 1) × cycle time, chest — the first shot lands at t = 0">TTK</th>
|
||||||
|
</tr>
|
||||||
|
@foreach (var (label, rows) in Model.WeaponGroups())
|
||||||
|
{
|
||||||
|
<tr class="wcat"><th colspan="7">@label</th></tr>
|
||||||
|
foreach (var w in rows)
|
||||||
|
{
|
||||||
|
var shot = WeaponMath.Hitgroups.Select(hg => WeaponMath.Shot(w, 0, hg, true)).ToArray();
|
||||||
|
<tr data-wid="@w.Id">
|
||||||
|
<td>@Html.Raw(Model.IconFor(w))@w.Name @(w.Pellets > 1 ? Html.Raw($"<span class=\"muted small\">×{w.Pellets}</span>") : Html.Raw(""))</td>
|
||||||
|
<td class="num" data-c="dmg">@shot[1]</td>
|
||||||
|
<td class="num" data-c="head">@TheoryModel.StkStr(shot[0])</td>
|
||||||
|
<td class="num" data-c="chest">@TheoryModel.StkStr(shot[1])</td>
|
||||||
|
<td class="num" data-c="stomach">@TheoryModel.StkStr(shot[2])</td>
|
||||||
|
<td class="num" data-c="legs">@TheoryModel.StkStr(shot[3])</td>
|
||||||
|
<td class="num" data-c="ttk">@TheoryModel.TtkStr(w, shot[1])</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted small">
|
||||||
|
From CS2 build @Model.Weap.GameVersion (extracted @(Model.Weap.GeneratedAt is { Length: >= 10 } g ? g[..10] : Model.Weap.GeneratedAt)).
|
||||||
|
“Kevlar + helmet” armors every hitgroup except legs — legs never take armor reduction.
|
||||||
|
Shotgun rows (×pellets) assume every pellet lands in the same hitgroup.
|
||||||
|
Damage is truncated per pellet, exactly like the engine does it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script type="application/json" id="weapons-data">@Html.Raw(Model.WeaponsJson)</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="/js/theory.js" defer></script>
|
<script src="/js/theory.js" defer></script>
|
||||||
|
<script src="/js/weapons.js" defer></script>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using CsWeb.Services;
|
using CsWeb.Services;
|
||||||
|
|
@ -6,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
namespace CsWeb.Pages;
|
namespace CsWeb.Pages;
|
||||||
|
|
||||||
public class TheoryModel(Fleet fleet) : PageModel
|
public class TheoryModel(Fleet fleet, Weapons weapons) : PageModel
|
||||||
{
|
{
|
||||||
// Panel geometry in viewBox units (SVG scales to container width).
|
// Panel geometry in viewBox units (SVG scales to container width).
|
||||||
public const int W = 720, PlotH = 134, L = 46, R = 10, T = 10;
|
public const int W = 720, PlotH = 134, L = 46, R = 10, T = 10;
|
||||||
|
|
@ -48,6 +49,7 @@ public class TheoryModel(Fleet fleet) : PageModel
|
||||||
public async Task OnGetAsync(string? mode)
|
public async Task OnGetAsync(string? mode)
|
||||||
{
|
{
|
||||||
ModeParam = mode;
|
ModeParam = mode;
|
||||||
|
BuildWeapons(); // file-backed, independent of the sockets — the STK table renders even with the fleet down
|
||||||
(_, Balance) = await fleet.BalanceForMode(mode ?? "");
|
(_, Balance) = await fleet.BalanceForMode(mode ?? "");
|
||||||
var c = B?.Curves;
|
var c = B?.Curves;
|
||||||
if (c is null || c.T.Length < 2) return;
|
if (c is null || c.T.Length < 2) return;
|
||||||
|
|
@ -167,4 +169,58 @@ public class TheoryModel(Fleet fleet) : PageModel
|
||||||
|
|
||||||
// Spinner step by magnitude, so Curve 0.8 steps by 0.05 instead of the browser default 1 (typing stays free-form).
|
// 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";
|
public static string StepFor(double v) => Math.Abs(v) < 1 ? "0.05" : Math.Abs(v) < 10 ? "0.1" : "1";
|
||||||
|
|
||||||
|
// ---- weapons / STK table (numbers from extract-weapons.sh via the Weapons store; math in WeaponMath) ----
|
||||||
|
|
||||||
|
public WeaponsDoc? Weap { get; private set; }
|
||||||
|
public string WeaponsJson { get; private set; } = "null";
|
||||||
|
|
||||||
|
private void BuildWeapons()
|
||||||
|
{
|
||||||
|
Weap = weapons.Doc;
|
||||||
|
if (Weap is null) return;
|
||||||
|
WeaponsJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
Weap.GameVersion,
|
||||||
|
Weap.Weapons,
|
||||||
|
// Parity target: the default table state (distance 0, armored, vanilla) that weapons.js recomputes at
|
||||||
|
// load. Distance 0 keeps pow() out of the picture (x^0 = 1 exactly), so with the same operation order
|
||||||
|
// C# and JS produce bit-identical doubles and the comparison is exact-integer, no tolerance needed.
|
||||||
|
Ssr = Weap.Weapons.Select(w => new
|
||||||
|
{
|
||||||
|
w.Id,
|
||||||
|
Shot = WeaponMath.Hitgroups.Select(hg => WeaponMath.Shot(w, 0, hg, true)).ToArray(),
|
||||||
|
}).ToList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly (string Key, string Label)[] CatOrder =
|
||||||
|
[("rifles", "Rifles"), ("pistols", "Pistols"), ("smgs", "SMGs"), ("snipers", "Snipers"), ("shotguns", "Shotguns"), ("heavy", "Heavy")];
|
||||||
|
|
||||||
|
public IEnumerable<(string Label, List<WeaponRow> Rows)> WeaponGroups()
|
||||||
|
{
|
||||||
|
if (Weap is null) yield break;
|
||||||
|
foreach (var (key, label) in CatOrder)
|
||||||
|
{
|
||||||
|
var rows = Weap.Weapons.Where(w => w.Cat == key).OrderBy(w => w.Name, StringComparer.Ordinal).ToList();
|
||||||
|
if (rows.Count > 0) yield return (label, rows);
|
||||||
|
}
|
||||||
|
var known = CatOrder.Select(c => c.Key).ToHashSet();
|
||||||
|
var other = Weap.Weapons.Where(w => !known.Contains(w.Cat)).OrderBy(w => w.Name, StringComparer.Ordinal).ToList();
|
||||||
|
if (other.Count > 0) yield return ("Other", other); // a future extractor category still gets rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline the extractor's cleaned SVG with img semantics — role + aria-label carry the weapon name (the "alt").
|
||||||
|
public string IconFor(WeaponRow w)
|
||||||
|
{
|
||||||
|
var svg = weapons.IconSvg(w.Icon);
|
||||||
|
if (svg is null) return "";
|
||||||
|
int i = svg.IndexOf("<svg", StringComparison.Ordinal);
|
||||||
|
return i < 0 ? "" : svg.Insert(i + 4, $" class=\"wicon\" role=\"img\" aria-label=\"{WebUtility.HtmlEncode(w.Name)}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string StkStr(int shot) => WeaponMath.Stk(shot) is var s && s > 0 ? s.ToString(CultureInfo.InvariantCulture) : "—";
|
||||||
|
|
||||||
|
public static string TtkStr(WeaponRow w, int shot) =>
|
||||||
|
WeaponMath.Stk(shot) is var s && s > 0 ? WeaponMath.Ttk(w, s).ToString("0.00", CultureInfo.InvariantCulture) + " s" : "—";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using CsWeb.Services;
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages();
|
||||||
builder.Services.AddSingleton<Fleet>();
|
builder.Services.AddSingleton<Fleet>();
|
||||||
|
builder.Services.AddSingleton<Weapons>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ ASP.NET Core Razor Pages, zero external packages, no JS framework, no build pipe
|
||||||
and owns no data: each game server's plugin exposes a local Unix-domain-socket API (`status` / `balance` /
|
and owns no data: each game server's plugin exposes a local Unix-domain-socket API (`status` / `balance` /
|
||||||
`top`), and the site renders whatever those report — live status, DB-backed leaderboards, and the effective
|
`top`), and the site renders whatever those report — live status, DB-backed leaderboards, and the effective
|
||||||
balance config (the theorycrafting curves are computed by the same code that scales damage in game, so the
|
balance config (the theorycrafting curves are computed by the same code that scales damage in game, so the
|
||||||
published numbers can't drift from reality). No cookies, no trackers, no analytics.
|
published numbers can't drift from reality). The weapons/STK table follows the same philosophy from the other
|
||||||
|
side: its numbers and icons are extracted from the installed game's own files on every Valve update
|
||||||
|
(`Outnumbered:WeaponsDir`, produced by the operator's extractor script), never hand-copied.
|
||||||
|
No cookies, no trackers, no analytics.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
|
|
|
||||||
113
Services/Weapons.cs
Normal file
113
Services/Weapons.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace CsWeb.Services;
|
||||||
|
|
||||||
|
// ---- weapons.json DTOs (produced by deploy-web/extract-weapons.sh from the installed game's files) ----
|
||||||
|
|
||||||
|
public sealed class WeaponRow
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Cat { get; init; } = "";
|
||||||
|
public int Dmg { get; init; }
|
||||||
|
public double ArmorRatio { get; init; }
|
||||||
|
public double RangeMod { get; init; }
|
||||||
|
public double HsMult { get; init; }
|
||||||
|
public double Range { get; init; }
|
||||||
|
public double Cycle { get; init; }
|
||||||
|
public int Clip { get; init; }
|
||||||
|
public int Pellets { get; init; }
|
||||||
|
public string Icon { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WeaponsDoc
|
||||||
|
{
|
||||||
|
public int V { get; init; }
|
||||||
|
public string GeneratedAt { get; init; } = "";
|
||||||
|
public string GameVersion { get; init; } = "";
|
||||||
|
public List<WeaponRow> Weapons { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// File-backed twin of Fleet's socket caches: weapons.json + icons re-read at TTL pace with last-good retention,
|
||||||
|
// so an extractor run mid-request (or a briefly missing file during its swap) never blanks the table.
|
||||||
|
public sealed class Weapons(IConfiguration cfg, IWebHostEnvironment env, ILogger<Weapons> log)
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(60);
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
// Relative dir = dev convenience (resolved against the project root); prod config uses the absolute path.
|
||||||
|
private readonly string _dir = Path.IsPathRooted(cfg["Outnumbered:WeaponsDir"] ?? "")
|
||||||
|
? cfg["Outnumbered:WeaponsDir"]!
|
||||||
|
: Path.Combine(env.ContentRootPath, cfg["Outnumbered:WeaponsDir"] ?? "weapons-data");
|
||||||
|
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private WeaponsDoc? _doc;
|
||||||
|
private Dictionary<string, string> _icons = [];
|
||||||
|
private DateTimeOffset _lastAttempt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public WeaponsDoc? Doc
|
||||||
|
{
|
||||||
|
get { Refresh(); lock (_lock) return _doc; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? IconSvg(string icon)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
lock (_lock) return _icons.GetValueOrDefault(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Refresh()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (DateTimeOffset.UtcNow - _lastAttempt < Ttl) return;
|
||||||
|
_lastAttempt = DateTimeOffset.UtcNow; // stamped before the read: a missing dir is retried at TTL pace
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = JsonSerializer.Deserialize<WeaponsDoc>(File.ReadAllText(Path.Combine(_dir, "weapons.json")), JsonOpts);
|
||||||
|
if (doc is null || doc.Weapons.Count == 0) return;
|
||||||
|
var icons = new Dictionary<string, string>();
|
||||||
|
foreach (var icon in doc.Weapons.Select(w => w.Icon))
|
||||||
|
{
|
||||||
|
var p = Path.Combine(_dir, "icons", icon + ".svg");
|
||||||
|
if (File.Exists(p)) icons[icon] = File.ReadAllText(p);
|
||||||
|
}
|
||||||
|
lock (_lock) { _doc = doc; _icons = icons; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
log.LogWarning(ex, "weapons data read failed from {Dir} (keeping last-good)", _dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vanilla CS2 bullet math. Per-weapon numbers come from weapons.json; the four constants below live in compiled
|
||||||
|
// engine code (no data file carries them) and are validated against anchor damage numbers (AK-47 helmet HS = 111,
|
||||||
|
// USP-S = 70, Glock = 56, AWP armored legs = 86) — wwwroot/js/weapons.js mirrors this class function-for-function,
|
||||||
|
// with THE SAME operation order so the load-time parity check compares bit-identical doubles.
|
||||||
|
public static class WeaponMath
|
||||||
|
{
|
||||||
|
// Hitgroup order used everywhere (SSR cells, parity blob, JS): head, chest, stomach, legs.
|
||||||
|
public static readonly string[] Hitgroups = ["head", "chest", "stomach", "legs"];
|
||||||
|
|
||||||
|
// Raw (unfloored) per-pellet damage. Armor gates: legs never take armor reduction; "armored" means
|
||||||
|
// kevlar + helmet together (the head is armored only via helmet — one toggle covers the full buy).
|
||||||
|
public static double Pellet(WeaponRow w, double dist, string hg, bool armored)
|
||||||
|
{
|
||||||
|
if (dist > w.Range) return 0; // beyond the weapon's own max bullet range nothing connects at all
|
||||||
|
double d = w.Dmg * Math.Pow(w.RangeMod, dist / 500.0);
|
||||||
|
d *= hg switch { "head" => w.HsMult, "stomach" => 1.25, "legs" => 0.75, _ => 1.0 };
|
||||||
|
if (armored && hg != "legs") d *= w.ArmorRatio * 0.5;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Damage is floored per pellet at application (each pellet is its own trace); a "shot" assumes every pellet
|
||||||
|
// lands in the same hitgroup at the same distance — the standard convention for shotgun table numbers.
|
||||||
|
public static int Shot(WeaponRow w, double dist, string hg, bool armored) =>
|
||||||
|
(int)Math.Floor(Pellet(w, dist, hg, armored)) * w.Pellets;
|
||||||
|
|
||||||
|
public static int Stk(int shot) => shot <= 0 ? 0 : (int)Math.Ceiling(100.0 / shot); // 0 = can't kill (out of range)
|
||||||
|
|
||||||
|
public static double Ttk(WeaponRow w, int stk) => stk <= 0 ? 0 : (stk - 1) * w.Cycle; // first shot at t=0
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"Outnumbered": {
|
"Outnumbered": {
|
||||||
"SocketDir": "/tmp/og-sock"
|
"SocketDir": "/tmp/og-sock",
|
||||||
|
"WeaponsDir": "../weapons-data"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"Outnumbered": {
|
"Outnumbered": {
|
||||||
"SocketDir": "/run/outnumbered",
|
"SocketDir": "/run/outnumbered",
|
||||||
"PublicHost": "cs2-on.eu",
|
"PublicHost": "cs2-on.eu",
|
||||||
"ReleasesUrl": "https://git.lo.sh/kamal/cs2-outnumbered/releases"
|
"ReleasesUrl": "https://git.lo.sh/kamal/cs2-outnumbered/releases",
|
||||||
|
"WeaponsDir": "/home/cs2/www/shared/weapons"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,13 @@ figcaption { text-align: center; padding-top: 6px; }
|
||||||
.sim td input[type="number"] { width: 64px; }
|
.sim td input[type="number"] { width: 64px; }
|
||||||
.sim .modified { outline: 1px solid var(--accent2); }
|
.sim .modified { outline: 1px solid var(--accent2); }
|
||||||
|
|
||||||
|
/* ---- weapons STK table: icons letterbox in a fixed slot (viewBox aspect varies per gun) ---- */
|
||||||
|
.wicon { height: 16px; width: 44px; vertical-align: -3px; margin-right: 8px; color: var(--muted); }
|
||||||
|
tr:hover .wicon { color: var(--text); }
|
||||||
|
#wpn-table tr.wcat th { padding-top: 16px; text-transform: uppercase; font-size: 12px; letter-spacing: 0.04em; }
|
||||||
|
#wpn-distv { font-variant-numeric: tabular-nums; display: inline-block; min-width: 56px; }
|
||||||
|
#wpn-dist { width: 220px; accent-color: var(--accent); vertical-align: middle; }
|
||||||
|
|
||||||
/* handicap knobs: semantic groups as a grid of boxes; inside each, label|input rows */
|
/* handicap knobs: semantic groups as a grid of boxes; inside each, label|input rows */
|
||||||
.kgroups { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
|
.kgroups { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
|
||||||
.kgroup { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px 10px; }
|
.kgroup { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px 10px; }
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,45 @@
|
||||||
|
|
||||||
function fmt(v) { return "×" + v.toFixed(2); }
|
function fmt(v) { return "×" + v.toFixed(2); }
|
||||||
|
|
||||||
|
// The server's own 201-point arrays — displayed VERBATIM whenever the knobs are unedited (load + reset), so in
|
||||||
|
// the default state the page shows server truth, never our reimplementation of it. JS curves take over on edit.
|
||||||
|
function restoreServerCurves() {
|
||||||
|
curves.deal = seed.Curves.Deal.slice();
|
||||||
|
curves.take = seed.Curves.Take.slice();
|
||||||
|
curves.xp = seed.Curves.Xp.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift oracle: with server-seeded knobs, resample() must reproduce the seed's server-computed arrays. The seed
|
||||||
|
// ships everything needed to check that, so drift SELF-REPORTS instead of relying on a careful reader comparing
|
||||||
|
// the crosshair to the key-points table. Tolerance is relative 1e-9 (libm pow may differ by ULPs across
|
||||||
|
// runtimes; a real formula change is orders of magnitude larger). Leaves `curves` = server arrays either way.
|
||||||
|
function verifyParity() {
|
||||||
|
resample(); // JS-computed curves from the untouched server knobs
|
||||||
|
const server = { deal: seed.Curves.Deal, take: seed.Curves.Take, xp: seed.Curves.Xp };
|
||||||
|
let ok = true;
|
||||||
|
outer: for (const k in server) {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const a = curves[k][i], b = server[k][i];
|
||||||
|
if (Math.abs(a - b) > 1e-9 * Math.max(1, Math.abs(b))) {
|
||||||
|
console.warn("theory.js drift vs server:", k, "at t=" + seed.Curves.T[i].toFixed(2), "js=" + a, "server=" + b);
|
||||||
|
const banner = $("sim-drift");
|
||||||
|
if (banner) banner.hidden = false;
|
||||||
|
ok = false;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restoreServerCurves();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
function recompute() {
|
function recompute() {
|
||||||
resample();
|
resample();
|
||||||
redrawPanels();
|
redrawPanels();
|
||||||
|
updateReadouts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReadouts() {
|
||||||
const rawT = computeRawT(h, player);
|
const rawT = computeRawT(h, player);
|
||||||
const tm = teamMults();
|
const tm = teamMults();
|
||||||
let b = bands(ease(rawT, h.Curve), h, tm.deal, tm.take);
|
let b = bands(ease(rawT, h.Curve), h, tm.deal, tm.take);
|
||||||
|
|
@ -149,9 +184,14 @@
|
||||||
$("sim-take").textContent = fmt(b.take);
|
$("sim-take").textContent = fmt(b.take);
|
||||||
$("sim-xpband").textContent = fmt(b.xp);
|
$("sim-xpband").textContent = fmt(b.xp);
|
||||||
// Hud.EffectiveMultipliers parity (headshot:false, crit:false, no actives):
|
// Hud.EffectiveMultipliers parity (headshot:false, crit:false, no actives):
|
||||||
$("sim-out").textContent = fmt((1 + effRun("damage") / 100) * b.deal);
|
const out = (1 + effRun("damage") / 100) * b.deal;
|
||||||
|
const hsx = 1 + effRun("hs_damage") / 100;
|
||||||
|
$("sim-out").textContent = fmt(out);
|
||||||
$("sim-in").textContent = fmt(b.take);
|
$("sim-in").textContent = fmt(b.take);
|
||||||
$("sim-hs").textContent = fmt(1 + effRun("hs_damage") / 100);
|
$("sim-hs").textContent = fmt(hsx);
|
||||||
|
// Published for weapons.js ("apply my build & handicap"): the same base-readout multipliers.
|
||||||
|
window.OGSim = { out: out, hs: hsx };
|
||||||
|
document.dispatchEvent(new CustomEvent("og:sim"));
|
||||||
// ProgressionModel.PrestigeXpMultiplier: 1 + prestige * boost%/100.
|
// ProgressionModel.PrestigeXpMultiplier: 1 + prestige * boost%/100.
|
||||||
$("sim-xp").textContent = fmt((1 + effRun("xp_boost") / 100) * (1 + player.prestige * (player.prestigeBoost / 100)) * b.xp);
|
$("sim-xp").textContent = fmt((1 + effRun("xp_boost") / 100) * (1 + player.prestige * (player.prestigeBoost / 100)) * b.xp);
|
||||||
|
|
||||||
|
|
@ -221,7 +261,9 @@
|
||||||
// sim actually used (silent clamping in a theorycrafting tool is a lie). Partial input ("", "-") is left alone.
|
// sim actually used (silent clamping in a theorycrafting tool is a lie). Partial input ("", "-") is left alone.
|
||||||
function clampedInt(el) {
|
function clampedInt(el) {
|
||||||
const raw = Number.parseInt(el.value, 10);
|
const raw = Number.parseInt(el.value, 10);
|
||||||
const v = clamp(Number.isNaN(raw) ? 0 : raw, 0, Number.parseInt(el.max, 10) || 0);
|
const max = Number.parseInt(el.max, 10);
|
||||||
|
const cap = Number.isNaN(max) ? Infinity : max; // a missing max attribute means "uncapped", never "zero"
|
||||||
|
const v = clamp(Number.isNaN(raw) ? 0 : raw, 0, cap);
|
||||||
if (!Number.isNaN(raw) && raw !== v) el.value = v;
|
if (!Number.isNaN(raw) && raw !== v) el.value = v;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
@ -265,8 +307,17 @@
|
||||||
recompute();
|
recompute();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$("sim-reset").addEventListener("click", function () { seedInputs(); recompute(); });
|
// Reset shows the server's arrays again (not a resample of them) — the unedited state is always server truth.
|
||||||
|
$("sim-reset").addEventListener("click", function () {
|
||||||
|
seedInputs();
|
||||||
|
restoreServerCurves();
|
||||||
|
redrawPanels();
|
||||||
|
updateReadouts();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load: the SSR polylines already ARE the server curves, so don't redraw — just verify parity (drift
|
||||||
|
// self-reports via the banner), leave server arrays in place for the crosshair, and position the readouts.
|
||||||
seedInputs();
|
seedInputs();
|
||||||
recompute();
|
verifyParity();
|
||||||
|
updateReadouts();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
99
wwwroot/js/weapons.js
Normal file
99
wwwroot/js/weapons.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Weapons/STK table: mirrors Services/Weapons.cs WeaponMath function-for-function with the SAME operation order,
|
||||||
|
// so the load-time parity check (SSR default state: distance 0, armored, vanilla — pow(x,0)=1 keeps libm out of
|
||||||
|
// the picture) compares exact integers. The four global constants (stomach ×1.25, legs ×0.75 + no armor on legs,
|
||||||
|
// armor factor ×0.5, falloff divisor 500) live in compiled engine code, not in the extracted data — they're
|
||||||
|
// validated against anchor numbers (AK-47 helmet HS = 111, USP-S = 70, Glock = 56, AWP armored legs = 86).
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
const dataEl = document.getElementById("weapons-data");
|
||||||
|
if (!dataEl) return;
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(dataEl.textContent); } catch { return; }
|
||||||
|
if (!data?.Weapons?.length) return;
|
||||||
|
|
||||||
|
const HG = ["head", "chest", "stomach", "legs"]; // WeaponMath.Hitgroups order (SSR cells + Ssr parity blob)
|
||||||
|
const $ = function (id) { return document.getElementById(id); };
|
||||||
|
const dist = $("wpn-dist"), distv = $("wpn-distv"), armor = $("wpn-armor"), mod = $("wpn-mod");
|
||||||
|
|
||||||
|
// WeaponMath.Pellet: raw per-pellet damage, unfloored. The mod layer composes HERE, pre-floor: the plugin
|
||||||
|
// (Stats.cs) multiplies float info.Damage before the engine applies armor and truncates, and multiplication
|
||||||
|
// commutes — head applies the hs_damage chain ON TOP of the engine's per-weapon hsMult. out/hsx = 1 is exact.
|
||||||
|
function pellet(w, d, hg, armored, out, hsx) {
|
||||||
|
if (d > w.Range) return 0; // beyond the weapon's own max bullet range nothing connects
|
||||||
|
let v = w.Dmg * Math.pow(w.RangeMod, d / 500.0);
|
||||||
|
v *= hg === "head" ? w.HsMult : hg === "stomach" ? 1.25 : hg === "legs" ? 0.75 : 1.0;
|
||||||
|
if (armored && hg !== "legs") v *= w.ArmorRatio * 0.5;
|
||||||
|
v *= out;
|
||||||
|
if (hg === "head") v *= hsx;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// WeaponMath.Shot: floored per pellet (each pellet is its own trace), all pellets in the same hitgroup.
|
||||||
|
function shot(w, d, hg, armored, out, hsx) { return Math.floor(pellet(w, d, hg, armored, out, hsx)) * w.Pellets; }
|
||||||
|
// WeaponMath.Stk: 0 = can't kill (out of range).
|
||||||
|
function stk(s) { return s <= 0 ? 0 : Math.ceil(100 / s); }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const d = Number.parseFloat(dist.value) || 0;
|
||||||
|
const armored = armor.checked;
|
||||||
|
const sim = mod.checked && window.OGSim ? window.OGSim : { out: 1, hs: 1 };
|
||||||
|
distv.textContent = d + " u";
|
||||||
|
data.Weapons.forEach(function (w) {
|
||||||
|
const row = document.querySelector('#wpn-table tr[data-wid="' + w.Id + '"]');
|
||||||
|
if (!row) return;
|
||||||
|
const s = HG.map(function (hg) { return shot(w, d, hg, armored, sim.out, sim.hs); });
|
||||||
|
row.querySelector('[data-c="dmg"]').textContent = s[1];
|
||||||
|
HG.forEach(function (hg, i) {
|
||||||
|
const k = stk(s[i]);
|
||||||
|
row.querySelector('[data-c="' + hg + '"]').textContent = k > 0 ? k : "—";
|
||||||
|
});
|
||||||
|
const kc = stk(s[1]);
|
||||||
|
row.querySelector('[data-c="ttk"]').textContent = kc > 0 ? ((kc - 1) * w.Cycle).toFixed(2) + " s" : "—";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift oracle (same pattern as theory.js): recompute the SSR default state and compare against the server's
|
||||||
|
// own numbers shipped in the blob. Any mismatch means this file drifted from WeaponMath and self-reports.
|
||||||
|
function verifyParity() {
|
||||||
|
for (const row of data.Ssr || []) {
|
||||||
|
const w = data.Weapons.find(function (x) { return x.Id === row.Id; });
|
||||||
|
if (!w) continue;
|
||||||
|
for (let i = 0; i < HG.length; i++) {
|
||||||
|
const js = shot(w, 0, HG[i], true, 1, 1);
|
||||||
|
if (js !== row.Shot[i]) {
|
||||||
|
console.warn("weapons.js drift vs server:", row.Id, HG[i], "js=" + js, "server=" + row.Shot[i]);
|
||||||
|
const banner = $("wpn-drift");
|
||||||
|
if (banner) banner.hidden = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit defaults (browsers restore form state on back-nav; the SSR cells are only true for THIS state).
|
||||||
|
dist.value = 0;
|
||||||
|
armor.checked = true;
|
||||||
|
armor.disabled = false;
|
||||||
|
mod.checked = false;
|
||||||
|
// Gate on the sim actually having initialized, not on the blob element existing: #sim-data can render as
|
||||||
|
// literal null (fleet reachable, balance payload without usable curves) and theory.js bails on several other
|
||||||
|
// early returns too. theory.js runs first (defer order) and publishes OGSim at load when — and only when —
|
||||||
|
// the simulator is live, so its absence HERE is definitive.
|
||||||
|
if (!window.OGSim) {
|
||||||
|
mod.disabled = true;
|
||||||
|
mod.parentElement.title = "needs a live server config (the simulator above)";
|
||||||
|
}
|
||||||
|
|
||||||
|
dist.addEventListener("input", render);
|
||||||
|
armor.addEventListener("change", render);
|
||||||
|
mod.addEventListener("change", function () {
|
||||||
|
// Bots always wear the assault suit — with the build applied, "unarmored" models nothing real.
|
||||||
|
armor.disabled = mod.checked;
|
||||||
|
if (mod.checked) armor.checked = true;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.addEventListener("og:sim", function () { if (mod.checked) render(); });
|
||||||
|
|
||||||
|
verifyParity();
|
||||||
|
// No initial render: the SSR cells already ARE the default state (and remain server truth without JS).
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue