Files
healer-man/src/game/romNativeStats.ts
2026-10-04 13:13:35 -04:00

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 } : {}),
});
}