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}