cs2-web/wwwroot/js/theory.js
2026-07-07 20:23:39 +03:00

323 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Theory page: live curves + the simulator. Seeded from the server's effective config (#sim-data); every formula
// below MIRRORS the plugin's Domain layer (file noted per function). The VALUES always come from the server payload —
// only the formula shapes are duplicated here, and those change rarely. Abilities/crits/headshots are deliberately
// excluded (parity with the in-game HUD's base readout).
(function () {
"use strict";
const seedEl = document.getElementById("sim-data");
if (!seedEl) return;
let seed;
try { seed = JSON.parse(seedEl.textContent); } catch { return; }
if (!seed?.Curves?.T || seed.Curves.T.length < 2) return;
// Geometry mirrors TheoryModel constants (viewBox units).
const L = 46, R = 10, W = 720, T = 10, PLOTH = 134, PLOT = W - L - R;
const N = seed.Curves.T.length;
const clamp = function (v, a, b) { return Math.max(a, Math.min(b, v)); };
const clamp01 = function (v) { return clamp(v, 0, 1); };
const lerp = function (a, b, t) { return a + (b - a) * t; };
// HandicapModel.Ease: sign-preserving curve easing.
function ease(t, curve) { return t >= 0 ? Math.pow(t, curve) : -Math.pow(-t, curve); }
// HandicapModel.ComputeT + ResolvedHandicap's guard denominators. Returns the RAW post-floor t (the chart's
// x-axis); callers ease it before the band lerps — ease(raw) is exactly what the live ComputeT returns.
function computeRawT(h, p) {
if (!h.Enabled) return 0;
const kdNerfDenom = Math.max(0.01, h.KdMaxNerf - h.KdNeutral);
const kdBuffDenom = Math.max(0.01, h.KdNeutral - h.KdMinBuff);
const hsDenom = Math.max(0.01, h.HsMaxNerf);
const streakDenom = Math.max(1, h.StreakMaxNerf);
const levelDenom = Math.max(1, h.LevelMaxNerf);
const wsum = h.KdWeight + h.HsWeight + h.StreakWeight + h.LevelWeight + h.ProgressWeight;
let kdN = 0, buff = 0;
if (p.kills + p.deaths >= 3) {
const r = p.kills / Math.max(1, p.deaths);
kdN = clamp01((r - h.KdNeutral) / kdNerfDenom);
buff = clamp01((h.KdNeutral - r) / kdBuffDenom);
}
const hsRate = p.kills >= h.HsMinKills ? p.hsk / Math.max(1, p.kills) : 0;
let n = wsum <= 0 ? 0 :
(h.KdWeight * kdN + h.HsWeight * clamp01(hsRate / hsDenom) + h.StreakWeight * clamp01(p.streak / streakDenom)
+ h.LevelWeight * clamp01(p.level / levelDenom) + h.ProgressWeight * clamp01(p.progress)) / wsum;
let t = clamp(h.MasterDifficulty * (n - buff), -1, 1);
if (p.floor > t) t = Math.min(p.floor, 1);
return t;
}
// HandicapModel.DealFromT/TakeFromT/XpFromT (takes the EASED t; team multipliers ride on deal/take only).
function bands(eased, h, teamDeal, teamTake) {
return {
deal: (eased >= 0 ? lerp(1, h.MDealFloor, eased) : lerp(1, h.MDealCeiling, -eased)) * teamDeal,
take: (eased >= 0 ? lerp(1, h.MTakeCeiling, eased) : lerp(1, h.MTakeFloor, -eased)) * teamTake,
xp: eased >= 0 ? lerp(1, h.XpCeiling, eased) : lerp(1, h.XpFloor, -eased),
};
}
// ---- state ----
const h = {}; // live handicap knobs (seeded from server)
const player = { level: 0, prestige: 0, kills: 0, deaths: 0, hsk: 0, streak: 0, progress: 0, floor: -1, prestigeBoost: seed.PrestigeBoostPercent };
const invested = {}; // stat key -> levels
const cards = {}; // card key -> picks
const statDefs = {}; // key -> {Base, PerLevel}
seed.Stats.forEach(function (s) { statDefs[s.Key] = s; invested[s.Key] = 0; });
seed.Cards.forEach(function (c) { cards[c.Key] = 0; });
const curves = { deal: [], take: [], xp: [] }; // resampled from live knobs
// StatResolver.Eff + EffRun: base + invested*perLevel (+ stat-keyed card picks x PerPick — card key == stat key).
function effRun(key) {
const d = statDefs[key];
let v = d ? d.Base + (invested[key] || 0) * d.PerLevel : 0;
const card = seed.Cards.find(function (c) { return c.Key === key && !c.IsTeam; });
if (card) v += (cards[key] || 0) * card.PerPick;
return v;
}
// SurvivalDriver team cards: compounding per shared squad level ((1±PerPick/100)^picks).
function teamMults() {
let deal = 1, take = 1;
seed.Cards.forEach(function (c) {
if (!c.IsTeam) return;
if (c.Key === "global_deal") deal = Math.pow(1 + c.PerPick / 100, cards[c.Key] || 0);
if (c.Key === "global_take") take = Math.pow(1 - c.PerPick / 100, cards[c.Key] || 0);
});
return { deal: deal, take: take };
}
// ---- curves: resample the 201 points from the LIVE knobs (HandicapModel.BandsFromT + Api.cs's loop, client-side;
// the disabled short-circuit mirrors BandsFromT's own) ----
function resample() {
curves.deal = []; curves.take = []; curves.xp = [];
for (let i = 0; i < N; i++) {
const t = -1 + i * (2 / (N - 1));
let b = bands(ease(clamp(t, -1, 1), h.Curve), h, 1, 1);
if (!h.Enabled) b = { deal: 1, take: 1, xp: 1 };
curves.deal.push(b.deal); curves.take.push(b.take); curves.xp.push(b.xp);
}
}
function yScale(vals) { // TheoryModel.BuildPanel's ymax rule
const max = Math.max.apply(null, vals);
const step = max <= 2 ? 0.5 : max <= 5 ? 1 : 2;
return Math.max(step, Math.ceil(max / step) * step);
}
function redrawPanels() {
panels.forEach(function (svg) {
const vals = curves[svg.dataset.key];
const ymax = yScale(vals);
const pts = [];
for (let i = 0; i < vals.length; i++) {
const x = L + i / (vals.length - 1) * PLOT;
const y = T + PLOTH * (1 - vals[i] / ymax);
pts.push(x.toFixed(1) + "," + y.toFixed(1));
}
svg.querySelector(".series").setAttribute("points", pts.join(" "));
const oneY = T + PLOTH * (1 - 1 / ymax);
const one = svg.querySelector(".grid.one");
one.setAttribute("y1", oneY); one.setAttribute("y2", oneY);
const ymaxLbl = svg.querySelector(".tick.ymax");
ymaxLbl.textContent = (Math.round(ymax * 10) / 10).toString();
svg.querySelectorAll(".tick").forEach(function (tk) {
if (tk.textContent === "×1") tk.setAttribute("y", oneY + 4);
});
svg._ymax = ymax;
});
}
// ---- readouts ----
const $ = function (id) { return document.getElementById(id); };
const panels = Array.prototype.slice.call(document.querySelectorAll("svg.panel"));
const slider = $("tslider");
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);
if (!h.Enabled) b = { deal: tm.deal, take: tm.take, xp: 1 };
$("sim-t").textContent = rawT.toFixed(2);
$("sim-deal").textContent = fmt(b.deal);
$("sim-take").textContent = fmt(b.take);
$("sim-xpband").textContent = fmt(b.xp);
// Hud.EffectiveMultipliers parity (headshot:false, crit:false, no actives):
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(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);
// effective column per stat
seed.Stats.forEach(function (s) {
const el = $("eff-" + s.Key);
if (el) el.textContent = (Math.round(effRun(s.Key) * 100) / 100).toString();
});
// the simulated player's position on the raw-t axis
const x = L + (rawT + 1) / 2 * PLOT;
panels.forEach(function (svg) {
const m = svg.querySelector(".simt");
m.setAttribute("x1", x); m.setAttribute("x2", x);
});
updateCross(Number.parseFloat(slider.value));
}
// ---- slider / hover crosshair (explores the CURRENT — possibly edited — curves) ----
function updateCross(t) {
t = clamp(t, -1, 1);
const i = Math.round((t + 1) / 2 * (N - 1));
$("tval").textContent = "t = " + t.toFixed(2);
const x = L + (t + 1) / 2 * PLOT;
panels.forEach(function (svg) {
const cross = svg.querySelector(".cross");
cross.setAttribute("x1", x); cross.setAttribute("x2", x);
const ro = $("ro-" + svg.dataset.key);
if (ro) ro.textContent = fmt(curves[svg.dataset.key][i]);
});
}
slider.addEventListener("input", function () { updateCross(Number.parseFloat(slider.value)); });
panels.forEach(function (svg) {
svg.addEventListener("pointermove", function (ev) {
const rect = svg.getBoundingClientRect();
const vx = (ev.clientX - rect.left) * (W / rect.width);
let t = ((vx - L) / PLOT) * 2 - 1;
slider.value = t.toFixed(2);
updateCross(t);
});
});
// ---- input wiring + seeding ----
function seedInputs() {
Object.keys(seed.Handicap).forEach(function (k) { h[k] = seed.Handicap[k]; });
h.Enabled = !!h.Enabled;
document.querySelectorAll("[data-h]").forEach(function (el) {
const k = el.dataset.h;
if (el.type === "checkbox") el.checked = !!seed.Handicap[k];
else el.value = seed.Handicap[k];
el.classList.remove("modified");
});
document.querySelectorAll("[data-sim]").forEach(function (el) {
const k = el.dataset.sim;
player[k] = k === "floor" ? -1 : k === "prestigeBoost" ? seed.PrestigeBoostPercent : 0;
el.value = player[k];
el.classList.remove("modified");
});
document.querySelectorAll("[data-stat]").forEach(function (el) { invested[el.dataset.stat] = 0; el.value = 0; el.classList.remove("modified"); });
document.querySelectorAll("[data-card]").forEach(function (el) { cards[el.dataset.card] = 0; el.value = 0; el.classList.remove("modified"); });
}
function markModified(el, isDefault) { el.classList.toggle("modified", !isDefault); }
// Out-of-range typed values are clamped AND written back to the field, so the display always shows the number the
// 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 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;
}
document.querySelectorAll("[data-h]").forEach(function (el) {
el.addEventListener("input", function () {
const k = el.dataset.h;
if (el.type === "checkbox") { h[k] = el.checked; markModified(el, el.checked === !!seed.Handicap[k]); }
else {
let v = Number.parseFloat(el.value) || 0;
// The plugin defines Curve's valid domain as > 0 (Pow(0, negative) = Infinity would NaN the charts);
// mirror that boundary here — the payload can never seed a bad value, only an edit can.
if (k === "Curve") v = Math.max(v, 0.05);
h[k] = v;
markModified(el, v === seed.Handicap[k]);
}
recompute();
});
});
document.querySelectorAll("[data-sim]").forEach(function (el) {
el.addEventListener("input", function () {
const k = el.dataset.sim;
const dflt = k === "floor" ? -1 : k === "prestigeBoost" ? seed.PrestigeBoostPercent : 0;
const v = Number.parseFloat(el.value);
player[k] = Number.isFinite(v) ? v : dflt; // a cleared field means "default", not 0 (floor 0 is an ACTIVE floor)
markModified(el, player[k] === dflt);
recompute();
});
});
document.querySelectorAll("[data-stat]").forEach(function (el) {
el.addEventListener("input", function () {
invested[el.dataset.stat] = clampedInt(el);
markModified(el, invested[el.dataset.stat] === 0);
recompute();
});
});
document.querySelectorAll("[data-card]").forEach(function (el) {
el.addEventListener("input", function () {
cards[el.dataset.card] = clampedInt(el);
markModified(el, cards[el.dataset.card] === 0);
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();
verifyParity();
updateReadouts();
})();