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>
|
||||
}
|
||||
|
||||
<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>
|
||||
<p class="small muted">
|
||||
Seeded from the live server config. Edit anything — player state, stat levels, card picks, handicap knobs —
|
||||
|
|
@ -205,13 +211,83 @@ else
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<p class="muted small">
|
||||
Weapon-by-weapon time-to-kill tables and per-weapon damage simulation are planned on top of this.
|
||||
<script type="application/json" id="sim-data">@Html.Raw(Model.SimJson)</script>
|
||||
}
|
||||
|
||||
<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>
|
||||
|
||||
<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 {
|
||||
<script src="/js/theory.js" defer></script>
|
||||
<script src="/js/weapons.js" defer></script>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CsWeb.Services;
|
||||
|
|
@ -6,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
|||
|
||||
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).
|
||||
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)
|
||||
{
|
||||
ModeParam = mode;
|
||||
BuildWeapons(); // file-backed, independent of the sockets — the STK table renders even with the fleet down
|
||||
(_, Balance) = await fleet.BalanceForMode(mode ?? "");
|
||||
var c = B?.Curves;
|
||||
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).
|
||||
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);
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddSingleton<Fleet>();
|
||||
builder.Services.AddSingleton<Weapons>();
|
||||
|
||||
var app = builder.Build();
|
||||
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` /
|
||||
`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
|
||||
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
|
||||
|
||||
|
|
|
|||
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": {
|
||||
"SocketDir": "/tmp/og-sock"
|
||||
"SocketDir": "/tmp/og-sock",
|
||||
"WeaponsDir": "../weapons-data"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"Outnumbered": {
|
||||
"SocketDir": "/run/outnumbered",
|
||||
"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 .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 */
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -135,10 +135,45 @@
|
|||
|
||||
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() {
|
||||
resample();
|
||||
redrawPanels();
|
||||
updateReadouts();
|
||||
}
|
||||
|
||||
function updateReadouts() {
|
||||
const rawT = computeRawT(h, player);
|
||||
const tm = teamMults();
|
||||
let b = bands(ease(rawT, h.Curve), h, tm.deal, tm.take);
|
||||
|
|
@ -149,9 +184,14 @@
|
|||
$("sim-take").textContent = fmt(b.take);
|
||||
$("sim-xpband").textContent = fmt(b.xp);
|
||||
// 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-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.
|
||||
$("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.
|
||||
function clampedInt(el) {
|
||||
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;
|
||||
return v;
|
||||
}
|
||||
|
|
@ -265,8 +307,17 @@
|
|||
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();
|
||||
recompute();
|
||||
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();
|
||||
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