forked from phenom/healer-man
353 lines
17 KiB
TypeScript
353 lines
17 KiB
TypeScript
import { isRomClassId, type GenderId, type RomClassId, type RomRaceId } from "../app/characterCatalog";
|
|
import generatedRules from "./generated/romNativeStatRules.generated.json";
|
|
import type { RomHeirloomReferenceGearStats } from "./romHeirloomReference";
|
|
import type {
|
|
RomNativeAdditiveChannel,
|
|
RomNativeAttributeKey,
|
|
RomNativeDerivedStats,
|
|
RomNativeDerivationInput,
|
|
RomNativeGearStats,
|
|
RomNativeRateChannel,
|
|
RomNativeSourceInputs,
|
|
RomNativeStatModifiers,
|
|
} from "./romNativeStatsTypes";
|
|
|
|
export type {
|
|
RomNativeAdditiveChannel,
|
|
RomNativeAttributeKey,
|
|
RomNativeDerivedStats,
|
|
RomNativeDerivationInput,
|
|
RomNativeGearStats,
|
|
RomNativeRateChannel,
|
|
RomNativeSourceInputs,
|
|
RomNativeStatModifiers,
|
|
} from "./romNativeStatsTypes";
|
|
export {
|
|
normalizeRomNativeGearStats,
|
|
romNativeGearFromSharedDirectStats,
|
|
sumRomNativeGearStats,
|
|
} from "./romNativeStatsTypes";
|
|
|
|
interface GrowthRow { readonly levelOne: number; readonly flatGrowth: number; readonly percentGrowth: number }
|
|
interface NativeRuleVariant {
|
|
readonly growth: Readonly<Record<RomNativeAttributeKey | "health" | "mana", GrowthRow>>;
|
|
readonly coefficients: Readonly<Record<string, number>>;
|
|
}
|
|
type NativeRaceVariants = Readonly<Record<RomRaceId, Readonly<Record<GenderId, NativeRuleVariant>>>>;
|
|
interface NativeRuleData {
|
|
readonly variants: Readonly<Record<RomClassId, NativeRaceVariants>>;
|
|
readonly playerFix: Readonly<Record<string, number>>;
|
|
readonly manaClassIds: readonly RomClassId[];
|
|
}
|
|
|
|
const rules = generatedRules as unknown as NativeRuleData;
|
|
const ATTRIBUTE_KEYS: readonly RomNativeAttributeKey[] = ["strength", "stamina", "intellect", "wisdom", "agility"];
|
|
const RATE_KEYS: readonly RomNativeRateChannel[] = [
|
|
"health", "mana", "strength", "stamina", "intellect", "wisdom", "agility", "allAttributes",
|
|
"physicalAttack", "physicalDefense", "magicalDefense", "magicalAttack", "physicalDamage", "magicalDamage",
|
|
"magicPower", "healPower",
|
|
];
|
|
const ADDITIVE_KEYS: readonly RomNativeAdditiveChannel[] = [
|
|
"health", "mana", "healthRecovery", "manaRecovery",
|
|
"physicalAttack", "physicalDefense", "magicalDefense", "magicalAttack",
|
|
"physicalHit", "magicalHit", "physicalDodge", "magicalDodge", "parry", "physicalCritical", "magicalCritical",
|
|
"physicalDamage", "magicalDamage", "magicalDamagePoint", "healPoint", "healAbsorbRate", "healAbsorb",
|
|
];
|
|
const CHANNEL_TO_GEAR_KEY: Readonly<Record<RomNativeAdditiveChannel, keyof RomNativeGearStats>> = {
|
|
health: "health", mana: "mana", healthRecovery: "healthRecovery", manaRecovery: "manaRecovery",
|
|
physicalAttack: "physicalAttack", physicalDefense: "physicalDefense",
|
|
magicalDefense: "magicalDefense", magicalAttack: "magicalAttack", physicalHit: "physicalHit", magicalHit: "magicalHit",
|
|
physicalDodge: "physicalDodge", magicalDodge: "magicalDodge", parry: "parry", physicalCritical: "physicalCritical",
|
|
magicalCritical: "magicalCritical", physicalDamage: "physicalDamage", magicalDamage: "magicalDamage",
|
|
magicalDamagePoint: "magicalDamagePoint", healPoint: "healPoint", healAbsorbRate: "healAbsorbRate", healAbsorb: "healAbsorb",
|
|
};
|
|
const GEAR_RATE_KEY: Readonly<Record<RomNativeRateChannel, keyof RomNativeGearStats>> = {
|
|
health: "healthRateFactor", mana: "manaRateFactor", strength: "strengthRateFactor", stamina: "staminaRateFactor",
|
|
intellect: "intellectRateFactor", wisdom: "wisdomRateFactor", agility: "agilityRateFactor",
|
|
allAttributes: "allAttributesRateFactor", physicalAttack: "physicalAttackRateFactor",
|
|
physicalDefense: "physicalDefenseRateFactor", magicalDefense: "magicalDefenseRateFactor",
|
|
magicalAttack: "magicalAttackRateFactor", physicalDamage: "physicalDamageRateFactor",
|
|
magicalDamage: "magicalDamageRateFactor", magicPower: "magicPowerRateFactor", healPower: "healPowerRateFactor",
|
|
};
|
|
const ATTRIBUTE_BASE_FIELDS: Readonly<Record<RomNativeAttributeKey, "strength" | "stamina" | "intellect" | "wisdom" | "agility">> = {
|
|
strength: "strength", stamina: "stamina", intellect: "intellect", wisdom: "wisdom", agility: "agility",
|
|
};
|
|
const NEUTRAL_MODIFIERS: RomNativeStatModifiers = Object.freeze({ attributes: {}, channels: {}, rates: {} });
|
|
|
|
function finite(value: unknown, fallback = 0): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function factor(value: unknown): number {
|
|
return Math.max(0, finite(value, 1));
|
|
}
|
|
|
|
function clampLevel(level: number): number {
|
|
return Math.max(1, Math.min(80, Math.trunc(Number.isFinite(level) ? level : 1)));
|
|
}
|
|
|
|
function growthValue(row: GrowthRow, level: number): number {
|
|
let value = row.levelOne;
|
|
for (let current = 2; current <= level; current += 1) value = value * (1 + row.percentGrowth) + row.flatGrowth;
|
|
return value;
|
|
}
|
|
|
|
function normalizeGear(gear: RomNativeGearStats | null | undefined): RomNativeGearStats {
|
|
const normalized: Record<string, number> = {};
|
|
if (gear) {
|
|
for (const key of [...ATTRIBUTE_KEYS, ...ADDITIVE_KEYS, "healthRecovery", "manaRecovery"] as const) {
|
|
const value = finite(gear[key]);
|
|
if (value !== 0) normalized[key] = value;
|
|
}
|
|
for (const key of RATE_KEYS) {
|
|
const value = factor(gear[GEAR_RATE_KEY[key]]);
|
|
if (value !== 1) normalized[GEAR_RATE_KEY[key]] = value;
|
|
}
|
|
}
|
|
return Object.freeze(normalized) as RomNativeGearStats;
|
|
}
|
|
|
|
function normalizeInput(input: RomNativeDerivationInput): RomNativeSourceInputs {
|
|
const level = clampLevel(input.level);
|
|
const secondaryClassId = input.secondaryClassId && isRomClassId(input.secondaryClassId)
|
|
? input.secondaryClassId
|
|
: null;
|
|
const secondaryLevel = secondaryClassId
|
|
? clampLevel(input.secondaryLevel ?? level)
|
|
: null;
|
|
return Object.freeze({
|
|
classId: input.classId,
|
|
level,
|
|
raceId: input.raceId ?? "rom-human",
|
|
gender: input.gender ?? "male",
|
|
secondaryClassId,
|
|
secondaryLevel,
|
|
secondaryLevelPolicy: secondaryClassId
|
|
? input.secondaryLevel === undefined ? "primary-level-fallback" : "explicit"
|
|
: "none",
|
|
gear: normalizeGear(input.gear),
|
|
});
|
|
}
|
|
|
|
function normalizedModifiers(value?: RomNativeStatModifiers): RomNativeStatModifiers {
|
|
const attributes: Partial<Record<RomNativeAttributeKey, number>> = {};
|
|
const channels: Partial<Record<RomNativeAdditiveChannel, number>> = {};
|
|
const rates: Partial<Record<RomNativeRateChannel, number>> = {};
|
|
for (const key of ATTRIBUTE_KEYS) {
|
|
const amount = finite(value?.attributes?.[key]);
|
|
if (amount) attributes[key] = amount;
|
|
}
|
|
for (const key of ADDITIVE_KEYS) {
|
|
const amount = finite(value?.channels?.[key]);
|
|
if (amount) channels[key] = amount;
|
|
}
|
|
for (const key of RATE_KEYS) {
|
|
const multiplier = factor(value?.rates?.[key]);
|
|
if (multiplier !== 1) rates[key] = multiplier;
|
|
}
|
|
return Object.freeze({
|
|
attributes: Object.freeze(attributes), channels: Object.freeze(channels), rates: Object.freeze(rates),
|
|
});
|
|
}
|
|
|
|
function mergeModifiers(previous: RomNativeStatModifiers, nextValue: RomNativeStatModifiers): RomNativeStatModifiers {
|
|
const next = normalizedModifiers(nextValue);
|
|
const attributes: Partial<Record<RomNativeAttributeKey, number>> = {};
|
|
const channels: Partial<Record<RomNativeAdditiveChannel, number>> = {};
|
|
const rates: Partial<Record<RomNativeRateChannel, number>> = {};
|
|
for (const key of ATTRIBUTE_KEYS) {
|
|
const total = finite(previous.attributes?.[key]) + finite(next.attributes?.[key]);
|
|
if (total) attributes[key] = total;
|
|
}
|
|
for (const key of ADDITIVE_KEYS) {
|
|
const total = finite(previous.channels?.[key]) + finite(next.channels?.[key]);
|
|
if (total) channels[key] = total;
|
|
}
|
|
for (const key of RATE_KEYS) {
|
|
const total = factor(previous.rates?.[key]) * factor(next.rates?.[key]);
|
|
if (total !== 1) rates[key] = total;
|
|
}
|
|
return Object.freeze({
|
|
attributes: Object.freeze(attributes), channels: Object.freeze(channels), rates: Object.freeze(rates),
|
|
});
|
|
}
|
|
|
|
function sumChannel(
|
|
gear: RomNativeGearStats,
|
|
modifiers: RomNativeStatModifiers,
|
|
key: RomNativeAdditiveChannel,
|
|
): number {
|
|
return finite(gear[CHANNEL_TO_GEAR_KEY[key]]) + finite(modifiers.channels?.[key]);
|
|
}
|
|
|
|
function derivedRateFactors(gear: RomNativeGearStats, modifiers: RomNativeStatModifiers): Readonly<Record<RomNativeRateChannel, number>> {
|
|
return Object.freeze(Object.fromEntries(RATE_KEYS.map((key) => [
|
|
key,
|
|
factor(gear[GEAR_RATE_KEY[key]]) * factor(modifiers.rates?.[key]),
|
|
])) as Record<RomNativeRateChannel, number>);
|
|
}
|
|
|
|
function applyNativeDerivation(sourceInputs: RomNativeSourceInputs, modifiers: RomNativeStatModifiers): RomNativeDerivedStats {
|
|
const variant = rules.variants[sourceInputs.classId][sourceInputs.raceId][sourceInputs.gender];
|
|
const secondary = sourceInputs.secondaryClassId
|
|
? rules.variants[sourceInputs.secondaryClassId][sourceInputs.raceId][sourceInputs.gender]
|
|
: null;
|
|
const secondaryBaseLevel = sourceInputs.secondaryLevel ?? 1;
|
|
const base = Object.fromEntries(Object.entries(variant.growth).map(([key, row]) => [
|
|
key,
|
|
growthValue(row, sourceInputs.level)
|
|
+ (secondary && key in secondary.growth ? growthValue(secondary.growth[key as keyof typeof secondary.growth], secondaryBaseLevel) / 10 : 0),
|
|
])) as Record<RomNativeAttributeKey | "health" | "mana", number>;
|
|
const rates = derivedRateFactors(sourceInputs.gear, modifiers);
|
|
const attributes = Object.freeze(Object.fromEntries(ATTRIBUTE_KEYS.map((key) => {
|
|
const flat = base[key]
|
|
+ finite(sourceInputs.gear[ATTRIBUTE_BASE_FIELDS[key]])
|
|
+ finite(modifiers.attributes?.[key]);
|
|
return [key, Math.max(0, flat * rates[key] * rates.allAttributes)];
|
|
})) as Record<RomNativeAttributeKey, number>);
|
|
const c = variant.coefficients;
|
|
const fix = rules.playerFix;
|
|
const damageRate = Math.max(0, finite(c.damageRate, 1));
|
|
const usesMana = rules.manaClassIds.includes(sourceInputs.classId)
|
|
|| Boolean(sourceInputs.secondaryClassId && rules.manaClassIds.includes(sourceInputs.secondaryClassId));
|
|
const maxHealth = Math.max(1, (
|
|
base.health
|
|
+ attributes.strength * finite(c.hpStrength)
|
|
+ attributes.stamina * finite(c.hpStamina)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "health")
|
|
) * rates.health);
|
|
const maxMana = usesMana ? Math.max(0, (
|
|
base.mana
|
|
+ attributes.intellect * finite(c.mpIntellect)
|
|
+ attributes.wisdom * finite(c.mpWisdom)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "mana")
|
|
) * rates.mana) : 0;
|
|
const physicalAttack = Math.max(1, (
|
|
attributes.strength * finite(c.attackStrength)
|
|
+ attributes.intellect * finite(c.attackIntellect)
|
|
+ attributes.agility * finite(c.attackAgility)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "physicalAttack")
|
|
) * rates.physicalAttack);
|
|
const physicalDefense = Math.max(1, (
|
|
attributes.stamina * finite(c.defenseStamina)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "physicalDefense")
|
|
) * rates.physicalDefense);
|
|
const magicalDefense = Math.max(1, (
|
|
attributes.wisdom * finite(c.magicalDefenseWisdom)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "magicalDefense")
|
|
) * rates.magicalDefense);
|
|
const magicalAttack = Math.max(1, (
|
|
attributes.intellect * finite(c.magicalAttackIntellect)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "magicalAttack")
|
|
) * rates.magicalAttack);
|
|
const damageAttackCoefficient = finite(fix.damageAttackCoefficient);
|
|
const magicalDamageAttackCoefficient = finite(fix.magicalDamageAttackCoefficient);
|
|
const physicalDamage = Math.max(0, (
|
|
(damageAttackCoefficient !== 0 ? physicalAttack / damageAttackCoefficient : 0)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "physicalDamage")
|
|
) * damageRate * rates.physicalDamage);
|
|
const magicalDamage = Math.max(0, (
|
|
(magicalDamageAttackCoefficient !== 0 ? magicalAttack / magicalDamageAttackCoefficient : 0)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "magicalDamage")
|
|
) * rates.magicalDamage);
|
|
const physicalCritical = Math.max(1,
|
|
finite(sourceInputs.gear.physicalCritical) + finite(modifiers.channels?.physicalCritical) + finite(fix.basePhysicalCritical));
|
|
const magicalCritical = Math.max(0.5,
|
|
finite(sourceInputs.gear.magicalCritical) + finite(modifiers.channels?.magicalCritical) + finite(fix.baseMagicalCritical));
|
|
const physicalHit = sumChannel(sourceInputs.gear, modifiers, "physicalHit")
|
|
+ finite(fix.physicalHitAgility) * attributes.agility;
|
|
const magicalHit = sumChannel(sourceInputs.gear, modifiers, "magicalHit")
|
|
+ finite(fix.magicalHitWisdom) * attributes.wisdom;
|
|
const physicalDodge = sumChannel(sourceInputs.gear, modifiers, "physicalDodge")
|
|
+ finite(fix.physicalDodgeAgility) * attributes.agility;
|
|
const magicalDodge = sumChannel(sourceInputs.gear, modifiers, "magicalDodge")
|
|
+ finite(fix.magicalDodgeWisdom) * attributes.wisdom
|
|
+ finite(fix.magicalDodgeAgility) * attributes.agility;
|
|
const physicalDamagePoint = sumChannel(sourceInputs.gear, modifiers, "magicalDamagePoint");
|
|
const healPowerRate = rates.healPower;
|
|
const healPoint = sumChannel(sourceInputs.gear, modifiers, "healPoint");
|
|
return Object.freeze({
|
|
attributes,
|
|
rateFactors: rates,
|
|
health: Object.freeze({ max: maxHealth }),
|
|
mana: Object.freeze({ max: maxMana }),
|
|
usesMana,
|
|
physicalAttack,
|
|
physicalDamage,
|
|
physicalDefense,
|
|
magicalDefense,
|
|
magicalAttack,
|
|
magicalDamage,
|
|
magicalDamagePoint: physicalDamagePoint,
|
|
magicPowerRate: rates.magicPower,
|
|
healing: Object.freeze({
|
|
healPowerRate,
|
|
healPoint,
|
|
holyHealMindCoefficient: 0.3,
|
|
holyHealMindCapMultiplier: 6,
|
|
holyHealMagicDamageCoefficient: finite(fix.healingMagicalDamageCoefficient),
|
|
targetHealAbsorbRate: sumChannel(sourceInputs.gear, modifiers, "healAbsorbRate"),
|
|
targetHealAbsorb: sumChannel(sourceInputs.gear, modifiers, "healAbsorb"),
|
|
skillFormulaComplete: false,
|
|
}),
|
|
physicalHit,
|
|
magicalHit,
|
|
physicalDodge,
|
|
magicalDodge,
|
|
physicalCritical,
|
|
magicalCritical,
|
|
parry: Math.max(0, sumChannel(sourceInputs.gear, modifiers, "parry")),
|
|
healthRecovery: Math.max(0,
|
|
finite(c.baseHealthRecovery) + attributes.stamina * finite(c.healthRecoveryStamina)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "healthRecovery")),
|
|
manaRecovery: Math.max(0,
|
|
finite(c.baseManaRecovery) + attributes.wisdom * finite(c.manaRecoveryWisdom)
|
|
+ sumChannel(sourceInputs.gear, modifiers, "manaRecovery")),
|
|
sourceInputs,
|
|
modifiers,
|
|
semantics: "runewaker-player-source-attributes-and-derivations",
|
|
});
|
|
}
|
|
|
|
/** Reproduces player base and primary native derived formulas from generated Runewaker table records. */
|
|
export function deriveRomNativeStats(input: RomNativeDerivationInput): RomNativeDerivedStats {
|
|
return applyNativeDerivation(normalizeInput(input), NEUTRAL_MODIFIERS);
|
|
}
|
|
|
|
/** Adds source-unit aura changes, retaining gear BodyRates and recomputing every dependent channel. */
|
|
export function applyRomNativeStatModifiers(
|
|
stats: RomNativeDerivedStats,
|
|
modifiers: RomNativeStatModifiers,
|
|
): RomNativeDerivedStats {
|
|
return applyNativeDerivation(stats.sourceInputs, mergeModifiers(stats.modifiers, modifiers));
|
|
}
|
|
|
|
/** Converts the legacy reference export without conflating type 148 MdmgPoint and type 191 MDMG. */
|
|
export function romNativeGearStatsFromReference(stats: RomHeirloomReferenceGearStats): RomNativeGearStats {
|
|
return Object.freeze({
|
|
...(stats.strength ? { strength: stats.strength } : {}),
|
|
...(stats.stamina ? { stamina: stats.stamina } : {}),
|
|
...(stats.intellect ? { intellect: stats.intellect } : {}),
|
|
...(stats.wisdom ? { wisdom: stats.wisdom } : {}),
|
|
...(stats.agility ? { agility: stats.agility } : {}),
|
|
...(stats.health ? { health: stats.health } : {}),
|
|
...(stats.mana ? { mana: stats.mana } : {}),
|
|
...(stats.healthRecovery ? { healthRecovery: stats.healthRecovery } : {}),
|
|
...(stats.manaRecovery ? { manaRecovery: stats.manaRecovery } : {}),
|
|
...(stats.physicalAttack ? { physicalAttack: stats.physicalAttack } : {}),
|
|
...(stats.physicalDefense ? { physicalDefense: stats.physicalDefense } : {}),
|
|
...(stats.magicalDefense ? { magicalDefense: stats.magicalDefense } : {}),
|
|
...(stats.magicalAttack ? { magicalAttack: stats.magicalAttack } : {}),
|
|
...(stats.physicalHit ? { physicalHit: stats.physicalHit } : {}),
|
|
...(stats.magicalHit ? { magicalHit: stats.magicalHit } : {}),
|
|
...(stats.dodge ? { physicalDodge: stats.dodge } : {}),
|
|
...(stats.parry ? { parry: stats.parry } : {}),
|
|
...(stats.physicalCritical ? { physicalCritical: stats.physicalCritical } : {}),
|
|
...(stats.magicalCritical ? { magicalCritical: stats.magicalCritical } : {}),
|
|
...(stats.weaponDamage ? { physicalDamage: stats.weaponDamage } : {}),
|
|
...(stats.magicalDamage ? { magicalDamagePoint: stats.magicalDamage } : {}),
|
|
...(stats.magicalWeaponDamage ? { magicalDamage: stats.magicalWeaponDamage } : {}),
|
|
...(stats.healingPower ? { healPoint: stats.healingPower } : {}),
|
|
});
|
|
}
|