cybermax / grindscale /model.js
CyberMax-tools's picture
Deploy grindscale to the hub
9be6417 verified
Raw History Blame Contribute Delete
4.16 kB
/* Grindscale model (browser). Same maths as src/model.py and the workbook. © 2026 CyberMax. All rights reserved. */
(function (root) {
"use strict";
var CURVES = ["Linear", "Power", "Exponential", "Blend"];
var DEFAULTS = { max_level: 50, curve: "Blend", base_xp: 100, linear_step: 50, power_exp: 1.6, exp_growth: 1.06,
blend_weight: 0.25, round_to: 10, xph_l1: 1550, xph_growth: 1.07, target_hours: 60, sessions_per_day: 2, minutes_per_session: 20 };
var PRESETS = {
"Mobile casual (30 levels)": { max_level: 30, curve: "Power", base_xp: 50, linear_step: 25, power_exp: 1.4, exp_growth: 1.08,
blend_weight: 0.2, round_to: 5, xph_l1: 1150, xph_growth: 1.08, target_hours: 15, sessions_per_day: 3, minutes_per_session: 8 },
"Classic RPG (50 levels)": DEFAULTS,
"Long-haul RPG (100 levels)": { max_level: 100, curve: "Blend", base_xp: 200, linear_step: 80, power_exp: 1.7, exp_growth: 1.035,
blend_weight: 0.3, round_to: 50, xph_l1: 2350, xph_growth: 1.05, target_hours: 300, sessions_per_day: 2, minutes_per_session: 45 },
"Roguelite meta (20 ranks)": { max_level: 20, curve: "Exponential", base_xp: 300, linear_step: 150, power_exp: 1.5, exp_growth: 1.15,
blend_weight: 0.5, round_to: 25, xph_l1: 1050, xph_growth: 1.0, target_hours: 25, sessions_per_day: 2, minutes_per_session: 30 }
};
function xround(x, digits) {
var m = Math.pow(10, digits || 0);
var v = Math.floor(Math.abs(x) * m + 0.5 + 1e-9) / m;
return x < 0 ? -v : v;
}
function rawXp(s, lv) {
var pw = s.base_xp * Math.pow(lv, s.power_exp);
var ex = s.base_xp * Math.pow(s.exp_growth, lv - 1);
switch (s.curve) {
case "Linear": return s.base_xp + s.linear_step * (lv - 1);
case "Power": return pw;
case "Exponential": return ex;
default: return (1 - s.blend_weight) * pw + s.blend_weight * ex;
}
}
function xpTable(s) {
var rows = [], totalXp = 0, totalH = 0;
for (var lv = 1; lv <= s.max_level; lv++) {
var need = lv === s.max_level ? 0 : Math.max(s.round_to, xround(rawXp(s, lv) / s.round_to) * s.round_to);
var xph = s.xph_l1 * Math.pow(s.xph_growth, lv - 1);
var hours = need / xph;
rows.push({ level: lv, xp_to_next: need, total_xp: totalXp, xp_per_hour: xph, hours: hours, total_hours: totalH });
totalXp += need;
totalH += hours;
}
return rows;
}
function summary(s) {
var rows = xpTable(s), last = rows[rows.length - 1];
var ratio = last.total_hours / s.target_hours;
var verdict = ratio > 1.15 ? "Slower than target: lower base XP or growth, or raise XP per hour"
: ratio < 0.85 ? "Faster than target: raise XP needs or slow XP per hour" : "On target (within 15%)";
return { total_xp: last.total_xp, total_hours: last.total_hours, ratio: ratio, verdict: verdict,
hours_to_10: rows[Math.min(10, s.max_level) - 1].total_hours,
hours_to_half: rows[Math.ceil(s.max_level / 2) - 1].total_hours,
days_to_max: last.total_hours / (s.sessions_per_day * s.minutes_per_session / 60) };
}
/* Drop chance maths: p per try, pity = guaranteed on try N (optional). */
function drop(p, pity, perHour) {
function tries(q) { return p >= 1 ? 1 : Math.ceil(Math.log(1 - q) / Math.log(1 - p) - 1e-9); }
var t90 = tries(0.9);
return { chance: p, one_in: 1 / p, tries50: tries(0.5), tries90: t90, tries99: tries(0.99),
hours90: perHour > 0 ? t90 / perHour : null, per_hour: p * (perHour || 0),
expected: pity ? (1 - Math.pow(1 - p, pity)) / p : 1 / p,
within: function (n) { return pity && n >= pity ? 1 : 1 - Math.pow(1 - p, n); } };
}
function csv(s) {
var lines = ["level,xp_to_next,total_xp,xp_per_hour"];
xpTable(s).forEach(function (r) { lines.push([r.level, r.xp_to_next, r.total_xp, num(xround(r.xp_per_hour, 2))].join(",")); });
return lines.join("\n") + "\n";
}
function num(v) { return String(v); }
root.Grindscale = { CURVES: CURVES, DEFAULTS: DEFAULTS, PRESETS: PRESETS, xround: xround, rawXp: rawXp, xpTable: xpTable,
summary: summary, drop: drop, csv: csv };
})(typeof window !== "undefined" ? window : globalThis);