code wiki / _hdl_build / nx_rpgstats.nx
nx_rpgstats.nx source
↩ module page · 132 lines · 5855 B
1// nx_rpgstats.nx -- the SOVEREIGN CHARACTER PROGRESSION PART. nx_gamebench gap-queue rank 1 after
2// save-load landed: rpg-stats-progression was PARTIAL and blocks 8 of 12 benchmarked titles
3// (OpenXcom, Open Diablo II, Cataclysm-DDA, Veloren, NetHack, Crawl, Endless Sky, adventure).
4//
5// Built as a CERTIFIED COMPOSABLE PART (knowledge/registry/game_parts.tsv doctrine: build once to exceed,
6// compose forever). Character state is a flat i64 vector BY DESIGN so it drops straight into gs_save/gs_load
7// from nx_gamesave -- parts composing is the whole point of the recombinator.
8//
9// THE PROPERTIES THAT MAKE IT SAFE TO COMPOSE (each one is a gate tooth):
10// 1. ORDER-INDEPENDENT MODIFIER STACKING. Real RPGs get this wrong constantly: apply +10 then +20% and you
11// get a different number than +20% then +10, so buff/debuff application order silently changes outcomes
12// and desyncs multiplayer. Here additive and multiplicative pools are summed SEPARATELY and applied once
13// -- commutative BY CONSTRUCTION, not by convention.
14// 2. DATA-DRIVEN CURVES (rule 11). The XP curve, HP formula and skill thresholds are PARAMETERS, never
15// literals in code. A game tunes progression without touching the part.
16// 3. MONOTONIC + EXACTLY INVERTIBLE XP. level_for_xp is the true inverse of xp_for_level at every boundary,
17// so a character can never land between levels or oscillate.
18// 4. INTEGER-ONLY, DETERMINISTIC. No float anywhere, so progression replays bit-identically -- the same
19// determinism exceed the renderer and nx_gamesave carry.
20// 5. CLAMPED BY CONSTRUCTION. Extreme inputs saturate instead of overflowing into negative HP / wrapped
21// levels (the classic "damage so high it heals you" bug).
22//
23// LIB ONLY -- no main() by ecosystem convention (cf. nx_swgpu.nx).
24// license_tier: ORIGINAL expect_exit: 0
25import "nx_syscalls.nx"
26
27const RS_MAXLEVEL: i64 = 200
28const RS_SAT: i64 = 0x3FFFFFFFFFFFFFFF // saturation ceiling, far below i64 overflow
29const RS_PCT: i64 = 100
30
31func rs_clamp(v: i64, lo: i64, hi: i64) -> i64 {
32 if v < lo { return lo }
33 if v > hi { return hi }
34 return v
35}
36// saturating add: summing two already-saturated terms must not wrap.
37// (Found by the gate: hp_base + smul(...) + smul(...) wrapped negative and the final clamp
38// silently rescued it to 1, which made the saturation tooth pass for the wrong reason.)
39func rs_sadd(a: i64, b: i64) -> i64 {
40 if b > 0 { if a > RS_SAT - b { return RS_SAT } }
41 if b < 0 { if a < (0 - RS_SAT) - b { return 0 - RS_SAT } }
42 return a + b
43}
44// saturating multiply: never wraps into negative territory
45func rs_smul(a: i64, b: i64) -> i64 {
46 if a == 0 { return 0 }
47 if b == 0 { return 0 }
48 var sign: i64 = 1
49 var x: i64 = a
50 var y: i64 = b
51 if x < 0 { sign = 0 - sign; x = 0 - x }
52 if y < 0 { sign = 0 - sign; y = 0 - y }
53 if x > RS_SAT / y { if sign > 0 { return RS_SAT } return 0 - RS_SAT }
54 return sign * x * y
55}
56
57// ===== XP CURVE (data-driven) =====
58// xp_for_level(L) = base*(L-1) + quad*(L-1)*(L-2)/2 -- level 1 costs 0.
59// Strictly increasing for base>0, quad>=0: the step from L to L+1 is base + quad*(L-1) > 0.
60func rs_xp_for_level(level: i64, base: i64, quad: i64) -> i64 {
61 if level <= 1 { return 0 }
62 let n: i64 = level - 1
63 let lin: i64 = rs_smul(base, n)
64 let tri: i64 = rs_smul(quad, rs_smul(n, n - 1) / 2)
65 let t: i64 = rs_sadd(lin, tri)
66 return rs_clamp(t, 0, RS_SAT)
67}
68// exact inverse: the highest level whose threshold is <= xp
69func rs_level_for_xp(xp: i64, base: i64, quad: i64) -> i64 {
70 if xp <= 0 { return 1 }
71 var lo: i64 = 1
72 var hi: i64 = RS_MAXLEVEL
73 while lo < hi {
74 let mid: i64 = (lo + hi + 1) / 2
75 if rs_xp_for_level(mid, base, quad) <= xp { lo = mid } else { hi = mid - 1 }
76 }
77 return lo
78}
79
80// ===== DERIVED STATS (data-driven formula) =====
81func rs_derived_hp(con: i64, level: i64, hp_base: i64, hp_per_con: i64, hp_per_lvl: i64) -> i64 {
82 let t: i64 = rs_sadd(rs_sadd(hp_base, rs_smul(hp_per_con, con)), rs_smul(hp_per_lvl, level - 1))
83 return rs_clamp(t, 1, RS_SAT) // a living character always has >=1 HP
84}
85
86// ===== ORDER-INDEPENDENT MODIFIERS =====
87// mods are (kind, value) pairs packed in a flat array: kind 0 = additive, kind 1 = percent.
88// All additives are summed, all percents are summed, then applied ONCE:
89// final = (base + sum_add) * (100 + sum_pct) / 100
90// Summation is commutative, so the result cannot depend on application order.
91func rs_apply_mods(base: i64, mods: *i64, nmods: i64) -> i64 {
92 var add: i64 = 0
93 var pct: i64 = 0
94 var i: i64 = 0
95 while i < nmods {
96 let kind: i64 = mods[i*2]
97 let val: i64 = mods[i*2 + 1]
98 if kind == 0 { add = add + val }
99 if kind == 1 { pct = pct + val }
100 i = i + 1
101 }
102 var v: i64 = rs_sadd(base, add)
103 // clamp the percent pool so a stack of debuffs cannot invert the sign
104 var p: i64 = RS_PCT + pct
105 if p < 0 { p = 0 }
106 v = rs_smul(v, p) / RS_PCT
107 return rs_clamp(v, 0, RS_SAT)
108}
109
110// ===== SKILLS (use-based advancement, monotonic + capped) =====
111// rank increases while accumulated uses cross rank*threshold; never decreases, never exceeds maxrank.
112func rs_skill_rank(uses: i64, threshold: i64, maxrank: i64) -> i64 {
113 if threshold <= 0 { return 0 }
114 if uses <= 0 { return 0 }
115 var rank: i64 = 0
116 var spent: i64 = 0
117 var need: i64 = threshold
118 var go: i64 = 1
119 while go == 1 {
120 if rank >= maxrank { go = 0 }
121 if go == 1 {
122 if uses >= spent + need {
123 spent = spent + need
124 rank = rank + 1
125 need = need + threshold // each rank costs progressively more
126 } else {
127 go = 0
128 }
129 }
130 }
131 return rank
132}