cs2-web/wwwroot/js/theory.js
Kamal Tufekcic 2d966b8198 initial commit
2026-07-05 12:14:39 +03:00

272 lines
13 KiB
JavaScript
Raw Permalink 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); }
function recompute() {
resample();
redrawPanels();
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):
$("sim-out").textContent = fmt((1 + effRun("damage") / 100) * b.deal);
$("sim-in").textContent = fmt(b.take);
$("sim-hs").textContent = fmt(1 + effRun("hs_damage") / 100);
// 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 v = clamp(Number.isNaN(raw) ? 0 : raw, 0, Number.parseInt(el.max, 10) || 0);
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();
});
});
$("sim-reset").addEventListener("click", function () { seedInputs(); recompute(); });
seedInputs();
recompute();
})();