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
This commit is contained in:
GaboGG 2026-07-20 19:44:23 -04:00
parent 6c2bb2f8f3
commit f252fa5566
3 changed files with 121 additions and 43 deletions

View file

@ -9,13 +9,13 @@ class SimPlayer {
this.style = config.style || '2H'; this.style = config.style || '2H';
this.difficulty = config.difficulty || 'Nightmare'; 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.str = config.str || 85;
this.dex = config.dex || 34; this.dex = config.dex || 34;
this.agi = config.agi || 24; 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.int = config.int || 6;
this.wis = config.wis || 15; this.wis = config.wis || 31; // ~93 MP from gear
// Derived stats // Derived stats
this.maxHp = F.calcMaxHp(this.end, this.level); this.maxHp = F.calcMaxHp(this.end, this.level);
@ -36,8 +36,7 @@ class SimPlayer {
// Damage stats // Damage stats
this.baseAtk = F.calcBasePhysDamage(this.str, this.dex, this.level, this.prof2h); 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.critChance = F.calcCritChance(this.str, this.dex, this.level);
this.critDamage = F.calcCritDamage(68); // +68% from gear this.critDamage = F.calcCritDamage(68); // +68% from gear
// Mitigation // Mitigation
@ -230,22 +229,55 @@ class Monster {
this.name = template.name; this.name = template.name;
this.mid = template.mid; this.mid = template.mid;
this.boss = template.boss || false; 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.hp = this.maxHp;
this.maxMp = Math.round(this.maxHp * 0.15); this.maxMp = F.monsterMp(level, this.wis);
this.mp = this.maxMp; 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.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.alive = true;
this.buffs = {}; this.buffs = {};
this.debuffs = {}; this.debuffs = {};
} }
isAlive() { return this.alive && this.hp > 0; } isAlive() { return this.alive && this.hp > 0; }
takeDamage(amount, type) { takeDamage(amount, type) {
const resist = this.res[type] || 0; // Apply hit/evade/parry check
const effective = Math.round(amount * (1 - resist / 100)); 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; this.hp -= effective;
if (this.hp <= 0) { this.hp = 0; this.alive = false; } if (this.hp <= 0) { this.hp = 0; this.alive = false; }
return { dealt: effective, resisted: amount - effective }; return { dealt: effective, resisted: amount - effective };

View file

@ -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 ── // ── Player stats ──
@ -30,28 +81,21 @@ function calcBasePhysDamage(str, dex, level, weaponProf) {
return Math.round(weaponDamage * (1 + profBonus / 100)); 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 ── // ── Crit ──
function calcCritChance(str, dex, level) { function calcCritChance(str, dex) {
return Math.min(50, 5 + str * 0.5 + dex * 1); // Hard cap 50% // Player formula from wiki
return Math.min(0.50, 0.05 + str * 0.005 + dex * 0.01);
} }
function calcCritDamage(bonus) { function calcCritDamage(bonus) {
return 1.5 + bonus / 100; // Base 1.5x, +Heartseeker etc. return 1.5 + bonus / 100;
} }
// ── Overcharge ── // ── Overcharge ──
function calcOCPerHit() { function calcOCPerHit() {
return 5 + Math.random() * 10; // Each basic attack generates 5-15 OC return 5 + Math.random() * 10;
} }
// ── Proc chances ── // ── Proc chances ──
@ -84,20 +128,23 @@ function calcSkillDamage(baseAtk, multiplier, physMit) {
// ── Difficulty multipliers ── // ── Difficulty multipliers ──
const DIFFICULTY = { const DIFFICULTY = {
Normal: { exp: 1, hpMult: 1.0, dropMult: 1.0 }, Normal: { exp: 1, hpMult: 1.0, dmgMult: 1.0, creditMult: 1.0, crystalDiffMod: 1, crystalGFBonus: 0.5 },
Hard: { exp: 2, hpMult: 1.2, dropMult: 1.2 }, Hard: { exp: 2, hpMult: 1.1, dmgMult: 1.2, creditMult: 1.25, crystalDiffMod: 1.2, crystalGFBonus: 1 },
Nightmare: { exp: 4, hpMult: 1.5, dropMult: 1.5 }, Nightmare: { exp: 4, hpMult: 1.2, dmgMult: 1.4, creditMult: 1.5, crystalDiffMod: 1.4, crystalGFBonus: 1 },
Hell: { exp: 7, hpMult: 1.8, dropMult: 2.0 }, Hell: { exp: 7, hpMult: 1.4, dmgMult: 1.6, creditMult: 1.75, crystalDiffMod: 1.7, crystalGFBonus: 1.5 },
Nintendo: { exp: 10, hpMult: 2.2, dropMult: 2.5 }, Nintendo: { exp: 10, hpMult: 1.7, dmgMult: 2.0, creditMult: 2.2, crystalDiffMod: 2, crystalGFBonus: 1.5 },
IWBTH: { exp: 15, hpMult: 2.5, dropMult: 3.0 }, IWBTH: { exp: 15, hpMult: 2.0, dmgMult: 2.5, creditMult: 3, crystalDiffMod: 2.5, crystalGFBonus: 2 },
PFUDOR: { exp: 20, hpMult: 3.0, dropMult: 4.0 }, PFUDOR: { exp: 20, hpMult: 2.0, dmgMult: 3.0, creditMult: 3, crystalDiffMod: 3, crystalGFBonus: 2 },
}; };
module.exports = { module.exports = {
monsterHp, monsterMp, monsterSp,
monsterBasePhysDmg, monsterPhysCrit, monsterPhysMit, monsterMagMit,
monsterEvade, monsterParry,
calcMaxHp, calcMaxMp, calcMaxSp, calcMaxHp, calcMaxMp, calcMaxSp,
calcHpRegen, calcMpRegen, calcHpRegen, calcMpRegen,
calcBasePhysDamage, calcBasePhysDamage,
calcHitChance, calcCritChance, calcCritDamage, calcCritChance, calcCritDamage,
calcOCPerHit, calcOCPerHit,
dominoStrikeChance, channelingProcChance, dominoStrikeChance, channelingProcChance,
calcSpellDamage, calcSkillDamage, calcSpellDamage, calcSkillDamage,

View file

@ -68,9 +68,10 @@ function simulateGrindfest(playerConfig, strategyConfig, roundCount) {
for (const monster of monsters) { for (const monster of monsters) {
if (!monster.isAlive()) continue; if (!monster.isAlive()) continue;
if (Math.random() > 0.40) continue; // Not all monsters act each turn 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 atk = Math.round(monster.baseAtk * (0.8 + Math.random() * 0.4)); // 80-120% of base
const dmg = player.takeDamage(atk, 'physical'); const crit = Math.random() < monster.critChance;
roundLog.push(`${monster.name} hits you, causing ${dmg} points of damage.`); 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; totalDmgTaken += dmg;
} }
@ -87,10 +88,10 @@ function simulateGrindfest(playerConfig, strategyConfig, roundCount) {
// ── Round end ── // ── Round end ──
const roundKills = monsters.filter(m => !m.isAlive()).length; const roundKills = monsters.filter(m => !m.isAlive()).length;
totalKills += roundKills; totalKills += roundKills;
// EXP and credits (wiki formula)
// EXP and credits const diffInfo = F.DIFFICULTY[player.difficulty] || F.DIFFICULTY.Nightmare;
const exp = Math.round(100 * diff.exp * (1 + player.level / 100)); const exp = Math.round(100 * diffInfo.exp * round);
const credits = Math.round(15 * diff.dropMult + Math.random() * 10); const credits = Math.round(15 * diffInfo.creditMult + Math.random() * 10 * round);
totalExp += exp; totalExp += exp;
totalCredits += credits; totalCredits += credits;
@ -157,9 +158,7 @@ function executeAction(player, monsters, action, log) {
case 'attack': { case 'attack': {
const target = action.target || monsters.find(m => m.isAlive()); const target = action.target || monsters.find(m => m.isAlive());
if (!target) return; if (!target) return;
const hit = Math.random() * 100 < player.hitChance; const crit = Math.random() < player.critChance;
if (!hit) { log.push(`You miss ${target.name}.`); return; }
const crit = Math.random() * 100 < player.critChance;
let dmg = player.baseAtk; let dmg = player.baseAtk;
if (crit) dmg = Math.round(dmg * player.critDamage); if (crit) dmg = Math.round(dmg * player.critDamage);
const result = target.takeDamage(dmg, 'physical'); const result = target.takeDamage(dmg, 'physical');