From fc7887ce110610f1a166dc32cc5ab7e85f40cf65 Mon Sep 17 00:00:00 2001 From: Kamal Tufekcic Date: Tue, 7 Jul 2026 20:23:39 +0300 Subject: [PATCH] minor optimizations and weapon sim --- Pages/Theory.cshtml | 82 ++++++++++++++++++++++++- Pages/Theory.cshtml.cs | 58 +++++++++++++++++- Program.cs | 1 + README.md | 5 +- Services/Weapons.cs | 113 +++++++++++++++++++++++++++++++++++ appsettings.Development.json | 3 +- appsettings.json | 3 +- wwwroot/css/site.css | 7 +++ wwwroot/js/theory.js | 61 +++++++++++++++++-- wwwroot/js/weapons.js | 99 ++++++++++++++++++++++++++++++ 10 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 Services/Weapons.cs create mode 100644 wwwroot/js/weapons.js 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 } + +

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.

- + + +
+
+ Target & range +
+ + + +
+

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.

+
+
+ +
+ + + + + + + + @foreach (var (label, rows) in Model.WeaponGroups()) + { + + foreach (var w in rows) + { + var shot = WeaponMath.Hitgroups.Select(hg => WeaponMath.Shot(w, 0, hg, true)).ToArray(); + + + + + + + + + + } + } +
WeaponBody dmgHeadChestStomachLegsTTK
@label
@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). +})();