File size: 4,155 Bytes
9be6417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/* 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);