minor optimizations and weapon sim

This commit is contained in:
Kamal Tufekcic 2026-07-07 20:23:39 +03:00
commit fc7887ce11
10 changed files with 419 additions and 11 deletions

View file

@ -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 &amp; 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 &amp; 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 &amp; handicap <input type="checkbox" id="wpn-mod" autocomplete="off" />
</label>
</div>
<p class="muted small">500 units ≈ one falloff step · a &mdash; cell means the weapon's bullets don't reach that distance at all (shotguns cut off earliest) · AWP mid ≈ 1500&ndash;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\">&times;{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)).
&ldquo;Kevlar + helmet&rdquo; armors every hitgroup except legs — legs never take armor reduction.
Shotgun rows (&times;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>
}

View file

@ -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" : "—";
}

View file

@ -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();

View file

@ -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
View 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
}

View file

@ -1,5 +1,6 @@
{
"Outnumbered": {
"SocketDir": "/tmp/og-sock"
"SocketDir": "/tmp/og-sock",
"WeaponsDir": "../weapons-data"
}
}

View file

@ -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"
}
}

View file

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

View file

@ -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
View 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).
})();