Difference between revisions of "User:Trang Oul/Utils"

From Basin Wiki
Jump to navigation Jump to search
 
(5 intermediate revisions by the same user not shown)
Line 33: Line 33:
  
 
====Multipliers====
 
====Multipliers====
* <code><nowiki>hitshift</nowiki></code> - used to multiply the damage by a power of 2 (2^(x-8); 8 = ×1). See Phrozen Keep guide for details.
+
* <code><nowiki>hitshift</nowiki></code> - used to multiply the damage by a power of 2 (<code>2^(hitshift-8)</code>; 8 = ×1). See Phrozen Keep guide for details.
 +
* <code>SrcDamage</code> - used as weapon damage multiplier (for skills such as [[Multiple Shot]]), in 1/128 (128 = ×1, 96 = ×3/4 and so on).
  
 
===Formula details===
 
===Formula details===
Line 213: Line 214:
  
  
lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev1, lev2, lev3, lev4, lev5 = sympy.symbols('lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev1, lev2, lev3, lev4, lev5', integer=True)
+
lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev, lev1, lev2, lev3, lev4, lev5 = sympy.symbols('lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev, lev1, lev2, lev3, lev4, lev5', integer=True)
  
  
  
 
# the formulae are the same per category, but for ease of use they are defined with names from skillcalc.txt
 
# the formulae are the same per category, but for ease of use they are defined with names from skillcalc.txt
ln12_sympy: sympy.core.expr.Expr = par1 + (lvl - 1) * par2
+
ln12: sympy.core.expr.Expr = par1 + (lvl - 1) * par2
ln34_sympy: sympy.core.expr.Expr = par3 + (lvl - 1) * par4
+
ln34: sympy.core.expr.Expr = par3 + (lvl - 1) * par4
ln56_sympy: sympy.core.expr.Expr = par5 + (lvl - 1) * par6
+
ln56: sympy.core.expr.Expr = par5 + (lvl - 1) * par6
ln78_sympy: sympy.core.expr.Expr = par7 + (lvl - 1) * par8
+
ln78: sympy.core.expr.Expr = par7 + (lvl - 1) * par8
  
# dm12_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par2 - par1)) / (100 * (lvl + 6))) + par1 #floor because of the integer division
+
# dm12: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par2 - par1)) / (100 * (lvl + 6))) + par1 #floor because of the integer division
# dm34_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par4 - par3)) / (100 * (lvl + 6))) + par3
+
# dm34: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par4 - par3)) / (100 * (lvl + 6))) + par3
# dm56_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par6 - par5)) / (100 * (lvl + 6))) + par5
+
# dm56: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par6 - par5)) / (100 * (lvl + 6))) + par5
# dm78_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par8 - par7)) / (100 * (lvl + 6))) + par7
+
# dm78: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par8 - par7)) / (100 * (lvl + 6))) + par7
  
 
# A formula different than in skillcalc.txt, but the results match those in the game (credit: Onderduiker).
 
# A formula different than in skillcalc.txt, but the results match those in the game (credit: Onderduiker).
dm12_sympy = sympy.Min(par1 + sympy.floor((par2 - par1) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par2)
+
dm12 = sympy.Min(par1 + sympy.floor((par2 - par1) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par2)
dm34_sympy = sympy.Min(par3 + sympy.floor((par4 - par3) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par4)
+
dm34 = sympy.Min(par3 + sympy.floor((par4 - par3) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par4)
dm56_sympy = sympy.Min(par5 + sympy.floor((par6 - par5) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par6)
+
dm56 = sympy.Min(par5 + sympy.floor((par6 - par5) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par6)
dm78_sympy = sympy.Min(par7 + sympy.floor((par8 - par7) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par8)
+
dm78 = sympy.Min(par7 + sympy.floor((par8 - par7) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par8)
  
  
levelwise3_sympy: sympy.core.expr.Expr = sympy.Piecewise(
+
levelwise1: sympy.core.expr.Expr = sympy.Piecewise(
 +
(base,                                                                                        lvl == 1),
 +
(base + lev * (lvl-1),                                                                        True)
 +
)
 +
 
 +
levelwise3: sympy.core.expr.Expr = sympy.Piecewise(
 
(base,                                                                                        lvl == 1),
 
(base,                                                                                        lvl == 1),
 
(base + lev1 * (lvl-1),                                                                        lvl <= 8),
 
(base + lev1 * (lvl-1),                                                                        lvl <= 8),
Line 242: Line 248:
 
)
 
)
  
levelwise5_sympy: sympy.core.expr.Expr= sympy.Piecewise(
+
levelwise5: sympy.core.expr.Expr= sympy.Piecewise(
 
(base,                                                                                        lvl == 1),
 
(base,                                                                                        lvl == 1),
 
(base + lev1 * (lvl-1),                                                                        lvl <= 8),
 
(base + lev1 * (lvl-1),                                                                        lvl <= 8),
Line 248: Line 254:
 
(base + lev1 * 7      + lev2 * 8      + lev3 * (lvl-16),                                    lvl <= 22),
 
(base + lev1 * 7      + lev2 * 8      + lev3 * (lvl-16),                                    lvl <= 22),
 
(base + lev1 * 7      + lev2 * 8      + lev3 * 6        + lev4 * (lvl-22),                  lvl <= 28),
 
(base + lev1 * 7      + lev2 * 8      + lev3 * 6        + lev4 * (lvl-22),                  lvl <= 28),
(base + lev1 * 7      + lev2 * 8      + lev3 * 6        + lev4 * 6        + lev5 * (lvl-28), True),
+
(base + lev1 * 7      + lev2 * 8      + lev3 * 6        + lev4 * 6        + lev5 * (lvl-28), True)
 
)
 
)
  
Line 263: Line 269:
 
'par8' : par8,
 
'par8' : par8,
 
'base' : base,
 
'base' : base,
 +
'lev'  : lev,
 
'lev1' : lev1,
 
'lev1' : lev1,
 
'lev2' : lev2,
 
'lev2' : lev2,
Line 268: Line 275:
 
'lev4' : lev4,
 
'lev4' : lev4,
 
'lev5' : lev5,
 
'lev5' : lev5,
'ln12' : ln12_sympy,
+
'ln12' : ln12,
'ln34' : ln34_sympy,
+
'ln34' : ln34,
'ln56' : ln56_sympy,
+
'ln56' : ln56,
'ln78' : ln78_sympy,
+
'ln78' : ln78,
'dm12' : dm12_sympy,
+
'dm12' : dm12,
'dm34' : dm34_sympy,
+
'dm34' : dm34,
'dm56' : dm56_sympy,
+
'dm56' : dm56,
'dm78' : dm78_sympy,
+
'dm78' : dm78,
'levelwise3': levelwise3_sympy,
+
'levelwise3': levelwise3,
'levelwise5': levelwise5_sympy,
+
'levelwise5': levelwise5,
 
}
 
}
 
return sympy.sympify(formula, FORMULA_PARTS)
 
return sympy.sympify(formula, FORMULA_PARTS)
  
 
+
def _levelwise_params(params:tuple[sympy.core.symbol.Symbol], values:str) -> dict[sympy.core.symbol.Symbol, int]:
# the formulae are the same per category, but for ease of use they are defined with names from skillcalc.txt
 
def ln12_py(lvl:int, par1:int, par2:int) -> int: return par1 + (lvl - 1) * par2
 
def ln34_py(lvl:int, par3:int, par4:int) -> int: return par3 + (lvl - 1) * par4
 
def ln56_py(lvl:int, par5:int, par6:int) -> int: return par5 + (lvl - 1) * par6
 
def ln78_py(lvl:int, par7:int, par8:int) -> int: return par7 + (lvl - 1) * par9
 
 
 
# def dm12_py(lvl:int, par1:int, par2:int) -> int: return ((110 * lvl) * (par2 - par1)) // (100 * (lvl + 6)) + par1
 
# def dm34_py(lvl:int, par3:int, par4:int) -> int: return ((110 * lvl) * (par4 - par3)) // (100 * (lvl + 6)) + par3
 
# def dm56_py(lvl:int, par5:int, par6:int) -> int: return ((110 * lvl) * (par6 - par5)) // (100 * (lvl + 6)) + par5
 
# def dm78_py(lvl:int, par7:int, par8:int) -> int: return ((110 * lvl) * (par8 - par7)) // (100 * (lvl + 6)) + par7
 
 
 
 
 
# A formula different than in skillcalc.txt, but the results match those in the game (credit: Onderduiker).
 
def dm12_py(lvl:int, par1:int, par2:int) -> int: return min(par1 + math.floor((par2 - par1) * math.floor((110 * lvl) / (lvl + 6)) / 100), par2)
 
def dm34_py(lvl:int, par3:int, par4:int) -> int: return min(par3 + math.floor((par4 - par3) * math.floor((110 * lvl) / (lvl + 6)) / 100), par4)
 
def dm56_py(lvl:int, par5:int, par6:int) -> int: return min(par5 + math.floor((par6 - par5) * math.floor((110 * lvl) / (lvl + 6)) / 100), par6)
 
def dm78_py(lvl:int, par7:int, par8:int) -> int: return min(par7 + math.floor((par8 - par7) * math.floor((110 * lvl) / (lvl + 6)) / 100), par8)
 
 
 
def levelwise3_py(lvl:int, base:int, lev1:int, lev2:int, lev3:int) -> int:
 
 
"""
 
"""
Formula based on level ranges (with 5 ranges).
+
Builds parameters for levelwise formula from the values directly copied from data file.
Base and values per level range are defined in columns, for example:
+
The same number of values as params are expected, separated with a tab.
EMin, EMinLev1, EMinLev2, EMinLev3, EMinLev4, EMinLev5
 
ELen, ELevLen1, ELevLen2, ELevLen3
 
Level ranges are hardcoded, as described in https://d2mods.info/forum/viewtopic.php?t=41556
 
 
"""
 
"""
if lvl == 1:
+
return {params[i]: int(val) for i, val in enumerate(values.split('\t'))}
return base
 
if lvl <= 8:
 
return base + lev1 * (lvl-1)
 
if lvl <= 16:
 
return base + lev1 * 7      + lev2 * (lvl-8)
 
else:
 
return base + lev1 * 7      + lev2 * 8      + lev3 * (lvl-16)
 
  
def levelwise5_py(lvl:int, base:int, lev1:int, lev2:int, lev3:int, lev4:int, lev5:int) -> int:
+
def levelwise1_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
 
"""
 
"""
Formula based on level ranges (with 3 ranges).
+
Builds parameters for levelwise formula from the values directly copied from data file.
Base and values per level range are defined in columns, for example:
+
2 values are expected: base, lev, separated with a tab.
ELen, ELevLen1, ELevLen2, ELevLen3
+
Used for columns such as [Range, LevRange] or [ToHit, LevToHit].
Level ranges are hardcoded, as described in https://d2mods.info/forum/viewtopic.php?t=41556
 
 
"""
 
"""
if lvl == 1:
+
return _levelwise_params((base, lev), values)
return base
 
if lvl <= 8:
 
return base + lev1 * (lvl-1)
 
if lvl <= 16:
 
return base + lev1 * 7      + lev2 * (lvl-8)
 
if lvl <= 22:
 
return base + lev1 * 7      + lev2 * 8      + lev3 * (lvl-16)
 
if lvl <= 28:
 
return base + lev1 * 7      + lev2 * 8      + lev3 * 6        + lev4 * (lvl-22)
 
else:
 
return base + lev1 * 7      + lev2 * 8      + lev3 * 6        + lev4 * 6        + lev5 * (lvl-28)
 
  
 
def levelwise3_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
 
def levelwise3_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
Line 341: Line 307:
 
Builds parameters for levelwise formula from the values directly copied from data file.
 
Builds parameters for levelwise formula from the values directly copied from data file.
 
4 values are expected: base, lev1, lev2, lev3, separated with a tab.
 
4 values are expected: base, lev1, lev2, lev3, separated with a tab.
 +
Used for columns such as [ELen, ELevLen1, ELevLen2, ELevLen3].
 
"""
 
"""
values = tuple(int(val) for val in values.split('\t'))
+
return _levelwise_params((base, lev1, lev2, lev3), values)
return {base: values[0], lev1: values[1], lev2: values[2], lev3: values[3]}
 
  
 
def levelwise5_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
 
def levelwise5_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
Line 349: Line 315:
 
Builds parameters for levelwise formula from the values directly copied from data file.
 
Builds parameters for levelwise formula from the values directly copied from data file.
 
6 values are expected: base, lev1, lev2, lev3, lev5, lev5, separated with a tab.
 
6 values are expected: base, lev1, lev2, lev3, lev5, lev5, separated with a tab.
 +
Used for columns such as [EMin, MinELev1, MinELev2, MinELev3, MinELev4, MinELev5].
 
"""
 
"""
values = tuple(int(val) for val in values.split('\t'))
+
return _levelwise_params((base, lev1, lev2, lev3, lev4, lev5), values)
return {base: values[0], lev1: values[1], lev2: values[2], lev3: values[3], lev4: values[4], lev5: values[5]}
 
  
 
class Stat:
 
class Stat:
def __init__(self, desc:str, formula, params:dict|None=None, seconds:bool=False) -> None:
+
def __init__(self, desc:str, formula, params:dict|None=None, hitshift:int=8, rational:bool=False, places_int:int=1, places_frac:int=0, pad_sign:bool=False, seconds:bool=False) -> None:
 
if params is None:
 
if params is None:
 
params = {}
 
params = {}
 
self.desc = desc
 
self.desc = desc
 
self.params = params
 
self.params = params
self.formula = self.make_formula(formula, seconds)
+
self.rational = rational
 +
self.places_int  = min(places_int, 1)
 +
self.places_frac = places_frac
 +
self.pad_sign = pad_sign
 +
self.formula = self.make_formula(formula, hitshift, seconds)
 
if isinstance(self.formula, sympy.core.expr.Expr):
 
if isinstance(self.formula, sympy.core.expr.Expr):
 
print(self.desc)
 
print(self.desc)
Line 365: Line 335:
 
 
 
@classmethod
 
@classmethod
def make_formula(cls, formula, seconds:bool=False):
+
def make_formula(cls, formula, hitshift:int=8, seconds:bool=False):
 
if isinstance(formula, (str, int, float, Fraction)):
 
if isinstance(formula, (str, int, float, Fraction)):
 
formula = make_formula(formula)
 
formula = make_formula(formula)
 +
 +
factor = sympy.Pow(2, hitshift-8)
 +
formula = formula * factor
 
 
 
if seconds:
 
if seconds:
 
if isinstance(formula, sympy.core.expr.Expr):
 
if isinstance(formula, sympy.core.expr.Expr):
return formula / FRAMES_PER_SECOND
+
formula = formula / FRAMES_PER_SECOND
else:
 
return lambda **params: formula(**params) / FRAMES_PER_SECOND
 
 
return formula
 
return formula
 
 
 
 
 
@classmethod
 
@classmethod
def round(cls, val) -> int|float:
+
def round(cls, val: sympy.core.numbers.Number) -> str:
ival: int  = int(val)
+
if not val.is_integer:
fval: float = float(val)
+
val = float(val)
return ival if ival == fval else fval
+
return str(val)
 
 
def _eval_sympy(self, slvl:int) -> int|float:
+
@classmethod
return self.round((self.formula.subs({lvl: slvl} | self.params)))
+
def do_format(cls, val:sympy.core.numbers.Rational, rational:bool=False, places_int:int=0, places_frac:int=0, pad_sign:bool=False) -> str:
 +
'''
 +
rational - whether to use rational or decimal fractions
 +
places_int - number of places to be padded on the left side of an integer part
 +
places_frac - number of places to be padded on the right side of a decimal part (in case of decimal numbers)
 +
or on the left side of numberator and denominator (in case of rational numbers)
 +
pad_sign - whether to pad the nonnegative numbers to be aligned with negative ones, prepended by a minus sign
 +
 +
'''
 +
def pad_with_text(s:str) -> str:
 +
return f'{{{{0|{s}}}}}' if s else ''
 +
 +
def pad_with_zeroes(places:int) -> str:
 +
return pad_with_text('0' * places)
 +
 +
def pad_int(val: sympy.core.numbers.Integer, places:int, hide_zero:bool=False) -> str:
 +
if not val.is_nonnegative or not val.is_integer:
 +
raise ValueError(f'Number must be a nonnegative integer (got {val})')
 +
 +
places = max(places - len(str(val)), 0)
 +
if hide_zero and val.is_zero:
 +
val = ''
 +
places += 1
 +
return f'{pad_with_zeroes(places)}{val}'
 +
 +
def pad_frac_irrational(val:sympy.core.numbers.Number, places:int) -> str:
 +
if not val.is_nonnegative or val >= 1:
 +
raise ValueError(f'Number must be a nonnegative proper fraction (got {val})')
 +
 +
if val.is_integer:
 +
if not places:
 +
return ''
 +
frac_part = '0' * places
 +
return pad_with_text(f'.{frac_part}')
 +
 +
frac_part = val - int(val)
 +
frac_part = str(float(frac_part))[2:] #without '0.'
 +
places = max(places - len(frac_part), 0)
 +
return f'.{frac_part}{pad_with_zeroes(places)}'
 +
 +
 +
def pad_frac_rational(val:sympy.core.numbers.Number, places:int) -> str:
 +
if not val.is_nonnegative or val >= 1:
 +
raise ValueError('fNumber must be a nonnegative proper fraction (got {val})')
 +
 +
if val.is_integer:
 +
if not places:
 +
return ''
 +
num_part = den_part = '0' * places
 +
frac_part = f'{num_part}/{den_part}'
 +
return f' {pad_with_text(frac_part)}'
 +
 +
frac_part = val - int(val)
 +
num, den = frac_part.as_numer_denom()
 +
return f' {pad_int(num, places)}/{pad_int(den, places)}'
 +
 +
 +
pad_sign = '-' if val.is_negative else '{{0|-}}' if pad_sign else ''
 +
absval = abs(val)
 +
 +
absval_int  = sympy.Integer(absval)
 +
absval_frac = absval - absval_int
 +
 +
int_part = pad_int(val=absval_int, places=places_int, hide_zero=rational and val.is_nonzero)
 +
 +
pad_frac = pad_frac_rational if rational else pad_frac_irrational
 +
frac_part = pad_frac(val=absval_frac, places=places_frac)
 +
 +
# return f'{pad_sign} ## {int_part} ## {frac_part}'
 +
return f'{pad_sign}{int_part}{frac_part}'
 
 
def _eval_py(self, slvl:int) -> int|float:
+
def calc(self, slvl:int) -> int|float|str:
params: dict[str, int] = {param.name: value for param, value in self.params.items()}
+
val = self.formula.subs({lvl: slvl} | self.params)
return self.round(self.formula(lvl=slvl, **params))
+
return self.do_format(val, self.rational, self.places_int, self.places_frac, self.pad_sign)
+
 
def eval(self, slvl:int) -> int|float:
 
if isinstance(self.formula, sympy.core.expr.Expr):
 
return self._eval_sympy(slvl)
 
else:
 
return self._eval_py(slvl)
 
 
 
 
def print_expr(self, seconds:bool=False) -> None:
 
def print_expr(self, seconds:bool=False) -> None:
Line 411: Line 446:
 
return '\n\n'.join(
 
return '\n\n'.join(
 
f'''{{{{Levels {lvl_group+1}-{lvl_group+splitlvl}}}}}
 
f'''{{{{Levels {lvl_group+1}-{lvl_group+splitlvl}}}}}
{'\n'.join(f'|-\n!align=left|{stat.desc}\n|{"||".join(str(stat.eval(lvl_group+lvl_unit)) for lvl_unit in range(1, splitlvl+1))}' for stat in stats)}
+
{'\n'.join(f'|-\n!align=left| {stat.desc}\n| {" || ".join(str(stat.calc(lvl_group+lvl_unit)) for lvl_unit in range(1, splitlvl+1))}' for stat in stats)}
 
|}}''' for lvl_group in range(0, maxlvl, splitlvl)
 
|}}''' for lvl_group in range(0, maxlvl, splitlvl)
 
)
 
)
Line 419: Line 454:
 
USAGE
 
USAGE
 
Make a list of stats to be included in the table of values (levels 1-60).
 
Make a list of stats to be included in the table of values (levels 1-60).
Formulae can be defined in three ways:
+
Formulae can be defined in two ways:
1. Pure Python: use Python functions - either defined here (fun_py) or other named or lambda functions, with lvl as the first param in addition to formula params.
+
1. SymPy: use SymPy expressions - either defined here (fun) or other expressions, using appropriate SymPy functions (for exampe sympy.Min instead of min).
2. SymPy: use SymPy expressions - either defined here (fun_sympy) or other expressions, using appropriate SymPy functions (for exampe sympy.Min instead of min).
+
1. Formula as string directly from the data file. Allowed formula parts: lvl, parameters (parX), functions (lnXY and dmXY) and some math functions (min, max). Remove synergies to calculate values without them.
3. Formula as string directly from the data file. Allowed formula parts: lvl, parameters (parX), functions (lnXY and dmXY) and some math functions (min, max). Remove synergies to calculate values without them.
 
 
See the examples below. Use only one formula per stat; the repeated stats below are to show how to define formulae.
 
See the examples below. Use only one formula per stat; the repeated stats below are to show how to define formulae.
  
Line 428: Line 462:
 
To convert time values from frames to seconds, set the "seconds" flag.
 
To convert time values from frames to seconds, set the "seconds" flag.
  
For SymPy (or string) formulae simplified formulae are also printed.
+
Simplified formulae are also printed.
 
For time values formulae with integer and decimal coefficients are printed.
 
For time values formulae with integer and decimal coefficients are printed.
"""
+
 
 +
If th
 +
 
  
 
stats: tuple[Stat] = (
 
stats: tuple[Stat] = (
Stat(desc='Inner Sight Duration (Seconds)',  formula=ln34_py,                                                                params={par3: 300, par4: 150}, seconds=True),
+
Stat(desc='Inner Sight Duration (Seconds)',  formula=ln34,                                                            params={par3: 300, par4: 150}, seconds=True),
Stat(desc='Inner Sight Duration (Seconds)',  formula=ln34_sympy,                                                            params={par3: 300, par4: 150}, seconds=True),
 
 
Stat(desc='Inner Sight Duration (Seconds)',  formula='ln34',                                                                params={par3: 300, par4: 150}, seconds=True),
 
Stat(desc='Inner Sight Duration (Seconds)',  formula='ln34',                                                                params={par3: 300, par4: 150}, seconds=True),
Stat(desc='Slow Missiles [[Missile]] Speed %', formula=lambda lvl,par1,par2: 100-dm12_py(lvl, par1, par2),                  params={par1: 25,  par2: 75}),
+
Stat(desc='Slow Missiles [[Missile]] Speed %', formula=                      100-dm12,                                params={par1: 25,  par2: 75}),
Stat(desc='Slow Missiles [[Missile]] Speed %', formula=                      100-dm12_sympy,                                params={par1: 25,  par2: 75}),
 
 
Stat(desc='Slow Missiles [[Missile]] Speed %', formula=                    '100-dm12',                                      params={par1: 25,  par2: 75}),
 
Stat(desc='Slow Missiles [[Missile]] Speed %', formula=                    '100-dm12',                                      params={par1: 25,  par2: 75}),
Stat(desc='Impale Durability loss chance %', formula=lambda lvl,par3,par4,par6: par6-dm34_py(lvl, par3, par4),              params={par3: 0,  par4: 30,  par6: 50}),
+
Stat(desc='Impale Durability loss chance %', formula=                          par6-dm34,                            params={par3: 0,  par4: 30,  par6: 50}),
Stat(desc='Impale Durability loss chance %', formula=                          par6-dm34_sympy,                            params={par3: 0,  par4: 30,  par6: 50}),
 
 
Stat(desc='Impale Durability loss chance %', formula=                          'par6-dm34',                                  params={par3: 0,  par4: 30,  par6: 50}),
 
Stat(desc='Impale Durability loss chance %', formula=                          'par6-dm34',                                  params={par3: 0,  par4: 30,  par6: 50}),
Stat(desc='Thunderstorm perdelay', formula=lambda lvl,par3,par4,par5,par6: (100-dm56_py(lvl, par5, par6)) * par4/100 + par3, params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
+
Stat(desc='Thunderstorm perdelay', formula=                                (100-dm56) * par4/100 + par3,              params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
Stat(desc='Thunderstorm perdelay', formula=                                (100-dm56_sympy) * par4/100 + par3,              params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
 
 
Stat(desc='Thunderstorm perdelay', formula=                              '(100-dm56) * par4/100 + par3',                    params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
 
Stat(desc='Thunderstorm perdelay', formula=                              '(100-dm56) * par4/100 + par3',                    params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
Stat(desc='Battle Cry duration (Seconds)', formula= ln12_py,                                                                params={par1: 300, par2: 60}, seconds=True),
+
Stat(desc='Battle Cry duration (Seconds)', formula= ln12,                                                              params={par1: 300, par2: 60}, seconds=True),
Stat(desc='Battle Cry duration (Seconds)', formula= ln12_sympy,                                                              params={par1: 300, par2: 60}, seconds=True),
 
 
Stat(desc='Battle Cry duration (Seconds)', formula='ln12',                                                                  params={par1: 300, par2: 60}, seconds=True),
 
Stat(desc='Battle Cry duration (Seconds)', formula='ln12',                                                                  params={par1: 300, par2: 60}, seconds=True),
Stat(desc='Chain Lightning hits', formula=lambda lvl, par3, par4, par5: ln34_py(lvl, par3, par4) / par5,                    params={par3: 26,  par4: 1, par5: 5}),
+
Stat(desc='Chain Lightning hits', formula=                              ln34               / par5,                    params={par3: 26,  par4: 1, par5:5}),
Stat(desc='Chain Lightning hits', formula=                              ln34_sympy               / par5,                    params={par3: 26,  par4: 1, par5:5}),
 
 
Stat(desc='Chain Lightning hits', formula=                            'ln34/par5',                                          params={par3: 26,  par4: 1, par5:5}),
 
Stat(desc='Chain Lightning hits', formula=                            'ln34/par5',                                          params={par3: 26,  par4: 1, par5:5}),
Stat(desc='Strafe min hits', formula=lambda lvl, par3, par4: min(par3 + lvl - 1, par4),                                      params={par3: 4,  par4: 10}),
 
 
Stat(desc='Strafe min hits', formula=                  sympy.Min(par3 + lvl - 1, par4),                                      params={par3: 4,  par4: 10}),
 
Stat(desc='Strafe min hits', formula=                  sympy.Min(par3 + lvl - 1, par4),                                      params={par3: 4,  par4: 10}),
 
Stat(desc='Strafe min hits', formula=                      'min(par3 + lvl - 1, par4)',                                    params={par3: 4,  par4: 10}),
 
Stat(desc='Strafe min hits', formula=                      'min(par3 + lvl - 1, par4)',                                    params={par3: 4,  par4: 10}),
Stat(desc='Prayer healing', formula=levelwise5_py,                                                                          params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
+
Stat(desc='Prayer healing', formula=levelwise5,                                                                        params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
Stat(desc='Prayer healing', formula=levelwise5_sympy,                                                                        params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
 
 
Stat(desc='Prayer healing', formula='levelwise5',                                                                            params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
 
Stat(desc='Prayer healing', formula='levelwise5',                                                                            params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
Stat(
 
desc='Energy shield absorption',
 
formula=lambda lvl, base, lev1, lev2, lev3, lev4, lev5: min(levelwise5_py(lvl, base, lev1, lev2, lev3, lev4, lev5), 95),
 
params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
 
),
 
 
Stat(desc='Energy shield absorption',
 
Stat(desc='Energy shield absorption',
formula=sympy.Min(levelwise5_sympy, 95),
+
formula=sympy.Min(levelwise5, 95),
 
params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
 
params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
 
),
 
),
Line 470: Line 493:
 
params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
 
params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
 
),
 
),
Stat(desc='Spirit Wolf coldlength', formula=levelwise3_py,        params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
+
Stat(desc='Spirit Wolf coldlength', formula=levelwise3,    params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
Stat(desc='Spirit Wolf coldlength', formula=levelwise3_sympy,    params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
 
 
Stat(desc='Spirit Wolf coldlength', formula='levelwise3',        params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
 
Stat(desc='Spirit Wolf coldlength', formula='levelwise3',        params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
Stat(desc='Chilling Armor cold length', formula=levelwise3_py,    params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
+
Stat(desc='Chilling Armor cold length', formula=levelwise3, params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
Stat(desc='Chilling Armor cold length', formula=levelwise3_sympy, params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
 
 
Stat(desc='Chilling Armor cold length', formula='levelwise3',    params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
 
Stat(desc='Chilling Armor cold length', formula='levelwise3',    params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
Stat(desc='Clay Golem slow', formula=dm34_py,          params={par3: 0, par4: 75}),
+
Stat(desc='Clay Golem slow', formula=dm34,      params={par3: 0, par4: 75}),
Stat(desc='Clay Golem slow', formula=dm34_sympy,      params={par3: 0, par4: 75}),
 
 
Stat(desc='Clay Golem slow', formula='dm34',          params={par3: 0, par4: 75}),
 
Stat(desc='Clay Golem slow', formula='dm34',          params={par3: 0, par4: 75}),
Stat(desc='Blaze length', formula=dm12_py,          params={par1: 50, par2: 500}, seconds=True),
+
Stat(desc='Blaze length', formula=dm12,      params={par1: 50, par2: 500}, seconds=True),
Stat(desc='Blaze length', formula=dm12_sympy,      params={par1: 50, par2: 500}, seconds=True),
 
 
Stat(desc='Blaze length', formula='dm12',          params={par1: 50, par2: 500}, seconds=True),
 
Stat(desc='Blaze length', formula='dm12',          params={par1: 50, par2: 500}, seconds=True),
 
 
 
# advanced example: poison damage (Poison Javelin)
 
# advanced example: poison damage (Poison Javelin)
 
# duration first (will be used in subsequent formulae)
 
# duration first (will be used in subsequent formulae)
Stat(desc='Seconds',                formula=levelwise3_sympy, params=levelwise3_params('200 50 50 50'), seconds=True),
+
Stat(desc='Seconds',                formula=levelwise3, params=levelwise3_params('75 5 5 5'), seconds=True),
 
# total poison damage = poison bit rate (from data file, in 1/256 per frame) * duration (from data file, in frames) / 256 (to converts bits into full points); we round it down to the full point
 
# total poison damage = poison bit rate (from data file, in 1/256 per frame) * duration (from data file, in frames) / 256 (to converts bits into full points); we round it down to the full point
Stat(desc='Minimum Poison Damage',  formula=sympy.floor(levelwise5_sympy.subs(levelwise5_params('32 16 32 48 64 96')) * levelwise3_sympy.subs(levelwise3_params('200 50 50 50')) / 256)),
+
Stat(desc='Minimum Poison Damage',  formula=sympy.floor(levelwise5.subs(levelwise5_params('196 64 128 208 440 640')) * levelwise3.subs(levelwise3_params('75 5 5 5')) / 256)),
 
# poison DPS = (poison bit rate * duration / 256) / duration * 25 (to calculate per second, not per frame)
 
# poison DPS = (poison bit rate * duration / 256) / duration * 25 (to calculate per second, not per frame)
 
# = poison bit rate * 25 / 256; we round it down to the full point
 
# = poison bit rate * 25 / 256; we round it down to the full point
Stat(desc='Minimum Poison Damage/s', formula=sympy.floor(levelwise5_sympy.subs(levelwise5_params('32 16 32 48 64 96')) * sympy.Rational(25, 256))),
+
Stat(desc='Minimum Poison Damage/s', formula=sympy.floor(levelwise5.subs(levelwise5_params('196 64 128 208 440 640')) * sympy.Rational(25, 256))),
 
#similar calculations for maximum damage
 
#similar calculations for maximum damage
 
)
 
)
 
 
print(build_table(stats))
 
print(build_table(stats))
  

Latest revision as of 04:25, 12 August 2024

Calculating skill params formulae and values per level

This tutorial's goal is to obtain two results:

  • a formula for a given skill's property (damage, range, duration and so on)
  • values for that property at different levels (1-60, as on skill pages).


1. Obtaining data

First we need to find the formulae. They are stored in data files, in skills.txt and in skillcalc.txt. They are ordinary TSV files, and while there are specialized tools for modding Diablo 2, plain old MS Excel suffices, with features like freezing headers, filtering or searching.

This part is based on Phrozen Keep. File: skills.txt

Columns

Formulae

Formulae are stored in the following columns (this list may not be exhaustive):

  • calcX - general skill formulae - see calcX desc for description
  • prgcalcX - Assassin finishers; also Shock Web, Blade Fury
  • auralencalc - length of aura/curse/buff/debuff, in frames
  • aurarangecalc - range of aura/curse/buff/debuff; also used by other skills
  • aurastatcalcX - stats of aura/curse/buff/debuff - see aurastatX for stat name
  • passivecalcX - passive skills - see passivestatX for stat name
  • petmax - max number of pets
  • DmgSymPerCalc - synergy to physical damage
  • EDmgSymPerCalc - synergy to elemental damage
  • ELenSymPerCalc - synergy to the duration of elemental damage
  • cltcalcX - client-side calculations - not used for our purpose


Params

  • ParamX - numeric param used in formula - see ParamX Description for description
  • other columns (including, but not limited toELen..., EMin..., EMax...) - referenced by formulae below.

Multipliers

  • hitshift - used to multiply the damage by a power of 2 (2^(hitshift-8); 8 = ×1). See Phrozen Keep guide for details.
  • SrcDamage - used as weapon damage multiplier (for skills such as Multiple Shot), in 1/128 (128 = ×1, 96 = ×3/4 and so on).

Formula details

  • hardcoded numbers
  • basic math operators and functions: arithmetic, min, max
  • conditional operator: condition ? value_if_true : value_if_false
  • functions and params as defined in skillcalc.txt:
  • lnXY, dmXY - linear and diminishing (rational) functions. We will be the most interested in these formulae.
All of them use skill level and additional params.
Currently all those functions are the same per category.
  • parX - parameter (column ParamX)
  • lvl - soft skill level (i.e. with bonuses)
  • blvl - base skill level (i.e. only hard points); used mostly for synergies
  • ulvl - character level; used by Druid summons only
  • references to other column or columns - used mostly for progression based on level ranges
Examples:
  • edns - "Elemental Damage Min (256ths)"
Used by Prayer to calculate life regen.
As per Phrozen Keep:
EMin: and EMax: Controls the basic minimum and maximum elemental damage dealt by this skill at sLvl 1.
EMinLev1: to EMinLev5: and EMaxLevDam1: to EMaxLevDam5: Controls the additional elemental damage added to the skills minimum and maximum damage for every additional sLvl.
LevDam1 is used for sLvls 2-8, LevDam2 for 9-16, LevDam3 for 17-22, LevDam4 for 23-28 and LevDam5 for sLvls above 28.
So, Prayer starts with 2 (col EMin) life points at level 1, increasing by 1 per level at levels 2-8 (EMinLev1), again by 1 at levels 9-16 (EMinLev2) and so on, ending by +3 per each level above 28.
  • edmn - "Elemental Damage"
Used for Energy Shield's' Damage % Absorption (min(edmn,95))
It starts at 20% (EMin), increased by 5 percentage points per skill (EMinLev1), then by 2 p.p. (EMinLev2), then by 1 p.p. (EMinLev3, EMinLev4, EMinLev5), capped at 95%.
Notice that although these functions refer to "Elemental Damage" columns, for those skills these columns just hold the values, used for other properties than damage.
  • edmx - "Elemental Damage Max"
Used by Dire Wolf's max cold damage (in D2R); cols EMax....
  • skill('Skill Name'.fun) - reference to another skill - used for synergies:
    Examples:
  • skill('Golem Mastery'.ln12) - ln12 formula calculated using Golem Mastery params.
  • skill('Summon Fenris'.par2) - Param2 of Summon Fenris (i.e. Summon Dire Wolf).
  • skill('Summon Fenris'.lvl) - (soft) skill level of Summon Fenris.
  • stat('stat_name'.fun) - used mostly to make pets inherit character stats
    Examples:

2. Calculation

Examples:

Skill Name Column
description if present
Formula Params
(besides lvl)
Simplified Formula Values
(sample)
Jab calc1 : Damage % ln34 Param3=-15, Param4=3 3*lvl - 18
Level Value
1 -15
2 -12
3 -9
Battle Cry auralencalc ln12 Param1=300, Param2=75
Since this is duration, we convert it to seconds by dividing by 25 and allowing fractional values.
12*lvl/5 + 48/5
or
2.4*lvl + 9.6
Level Value
1 12.0
2 14.4
3 16.8
Clay Golem aurastatcalc1 : item_slow dm34 Param3=0, Param4=75 165*lvl/(2*lvl + 12)
Level Value
1 11
2 20
3 27
Blaze auralencalc dm12 Param3=50, Param4=500
Since this is duration, we convert it to seconds by dividing by 25 and allowing fractional values.
(109*lvl + 60)/(5*lvl + 30)
Level Value
1 4.8
2 6.92
3 8.6
Chain Lightning calc1 : # of target jump hits ln34/par5 Param3=26, Param4=1, Param5=5 lvl/5 + 5
Level Value
1 5
2 5
5 6
25 10
Strafe aurastatcalc1 : Minimum # of Missiles created min(par3 + lvl - 1, par4) Param3=0, Param4=75 Min(10, lvl + 3)
Level Value
1 4
2 5
7 10
Raise Skeleton petmax (lvl < 4) ?lvl:(2+lvl/3) (none) Piecewise((lvl, lvl < 4), (lvl/3 + 2, True))
Level Value
1 1
2 2
3 3
4 3
Prayer aurastatcalc1 : hitpoints edns EMin...: 2, 1, 1, 2, 2, 3 Piecewise((lvl + 1, (lvl <= 8) | (lvl <= 16)), (2*lvl - 15, (lvl <= 22) | (lvl <= 28)), (3*lvl - 43, True))
Level Value
1 2
2 3
3 4
60 137
Energy Shield aurastatcalc1 : Damage % Absorption min(edmn,95) EMin...: 20, 5, 2, 1, 1, 1 Min(95, Piecewise((5*lvl + 15, lvl <= 8), (2*lvl + 39, lvl <= 16), (lvl + 55, True)))
Level Value
1 20
2 25
3 30
10 59
40 95


The following Python script can be used to simplify the formula and calculate values per level.


#coding: utf-8
import math
from fractions import Fraction

import sympy

FRAMES_PER_SECOND:int = 25


lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev, lev1, lev2, lev3, lev4, lev5 = sympy.symbols('lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev, lev1, lev2, lev3, lev4, lev5', integer=True)



# the formulae are the same per category, but for ease of use they are defined with names from skillcalc.txt
ln12: sympy.core.expr.Expr = par1 + (lvl - 1) * par2
ln34: sympy.core.expr.Expr = par3 + (lvl - 1) * par4
ln56: sympy.core.expr.Expr = par5 + (lvl - 1) * par6
ln78: sympy.core.expr.Expr = par7 + (lvl - 1) * par8

# dm12: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par2 - par1)) / (100 * (lvl + 6))) + par1 #floor because of the integer division
# dm34: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par4 - par3)) / (100 * (lvl + 6))) + par3
# dm56: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par6 - par5)) / (100 * (lvl + 6))) + par5
# dm78: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par8 - par7)) / (100 * (lvl + 6))) + par7

# A formula different than in skillcalc.txt, but the results match those in the game (credit: Onderduiker).
dm12 = sympy.Min(par1 + sympy.floor((par2 - par1) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par2)
dm34 = sympy.Min(par3 + sympy.floor((par4 - par3) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par4)
dm56 = sympy.Min(par5 + sympy.floor((par6 - par5) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par6)
dm78 = sympy.Min(par7 + sympy.floor((par8 - par7) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par8)


levelwise1: sympy.core.expr.Expr = sympy.Piecewise(
		(base,                                                                                         lvl == 1),
		(base + lev * (lvl-1),                                                                         True)
	)

levelwise3: sympy.core.expr.Expr = sympy.Piecewise(
		(base,                                                                                         lvl == 1),
		(base + lev1 * (lvl-1),                                                                        lvl <= 8),
		(base + lev1 * 7       + lev2 * (lvl-8),                                                       lvl <= 16),
		(base + lev1 * 7       + lev2 * 8       + lev3 * (lvl-16),                                     True)
	)

levelwise5: sympy.core.expr.Expr= sympy.Piecewise(
		(base,                                                                                         lvl == 1),
		(base + lev1 * (lvl-1),                                                                        lvl <= 8),
		(base + lev1 * 7       + lev2 * (lvl-8),                                                       lvl <= 16),
		(base + lev1 * 7       + lev2 * 8       + lev3 * (lvl-16),                                     lvl <= 22),
		(base + lev1 * 7       + lev2 * 8       + lev3 * 6        + lev4 * (lvl-22),                   lvl <= 28),
		(base + lev1 * 7       + lev2 * 8       + lev3 * 6        + lev4 * 6        + lev5 * (lvl-28), True)
	)

def make_formula(formula:str) -> sympy.core.expr.Expr:
	FORMULA_PARTS: dict[str, sympy.core.expr.Expr] = {
		'lvl' : lvl,
		'par1' : par1,
		'par2' : par2,
		'par3' : par3,
		'par4' : par4,
		'par5' : par5,
		'par6' : par6,
		'par7' : par7,
		'par8' : par8,
		'base' : base,
		'lev'  : lev,
		'lev1' : lev1,
		'lev2' : lev2,
		'lev3' : lev3,
		'lev4' : lev4,
		'lev5' : lev5,
		'ln12' : ln12,
		'ln34' : ln34,
		'ln56' : ln56,
		'ln78' : ln78,
		'dm12' : dm12,
		'dm34' : dm34,
		'dm56' : dm56,
		'dm78' : dm78,
		'levelwise3': levelwise3,
		'levelwise5': levelwise5,
	}
	return sympy.sympify(formula, FORMULA_PARTS)

def _levelwise_params(params:tuple[sympy.core.symbol.Symbol], values:str) -> dict[sympy.core.symbol.Symbol, int]:
	"""
	Builds parameters for levelwise formula from the values directly copied from data file.
	The same number of values as params are expected, separated with a tab.
	"""
	return {params[i]: int(val) for i, val in enumerate(values.split('\t'))}

def levelwise1_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
	"""
	Builds parameters for levelwise formula from the values directly copied from data file.
	2 values are expected: base, lev, separated with a tab.
	Used for columns such as [Range, LevRange] or [ToHit, LevToHit].
	"""
	return _levelwise_params((base, lev), values)

def levelwise3_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
	"""
	Builds parameters for levelwise formula from the values directly copied from data file.
	4 values are expected: base, lev1, lev2, lev3, separated with a tab.
	Used for columns such as [ELen, ELevLen1, ELevLen2, ELevLen3].
	"""
	return _levelwise_params((base, lev1, lev2, lev3), values)

def levelwise5_params(values:str) -> dict[sympy.core.symbol.Symbol, int]:
	"""
	Builds parameters for levelwise formula from the values directly copied from data file.
	6 values are expected: base, lev1, lev2, lev3, lev5, lev5, separated with a tab.
	Used for columns such as [EMin, MinELev1, MinELev2, MinELev3, MinELev4, MinELev5].
	"""
	return _levelwise_params((base, lev1, lev2, lev3, lev4, lev5), values)

class Stat:
	def __init__(self, desc:str, formula, params:dict|None=None, hitshift:int=8, rational:bool=False, places_int:int=1, places_frac:int=0, pad_sign:bool=False, seconds:bool=False) -> None:
		if params is None:
			params = {}
		self.desc = desc
		self.params = params
		self.rational = rational
		self.places_int  = min(places_int, 1)
		self.places_frac = places_frac
		self.pad_sign = pad_sign
		self.formula = self.make_formula(formula, hitshift, seconds)
		if isinstance(self.formula, sympy.core.expr.Expr):
			print(self.desc)
			self.print_expr(seconds)
	
	@classmethod
	def make_formula(cls, formula, hitshift:int=8, seconds:bool=False):
		if isinstance(formula, (str, int, float, Fraction)):
			formula = make_formula(formula)
		
		factor = sympy.Pow(2, hitshift-8)
		formula = formula * factor
		
		if seconds:
			if isinstance(formula, sympy.core.expr.Expr):
				formula = formula / FRAMES_PER_SECOND
		return formula
	
	
	@classmethod
	def round(cls, val: sympy.core.numbers.Number) -> str:
		if not val.is_integer:
			val = float(val)
		return str(val)
	
	@classmethod
	def do_format(cls, val:sympy.core.numbers.Rational, rational:bool=False, places_int:int=0, places_frac:int=0, pad_sign:bool=False) -> str:
		'''
		rational - whether to use rational or decimal fractions
		places_int - number of places to be padded on the left side of an integer part
		places_frac - number of places to be padded on the right side of a decimal part (in case of decimal numbers)
		or on the left side of numberator and denominator (in case of rational numbers)
		pad_sign - whether to pad the nonnegative numbers to be aligned with negative ones, prepended by a minus sign
		
		'''
		def pad_with_text(s:str) -> str:
			return f'{{{{0|{s}}}}}' if s else ''
		
		def pad_with_zeroes(places:int) -> str:
			return pad_with_text('0' * places)
		
		def pad_int(val: sympy.core.numbers.Integer, places:int, hide_zero:bool=False) -> str:
			if not val.is_nonnegative or not val.is_integer:
				raise ValueError(f'Number must be a nonnegative integer (got {val})')
			
			places = max(places - len(str(val)), 0)
			if hide_zero and val.is_zero:
				val = ''
				places += 1
			return f'{pad_with_zeroes(places)}{val}'
		
		def pad_frac_irrational(val:sympy.core.numbers.Number, places:int) -> str:
			if not val.is_nonnegative or val >= 1:
				raise ValueError(f'Number must be a nonnegative proper fraction (got {val})')
			
			if val.is_integer:
				if not places:
					return ''
				frac_part = '0' * places
				return pad_with_text(f'.{frac_part}')
			
			frac_part = val - int(val)
			frac_part = str(float(frac_part))[2:] #without '0.'
			places = max(places - len(frac_part), 0)
			return f'.{frac_part}{pad_with_zeroes(places)}'
			
		
		def pad_frac_rational(val:sympy.core.numbers.Number, places:int) -> str:
			if not val.is_nonnegative or val >= 1:
				raise ValueError('fNumber must be a nonnegative proper fraction (got {val})')
			
			if val.is_integer:
				if not places:
					return ''
				num_part = den_part = '0' * places
				frac_part = f'{num_part}/{den_part}'
				return f' {pad_with_text(frac_part)}'
			
			frac_part = val - int(val)
			num, den = frac_part.as_numer_denom()
			return f' {pad_int(num, places)}/{pad_int(den, places)}'
		
		
		pad_sign = '-' if val.is_negative else '{{0|-}}' if pad_sign else ''
		absval = abs(val)
		
		absval_int  = sympy.Integer(absval)
		absval_frac = absval - absval_int
		
		int_part = pad_int(val=absval_int, places=places_int, hide_zero=rational and val.is_nonzero)
		
		pad_frac = pad_frac_rational if rational else pad_frac_irrational
		frac_part = pad_frac(val=absval_frac, places=places_frac)
		
		# return f'{pad_sign} ## {int_part} ## {frac_part}'
		return f'{pad_sign}{int_part}{frac_part}'
	
	def calc(self, slvl:int) -> int|float|str:
		val = self.formula.subs({lvl: slvl} | self.params)
		return self.do_format(val, self.rational, self.places_int, self.places_frac, self.pad_sign)

	
	def print_expr(self, seconds:bool=False) -> None:
		expr: sympy.core.expr.Expr = self.formula.subs(self.params)
		expr = expr.simplify()
		print(expr)
		if seconds:
			print(expr.evalf())


def build_table(stats, maxlvl:int=60, splitlvl:int=10):
	'''
	Builds a wiki table of given stats for given level range.
	'''
	
	return '\n\n'.join(
		f'''{{{{Levels {lvl_group+1}-{lvl_group+splitlvl}}}}}
{'\n'.join(f'|-\n!align=left| {stat.desc}\n| {" || ".join(str(stat.calc(lvl_group+lvl_unit)) for lvl_unit in range(1, splitlvl+1))}' for stat in stats)}
|}}''' for lvl_group in range(0, maxlvl, splitlvl)
	)


"""
USAGE
Make a list of stats to be included in the table of values (levels 1-60).
Formulae can be defined in two ways:
1. SymPy: use SymPy expressions - either defined here (fun) or other expressions, using appropriate SymPy functions (for exampe sympy.Min instead of min).
1. Formula as string directly from the data file. Allowed formula parts: lvl, parameters (parX), functions (lnXY and dmXY) and some math functions (min, max). Remove synergies to calculate values without them.
See the examples below. Use only one formula per stat; the repeated stats below are to show how to define formulae.

For level-based values, use "levelwise" formulae, either for 3 or 5 columns.
To convert time values from frames to seconds, set the "seconds" flag.

Simplified formulae are also printed.
For time values formulae with integer and decimal coefficients are printed.

If th


stats: tuple[Stat] = (
	Stat(desc='Inner Sight Duration (Seconds)',  formula=ln34,                                                             params={par3: 300, par4: 150}, seconds=True),
	Stat(desc='Inner Sight Duration (Seconds)',  formula='ln34',                                                                 params={par3: 300, par4: 150}, seconds=True),
	Stat(desc='Slow Missiles [[Missile]] Speed %', formula=                      100-dm12,                                 params={par1: 25,  par2: 75}),
	Stat(desc='Slow Missiles [[Missile]] Speed %', formula=                     '100-dm12',                                      params={par1: 25,  par2: 75}),
	Stat(desc='Impale Durability loss chance %', formula=                           par6-dm34,                             params={par3: 0,   par4: 30,  par6: 50}),
	Stat(desc='Impale Durability loss chance %', formula=                          'par6-dm34',                                  params={par3: 0,   par4: 30,  par6: 50}),
	Stat(desc='Thunderstorm perdelay', formula=                                (100-dm56) * par4/100 + par3,               params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
	Stat(desc='Thunderstorm perdelay', formula=                               '(100-dm56) * par4/100 + par3',                    params={par3: 25,  par4: 100, par5: 0, par6: 100}, seconds=True),
	Stat(desc='Battle Cry duration (Seconds)', formula= ln12,                                                              params={par1: 300, par2: 60}, seconds=True),
	Stat(desc='Battle Cry duration (Seconds)', formula='ln12',                                                                   params={par1: 300, par2: 60}, seconds=True),
	Stat(desc='Chain Lightning hits', formula=                              ln34               / par5,                     params={par3: 26,  par4: 1, par5:5}),
	Stat(desc='Chain Lightning hits', formula=                             'ln34/par5',                                          params={par3: 26,  par4: 1, par5:5}),
	Stat(desc='Strafe min hits', formula=                  sympy.Min(par3 + lvl - 1, par4),                                      params={par3: 4,  par4: 10}),
	Stat(desc='Strafe min hits', formula=                       'min(par3 + lvl - 1, par4)',                                     params={par3: 4,  par4: 10}),
	Stat(desc='Prayer healing', formula=levelwise5,                                                                        params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
	Stat(desc='Prayer healing', formula='levelwise5',                                                                            params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}),
	Stat(desc='Energy shield absorption',
		formula=sympy.Min(levelwise5, 95),
		params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
	),
	Stat(desc='Energy shield absorption',
		formula='min(levelwise5, 95)',
		params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1}
	),
	Stat(desc='Spirit Wolf coldlength', formula=levelwise3,     params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
	Stat(desc='Spirit Wolf coldlength', formula='levelwise3',         params={base: 25,  lev1: 25, lev2: 25, lev3: 25}, seconds=True),
	Stat(desc='Chilling Armor cold length', formula=levelwise3, params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
	Stat(desc='Chilling Armor cold length', formula='levelwise3',     params={base: 100, lev1: 0,  lev2: 25, lev3: 25}, seconds=True),
	Stat(desc='Clay Golem slow', formula=dm34,       params={par3: 0, par4: 75}),
	Stat(desc='Clay Golem slow', formula='dm34',           params={par3: 0, par4: 75}),
	Stat(desc='Blaze length', formula=dm12,       params={par1: 50, par2: 500}, seconds=True),
	Stat(desc='Blaze length', formula='dm12',           params={par1: 50, par2: 500}, seconds=True),
	
	# advanced example: poison damage (Poison Javelin)
	# duration first (will be used in subsequent formulae)
	Stat(desc='Seconds',                 formula=levelwise3, params=levelwise3_params('75	5	5	5'), seconds=True),
	# total poison damage = poison bit rate (from data file, in 1/256 per frame) * duration (from data file, in frames) / 256 (to converts bits into full points); we round it down to the full point
	Stat(desc='Minimum Poison Damage',   formula=sympy.floor(levelwise5.subs(levelwise5_params('196	64	128	208	440	640')) * levelwise3.subs(levelwise3_params('75	5	5	5')) / 256)),
	# poison DPS = (poison bit rate * duration / 256) / duration * 25 (to calculate per second, not per frame)
	# = poison bit rate * 25 / 256; we round it down to the full point
	Stat(desc='Minimum Poison Damage/s', formula=sympy.floor(levelwise5.subs(levelwise5_params('196	64	128	208	440	640')) * sympy.Rational(25, 256))),
	#similar calculations for maximum damage
)
print(build_table(stats))

stats = (
	#build your own table
)
#print(build_table(stats))