hv-unified/simulator/src/entities.js
GaboGG 0ee31b7266 Benchmark v2 with real Lv90 stats, Shadow Veil removed, full-conservative defaults
Shadow Veil removed from BUFF_PRIORITY - was draining MP aggressively.
Player stats updated to match real character (effective values with gear).

Benchmark results (Lv90, Nightmare, 5 runs each):
1. full-conservative: 146 rounds, 4485 dmg/rd — WINNER
2. conservative-items: 143 rounds, 4468 dmg/rd
3. spirit-40: 136 rounds, 4457 dmg/rd

New defaults (full-conservative):
- cureHP: 0.45, cureItemHP: 0.50 (save items use cure)
- manaGemMP: 0.45, manaPotionMP: 0.25
- spiritPotionSP: 0.30, spiritStanceOC: 80
- skillOC: 75
2026-07-21 14:00:44 -04:00

319 lines
12 KiB
JavaScript

// ── Simulated Player ──
const F = require('./formulas');
class SimPlayer {
constructor(config) {
this.level = config.level || 52;
this.tier = this.level >= 300 ? 'master' : this.level >= 150 ? 'veteran' : this.level >= 50 ? 'adept' : 'novice';
this.style = config.style || '2H';
this.difficulty = config.difficulty || 'Nightmare';
// 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 || 258; // ~1804 HP at Lv52 from gear
this.int = config.int || 6;
this.wis = config.wis || 31; // ~93 MP from gear
// Derived stats (use effective values from config if provided)
this.maxHp = config.maxHp || F.calcMaxHp(this.end, this.level);
this.maxMp = config.maxMp || F.calcMaxMp(this.wis, this.level);
this.maxSp = config.maxSp || F.calcMaxSp(this.int, this.level);
this.hp = this.maxHp;
this.mp = this.maxMp;
this.sp = this.maxSp;
this.oc = 0;
// Proficiencies
this.prof2h = config.prof2h || 60;
this.prof1h = config.prof1h || 7;
this.profLight = config.profLight || 60;
this.profHeavy = config.profHeavy || 47;
this.profSupportive = config.profSupportive || 60;
this.profElemental = config.profElemental || 42;
// Damage stats
this.baseAtk = config.baseAtk || F.calcBasePhysDamage(this.str, this.dex, this.level, this.prof2h);
this.critChance = config.critChance || F.calcCritChance(this.str, this.dex);
this.critDamage = config.critDmg || F.calcCritDamage(68); // +68% from gear
// Mitigation
this.physMit = (config.physMit / 100) || 0.315;
this.magMit = (config.magMit / 100) || 0.299;
// Buffs
this.buffs = {}; // { name: {turns: N} }
this.spiritStance = false;
this.channeling = false;
// Spells known
this.spells = config.spells || [
{ n: 'Cure', mp: 11 }, { n: 'Protection', mp: 14 },
{ n: 'Fiery Blast', mp: 4 }, { n: 'Freeze', mp: 4 },
{ n: 'Shockblast', mp: 4 }, { n: 'Gale', mp: 4 },
{ n: 'Regen', mp: 17 }, { n: 'Absorb', mp: 17 },
{ n: 'Drain', mp: 9 }, { n: 'Slow', mp: 12 },
];
// Skills known
this.skills = config.skills || [
'Great Cleave', 'Rending Blow',
];
// Items
this.items = (config && config.items) ? config.items : {
p: 10007,
1: 11191,
2: 11195,
3: 11291,
4: 11295,
5: 11391,
};
// Item definitions (subset)
this.itemDefs = {
10005: { n: 'Health Gem', t: 'heal' },
10006: { n: 'Mana Gem', t: 'mana' },
10007: { n: 'Spirit Gem', t: 'spirit' },
10008: { n: 'Mystic Gem', t: 'channel' },
11191: { n: 'Health Draught', t: 'heal' },
11195: { n: 'Health Potion', t: 'heal' },
11291: { n: 'Mana Draught', t: 'mana' },
11295: { n: 'Mana Potion', t: 'mana' },
11391: { n: 'Spirit Draught', t: 'spirit' },
};
// Cooldowns tracking
this.cooldowns = {};
this.skillCosts = {
'Great Cleave': 50,
'Rending Blow': 50,
'Shatter Strike': 50,
};
this.skillCooldowns = {
'Great Cleave': 5,
'Rending Blow': 5,
'Shatter Strike': 5,
};
// Action log for this round
this.roundLog = [];
// Strategy config (overridable)
this.cfg = {
cureHP: 0.35,
cureItemHP: 0.65,
cureRegenHP: 0.75,
manaGemMP: 0.60,
manaPotionMP: 0.30,
spiritPotionSP: 0.40,
spiritStanceOC: 60,
useAttackSpells: false,
autoBuff: true,
autoDebuff: true,
autoSpirit: false,
};
}
isAlive() { return this.hp > 0; }
useMp(cost) {
cost = Math.min(cost, this.mp);
this.mp -= cost;
return cost;
}
addOc(amount) {
this.oc = Math.min(100, this.oc + amount);
}
spendOc(amount) {
if (this.oc < amount) return false;
this.oc -= amount;
return true;
}
hasBuff(name) { return this.buffs[name] && this.buffs[name].turns > 0; }
buffDuration(name) { return this.buffs[name] ? this.buffs[name].turns : 0; }
addBuff(name, turns) {
this.buffs[name] = { turns: Math.max(this.buffs[name]?.turns || 0, turns) };
}
hasSpell(name) { return this.spells.some(s => s.n === name); }
hasSkill(name) { return this.skills.includes(name); }
hasUsableItem(type) {
for (const [slot, id] of Object.entries(this.items)) {
if (!id || id === 0) continue;
const def = this.itemDefs[id];
if (!def || def.t !== type) continue;
return { id: slot, item: def };
}
return null;
}
useItem(slot) {
const id = this.items[slot];
if (!id) return null;
const def = this.itemDefs[id];
this.items[slot] = 0; // consumed
this.roundLog.push(`You use ${def.n}.`);
return def;
}
takeDamage(amount, type) {
const mit = type === 'physical' ? this.physMit : this.magMit;
const effective = Math.round(amount * (1 - mit / 100));
this.hp -= effective;
if (this.hp < 0) this.hp = 0;
return effective;
}
heal(amount) {
this.hp = Math.min(this.maxHp, this.hp + amount);
}
regenMp(amount) {
this.mp = Math.min(this.maxMp, this.mp + amount);
}
regenSp(amount) {
this.sp = Math.min(this.maxSp, this.sp + amount);
}
tickBuffs() {
// Tick down buff durations
for (const key of Object.keys(this.buffs)) {
this.buffs[key].turns--;
if (this.buffs[key].turns <= 0) delete this.buffs[key];
}
// Tick cooldowns
for (const key of Object.keys(this.cooldowns)) {
this.cooldowns[key]--;
if (this.cooldowns[key] <= 0) delete this.cooldowns[key];
}
}
}
// ── Monster definitions and factory ──
const MONSTERS = {
low: [
{ name: 'Tentacle Monster', mid: 2, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Cockatrice', mid: 4, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Giant Panda', mid: 5, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Scary Ghost', mid: 7, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Rabid Hamster', mid: 8, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Cookie Monster', mid: 9, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Blue Slime', mid: 10, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Mantitcore', mid: 3, pl: 55, hpBase: 1100, res: { phys: 15 } },
{ name: 'Green Slime', mid: 11, pl: 50, hpBase: 1000, res: { phys: 10 } },
{ name: 'Fire Fox', mid: 12, pl: 55, hpBase: 1100, res: { phys: 15 } },
{ name: 'Punishment Dragon', mid: 13, pl: 60, hpBase: 1200, res: { phys: 20 } },
],
bosses: [
{ name: 'Manbearpig', mid: 16, pl: 100, hpBase: 5000, res: { phys: 50 }, boss: true },
{ name: 'White Bunneh', mid: 17, pl: 100, hpBase: 5000, res: { phys: 50 }, boss: true },
{ name: 'Mithra', mid: 18, pl: 100, hpBase: 5000, res: { phys: 50 }, boss: true },
{ name: 'Dalek', mid: 19, pl: 100, hpBase: 5000, res: { phys: 50 }, boss: true },
],
};
class Monster {
constructor(template, level, difficultyMult) {
this.name = template.name;
this.mid = template.mid;
this.boss = template.boss || false;
// 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 = F.monsterMp(level, this.wis);
this.mp = this.maxMp;
this.maxSp = template.boss ? F.monsterSp(this) : 0;
this.sp = this.maxSp;
// 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) {
// 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 };
}
hasDebuff(name) { return this.debuffs[name] && this.debuffs[name].turns > 0; }
addDebuff(name, turns, stacks) {
if (!this.debuffs[name]) this.debuffs[name] = { turns: 0, stacks: 0 };
this.debuffs[name].turns = Math.max(this.debuffs[name].turns, turns);
this.debuffs[name].stacks = Math.max(this.debuffs[name].stacks, stacks || 1);
}
tickBuffs() {
for (const key of Object.keys(this.debuffs)) {
this.debuffs[key].turns--;
if (this.debuffs[key].turns <= 0) delete this.debuffs[key];
}
}
getPhysMitigation() {
const base = this.res.phys || 10;
const penStacks = this.debuffs['penetrated_armor'] ? this.debuffs['penetrated_armor'].stacks : 0;
return Math.max(0, base - penStacks * 10);
}
}
function spawnWave(level, difficulty, count, includeBoss) {
const diffMult = 1 + difficulty * 0.15;
const monsters = [];
const pool = MONSTERS.low;
if (includeBoss && Math.random() < 0.02) {
const boss = MONSTERS.bosses[Math.floor(Math.random() * MONSTERS.bosses.length)];
monsters.push(new Monster(boss, level, diffMult));
}
while (monsters.length < count) {
const t = pool[Math.floor(Math.random() * pool.length)];
monsters.push(new Monster(t, level, diffMult));
}
return monsters;
}
module.exports = { SimPlayer, Monster, MONSTERS, spawnWave };