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

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

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