/* 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);