From f252fa55661492d170ac1a5b7a62dd9dae0c121b Mon Sep 17 00:00:00 2001 From: GaboGG Date: Mon, 20 Jul 2026 19:44:23 -0400 Subject: [PATCH] Wiki-tuned simulator: real monster formulas from EHWiki - Monster stats: STR/DEX/AGI/END/INT/WIS scaled per wiki formula - HP = (100 + L*10 + END*5) * level_mod * difficulty_mod * class_mod - Base damage from log formula, crit from (1 - 3750/(3750+DEX+STR/2)) - Phys/mag mit, evade, parry all from wiki - Difficulty multipliers from wiki table (exp, credit, hp, dmg) - Player stats tuned to match real Lv52 character (1804 HP, 93 MP) - Added health items to default loadout --- simulator/src/entities.js | 56 +++++++++++++++++++----- simulator/src/formulas.js | 89 +++++++++++++++++++++++++++++--------- simulator/src/grindfest.js | 19 ++++---- 3 files changed, 121 insertions(+), 43 deletions(-) diff --git a/simulator/src/entities.js b/simulator/src/entities.js index 6f245bc..e8fd89d 100644 --- a/simulator/src/entities.js +++ b/simulator/src/entities.js @@ -9,13 +9,13 @@ class SimPlayer { this.style = config.style || '2H'; this.difficulty = config.difficulty || 'Nightmare'; - // Base stats (from a typical Lv52 2H build) + // Base stats matching Lv52 2H character from real game data (HP ~1804) this.str = config.str || 85; this.dex = config.dex || 34; this.agi = config.agi || 24; - this.end = config.end || 60; + this.end = config.end || 258; // ~1804 HP at Lv52 from gear this.int = config.int || 6; - this.wis = config.wis || 15; + this.wis = config.wis || 31; // ~93 MP from gear // Derived stats this.maxHp = F.calcMaxHp(this.end, this.level); @@ -36,8 +36,7 @@ class SimPlayer { // Damage stats this.baseAtk = F.calcBasePhysDamage(this.str, this.dex, this.level, this.prof2h); - this.hitChance = F.calcHitChance(this.dex, this.level, this.level); - this.critChance = F.calcCritChance(this.str, this.dex, this.level); + this.critChance = F.calcCritChance(this.str, this.dex); this.critDamage = F.calcCritDamage(68); // +68% from gear // Mitigation @@ -230,22 +229,55 @@ class Monster { this.name = template.name; this.mid = template.mid; this.boss = template.boss || false; - const plFactor = 1 + (level - 50) / 100; - this.maxHp = Math.round(template.hpBase * plFactor * difficultyMult); + + // Generate base stats for this monster (scaled to player level) + const pl = template.pl || level; + const baseEnd = template.boss ? 200 + Math.round(Math.random() * 100) : 30 + Math.round(Math.random() * 50); + const baseStr = template.boss ? 150 + Math.round(Math.random() * 100) : 20 + Math.round(Math.random() * 40); + const baseDex = template.boss ? 80 + Math.round(Math.random() * 40) : 10 + Math.round(Math.random() * 30); + const baseAgi = template.boss ? 60 + Math.round(Math.random() * 40) : 5 + Math.round(Math.random() * 20); + const baseWis = template.boss ? 60 + Math.round(Math.random() * 40) : 5 + Math.round(Math.random() * 20); + const baseInt = template.boss ? 40 + Math.round(Math.random() * 30) : 5 + Math.round(Math.random() * 15); + + // Scale monster stats to level using wiki formula: int(0.01 * base * level + level^1.076675 * 0.3325) + const scale = (base) => Math.round(0.01 * base * level + Math.pow(level, 1.076675) * 0.3325); + + this.str = scale(baseStr); + this.dex = scale(baseDex); + this.agi = scale(baseAgi); + this.end = scale(baseEnd); + this.int = scale(baseInt); + this.wis = scale(baseWis); + + // HP, MP, SP from wiki formulas + const diffMult = template.boss ? difficultyMult * 2 : difficultyMult; + this.maxHp = F.monsterHp(level, this.end, diffMult, template.classMod || 1); this.hp = this.maxHp; - this.maxMp = Math.round(this.maxHp * 0.15); + this.maxMp = F.monsterMp(level, this.wis); this.mp = this.maxMp; - this.maxSp = this.boss ? Math.round(this.maxHp * 0.3) : 0; + this.maxSp = template.boss ? F.monsterSp(this) : 0; this.sp = this.maxSp; - this.res = { ...template.res }; + + // Derived combat stats + this.baseAtk = F.monsterBasePhysDmg(this.str, this.dex); + this.critChance = F.monsterPhysCrit(this.dex, this.str); + this.physMit = F.monsterPhysMit(this.end, this.agi); + this.magMit = F.monsterMagMit(this.end, this.wis); + this.evade = F.monsterEvade(this.agi, level); + this.parry = F.monsterParry(this.dex, level); + this.alive = true; this.buffs = {}; this.debuffs = {}; } isAlive() { return this.alive && this.hp > 0; } takeDamage(amount, type) { - const resist = this.res[type] || 0; - const effective = Math.round(amount * (1 - resist / 100)); + // Apply hit/evade/parry check + if (type && Math.random() < this.evade) return { dealt: 0, resisted: amount, evaded: true }; + if (type && Math.random() < this.parry) return { dealt: 0, resisted: amount, parried: true }; + + const mit = type === 'fire' || type === 'cold' || type === 'elec' || type === 'wind' || type === 'holy' || type === 'dark' ? this.magMit : this.physMit; + const effective = Math.round(amount * (1 - mit)); this.hp -= effective; if (this.hp <= 0) { this.hp = 0; this.alive = false; } return { dealt: effective, resisted: amount - effective }; diff --git a/simulator/src/formulas.js b/simulator/src/formulas.js index 97ba231..128bc16 100644 --- a/simulator/src/formulas.js +++ b/simulator/src/formulas.js @@ -1,4 +1,55 @@ -// ── All known HV formulas (from EHWiki and forum research) ── +// ── All known HV formulas (from EHWiki) ── + +// ── Monster stats ── + +// HP = (100 + level * 10 + END * 5) * level_mod * difficulty_mod * class_mod +function monsterHp(level, end, difficultyMult, classMod = 1) { + const levelMod = Math.max(1, (level - 100) * 0.01); + return Math.round((100 + level * 10 + end * 5) * levelMod * difficultyMult * classMod); +} + +// MP = 10 + level + WIS +function monsterMp(level, wis) { + return 10 + level + wis; +} + +// SP = 1 + (Power/7) + (STR + DEX + AGI + END + INT + WIS)/5 +function monsterSp(stats) { + const sum = (stats.str || 0) + (stats.dex || 0) + (stats.agi || 0) + (stats.end || 0) + (stats.int || 0) + (stats.wis || 0); + return Math.round(1 + (stats.power || 0) / 7 + sum / 5); +} + +// Base Physical Damage = (log(3330 + STR*2 + DEX, 1.0003) - 27039.81) +function monsterBasePhysDmg(str, dex) { + return Math.max(1, Math.round(Math.log(3330 + str * 2 + dex) / Math.log(1.0003) - 27039.81)); +} + +// Physical Crit = 1 - (3750 / (3750 + DEX + STR/2)) +function monsterPhysCrit(dex, str) { + return Math.max(0, Math.min(1, 1 - (3750 / (3750 + dex + str / 2)))); +} + +// Physical Mitigation = 1 - (1000 / (1000 + END + AGI/2)) +function monsterPhysMit(end, agi) { + return Math.max(0, 1 - (1000 / (1000 + end + agi / 2))); +} + +// Magical Mitigation = 1 - (1000 / (1000 + END + WIS/2)) +function monsterMagMit(end, wis) { + return Math.max(0, 1 - (1000 / (1000 + end + wis / 2))); +} + +// Evade = 1 - (1 - min(10, AGI/100, (AGI-L)/75)/100) +function monsterEvade(agi, level) { + const base = Math.min(10, agi / 100, (agi - level) / 75); + return Math.max(0, Math.min(1, base / 100)); +} + +// Parry = 1 - (1 - min(10, DEX/100, (DEX-L)/75)/100) +function monsterParry(dex, level) { + const base = Math.min(10, dex / 100, (dex - level) / 75); + return Math.max(0, Math.min(1, base / 100)); +} // ── Player stats ── @@ -30,28 +81,21 @@ function calcBasePhysDamage(str, dex, level, weaponProf) { return Math.round(weaponDamage * (1 + profBonus / 100)); } -// ── Hit chance ── - -function calcHitChance(dex, level, monsterPL) { - const base = 80 + dex * 0.5; - const levelPenalty = Math.max(0, monsterPL - level) * 0.5; - return Math.min(95, base - levelPenalty); -} - // ── Crit ── -function calcCritChance(str, dex, level) { - return Math.min(50, 5 + str * 0.5 + dex * 1); // Hard cap 50% +function calcCritChance(str, dex) { + // Player formula from wiki + return Math.min(0.50, 0.05 + str * 0.005 + dex * 0.01); } function calcCritDamage(bonus) { - return 1.5 + bonus / 100; // Base 1.5x, +Heartseeker etc. + return 1.5 + bonus / 100; } // ── Overcharge ── function calcOCPerHit() { - return 5 + Math.random() * 10; // Each basic attack generates 5-15 OC + return 5 + Math.random() * 10; } // ── Proc chances ── @@ -84,20 +128,23 @@ function calcSkillDamage(baseAtk, multiplier, physMit) { // ── Difficulty multipliers ── const DIFFICULTY = { - Normal: { exp: 1, hpMult: 1.0, dropMult: 1.0 }, - Hard: { exp: 2, hpMult: 1.2, dropMult: 1.2 }, - Nightmare: { exp: 4, hpMult: 1.5, dropMult: 1.5 }, - Hell: { exp: 7, hpMult: 1.8, dropMult: 2.0 }, - Nintendo: { exp: 10, hpMult: 2.2, dropMult: 2.5 }, - IWBTH: { exp: 15, hpMult: 2.5, dropMult: 3.0 }, - PFUDOR: { exp: 20, hpMult: 3.0, dropMult: 4.0 }, + Normal: { exp: 1, hpMult: 1.0, dmgMult: 1.0, creditMult: 1.0, crystalDiffMod: 1, crystalGFBonus: 0.5 }, + Hard: { exp: 2, hpMult: 1.1, dmgMult: 1.2, creditMult: 1.25, crystalDiffMod: 1.2, crystalGFBonus: 1 }, + Nightmare: { exp: 4, hpMult: 1.2, dmgMult: 1.4, creditMult: 1.5, crystalDiffMod: 1.4, crystalGFBonus: 1 }, + Hell: { exp: 7, hpMult: 1.4, dmgMult: 1.6, creditMult: 1.75, crystalDiffMod: 1.7, crystalGFBonus: 1.5 }, + Nintendo: { exp: 10, hpMult: 1.7, dmgMult: 2.0, creditMult: 2.2, crystalDiffMod: 2, crystalGFBonus: 1.5 }, + IWBTH: { exp: 15, hpMult: 2.0, dmgMult: 2.5, creditMult: 3, crystalDiffMod: 2.5, crystalGFBonus: 2 }, + PFUDOR: { exp: 20, hpMult: 2.0, dmgMult: 3.0, creditMult: 3, crystalDiffMod: 3, crystalGFBonus: 2 }, }; module.exports = { + monsterHp, monsterMp, monsterSp, + monsterBasePhysDmg, monsterPhysCrit, monsterPhysMit, monsterMagMit, + monsterEvade, monsterParry, calcMaxHp, calcMaxMp, calcMaxSp, calcHpRegen, calcMpRegen, calcBasePhysDamage, - calcHitChance, calcCritChance, calcCritDamage, + calcCritChance, calcCritDamage, calcOCPerHit, dominoStrikeChance, channelingProcChance, calcSpellDamage, calcSkillDamage, diff --git a/simulator/src/grindfest.js b/simulator/src/grindfest.js index 8e49f9e..54fb350 100644 --- a/simulator/src/grindfest.js +++ b/simulator/src/grindfest.js @@ -68,9 +68,10 @@ function simulateGrindfest(playerConfig, strategyConfig, roundCount) { for (const monster of monsters) { if (!monster.isAlive()) continue; if (Math.random() > 0.40) continue; // Not all monsters act each turn - const atk = Math.round(20 + Math.random() * 60 + player.level * 0.3); - const dmg = player.takeDamage(atk, 'physical'); - roundLog.push(`${monster.name} hits you, causing ${dmg} points of damage.`); + const atk = Math.round(monster.baseAtk * (0.8 + Math.random() * 0.4)); // 80-120% of base + const crit = Math.random() < monster.critChance; + const dmg = player.takeDamage(Math.round(crit ? atk * 1.5 : atk), 'physical'); + roundLog.push(`${monster.name}${crit ? ' crits' : ' hits'} you, causing ${dmg} points of damage.`); totalDmgTaken += dmg; } @@ -87,10 +88,10 @@ function simulateGrindfest(playerConfig, strategyConfig, roundCount) { // ── Round end ── const roundKills = monsters.filter(m => !m.isAlive()).length; totalKills += roundKills; - - // EXP and credits - const exp = Math.round(100 * diff.exp * (1 + player.level / 100)); - const credits = Math.round(15 * diff.dropMult + Math.random() * 10); + // EXP and credits (wiki formula) + const diffInfo = F.DIFFICULTY[player.difficulty] || F.DIFFICULTY.Nightmare; + const exp = Math.round(100 * diffInfo.exp * round); + const credits = Math.round(15 * diffInfo.creditMult + Math.random() * 10 * round); totalExp += exp; totalCredits += credits; @@ -157,9 +158,7 @@ function executeAction(player, monsters, action, log) { case 'attack': { const target = action.target || monsters.find(m => m.isAlive()); if (!target) return; - const hit = Math.random() * 100 < player.hitChance; - if (!hit) { log.push(`You miss ${target.name}.`); return; } - const crit = Math.random() * 100 < player.critChance; + const crit = Math.random() < player.critChance; let dmg = player.baseAtk; if (crit) dmg = Math.round(dmg * player.critDamage); const result = target.takeDamage(dmg, 'physical');