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
319 lines
12 KiB
JavaScript
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 };
|