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:
parent
6c2bb2f8f3
commit
f252fa5566
3 changed files with 121 additions and 43 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue