diff --git a/Pages/Theory.cshtml b/Pages/Theory.cshtml
index d16ef26..4b5e707 100644
--- a/Pages/Theory.cshtml
+++ b/Pages/Theory.cshtml
@@ -83,6 +83,12 @@ else
}
+
+ 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 edited-knob results may be inaccurate
+ until the site is updated. Details in the browser console.
+
+
Simulator
Seeded from the live server config. Edit anything — player state, stat levels, card picks, handicap knobs —
@@ -205,13 +211,83 @@ else
-
- Weapon-by-weapon time-to-kill tables and per-weapon damage simulation are planned on top of this.
+
+}
+
+
Weapons — shots to kill
+@if (Model.Weap is null)
+{
+ Weapon data hasn't been generated on this host yet (deploy-web/extract-weapons.sh).
+}
+else
+{
+
+ Vanilla CS2 bullet math, computed from numbers extracted out of the installed game's own files
+ (re-extracted on every Valve update — never hand-copied). Shots to kill a 100 HP 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 apply my build & handicap to layer your
+ simulated player from above onto the same math.
-
+
+ This table's local math no longer matches the server-computed values (site version drift). The numbers shown
+ are still server truth, but the controls below may compute inaccurate results until the site
+ is updated. Details in the browser console.
+
+
+
+
+
+
+
+ Weapon
+ Body dmg
+ Head Chest Stomach Legs
+ TTK
+
+ @foreach (var (label, rows) in Model.WeaponGroups())
+ {
+ @label
+ foreach (var w in rows)
+ {
+ var shot = WeaponMath.Hitgroups.Select(hg => WeaponMath.Shot(w, 0, hg, true)).ToArray();
+
+ @Html.Raw(Model.IconFor(w))@w.Name @(w.Pellets > 1 ? Html.Raw($"×{w.Pellets} ") : Html.Raw(""))
+ @shot[1]
+ @TheoryModel.StkStr(shot[0])
+ @TheoryModel.StkStr(shot[1])
+ @TheoryModel.StkStr(shot[2])
+ @TheoryModel.StkStr(shot[3])
+ @TheoryModel.TtkStr(w, shot[1])
+
+ }
+ }
+
+
+
+
+ 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.
+
+
+
}
@section Scripts {
+
}
diff --git a/Pages/Theory.cshtml.cs b/Pages/Theory.cshtml.cs
index f84b44d..603b902 100644
--- a/Pages/Theory.cshtml.cs
+++ b/Pages/Theory.cshtml.cs
@@ -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 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(" 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" : "—";
}
diff --git a/Program.cs b/Program.cs
index 2c43805..90ed08d 100644
--- a/Program.cs
+++ b/Program.cs
@@ -3,6 +3,7 @@ using CsWeb.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
var app = builder.Build();
app.UseStaticFiles();
diff --git a/README.md b/README.md
index 9bf42f7..412a02a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Services/Weapons.cs b/Services/Weapons.cs
new file mode 100644
index 0000000..ba88dee
--- /dev/null
+++ b/Services/Weapons.cs
@@ -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 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 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 _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(File.ReadAllText(Path.Combine(_dir, "weapons.json")), JsonOpts);
+ if (doc is null || doc.Weapons.Count == 0) return;
+ var icons = new Dictionary();
+ 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
+}
diff --git a/appsettings.Development.json b/appsettings.Development.json
index 4413a08..305b49d 100644
--- a/appsettings.Development.json
+++ b/appsettings.Development.json
@@ -1,5 +1,6 @@
{
"Outnumbered": {
- "SocketDir": "/tmp/og-sock"
+ "SocketDir": "/tmp/og-sock",
+ "WeaponsDir": "../weapons-data"
}
}
diff --git a/appsettings.json b/appsettings.json
index 527c803..3e46ae1 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -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"
}
}
diff --git a/wwwroot/css/site.css b/wwwroot/css/site.css
index e57491a..fe0873a 100644
--- a/wwwroot/css/site.css
+++ b/wwwroot/css/site.css
@@ -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; }
diff --git a/wwwroot/js/theory.js b/wwwroot/js/theory.js
index 21e94cf..c577daf 100644
--- a/wwwroot/js/theory.js
+++ b/wwwroot/js/theory.js
@@ -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();
+ 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();
- recompute();
+ verifyParity();
+ updateReadouts();
})();
diff --git a/wwwroot/js/weapons.js b/wwwroot/js/weapons.js
new file mode 100644
index 0000000..dfad28f
--- /dev/null
+++ b/wwwroot/js/weapons.js
@@ -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).
+})();