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>; readonly coefficients: Readonly>; } type NativeRaceVariants = Readonly>>>; interface NativeRuleData { readonly variants: Readonly>; readonly playerFix: Readonly>; 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> = { 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> = { 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> = { 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 = {}; 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> = {}; const channels: Partial> = {}; const rates: Partial> = {}; 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> = {}; const channels: Partial> = {}; const rates: Partial> = {}; 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> { return Object.freeze(Object.fromEntries(RATE_KEYS.map((key) => [ key, factor(gear[GEAR_RATE_KEY[key]]) * factor(modifiers.rates?.[key]), ])) as Record); } 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; 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); 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 } : {}), }); }