From 6c2bb2f8f32cab86e8f17543c590711db200028c Mon Sep 17 00:00:00 2001 From: GaboGG Date: Mon, 20 Jul 2026 19:36:55 -0400 Subject: [PATCH] Add battle simulator and Forgejo repo setup - Simulator: headless HV battle engine in Node.js - Player/Entity models with stats, gear, spells - Monster definitions and spawning - Strategy engine ported from userscript - Grindfest simulation with turn-by-turn logging - Forgejo remote: git@git.gaboggamer.online:gabogg/hv-unified.git - 3 config presets: default, aggressive, conservative - Outputs summary stats (kills, damage, survival) per run --- simulator/README.md | 52 +++++ simulator/configs/aggressive.json | 12 ++ simulator/configs/conservative.json | 12 ++ simulator/configs/default.json | 12 ++ simulator/package.json | 12 ++ simulator/results/test.json | 70 +++++++ simulator/src/entities.js | 287 ++++++++++++++++++++++++++++ simulator/src/formulas.js | 105 ++++++++++ simulator/src/grindfest.js | 283 +++++++++++++++++++++++++++ simulator/src/runner.js | 96 ++++++++++ simulator/src/strategy.js | 136 +++++++++++++ 11 files changed, 1077 insertions(+) create mode 100644 simulator/README.md create mode 100644 simulator/configs/aggressive.json create mode 100644 simulator/configs/conservative.json create mode 100644 simulator/configs/default.json create mode 100644 simulator/package.json create mode 100644 simulator/results/test.json create mode 100644 simulator/src/entities.js create mode 100644 simulator/src/formulas.js create mode 100644 simulator/src/grindfest.js create mode 100644 simulator/src/runner.js create mode 100644 simulator/src/strategy.js diff --git a/simulator/README.md b/simulator/README.md new file mode 100644 index 0000000..23261f2 --- /dev/null +++ b/simulator/README.md @@ -0,0 +1,52 @@ +# HV Unified Simulator + +A headless battle simulator for HentaiVerse. Tests strategy configurations against simulated battles to find optimal settings. + +## Structure + +``` +simulator/ +├── src/ +│ ├── engine.js # Battle engine (turns, damage, procs) +│ ├── entities.js # Player, Monster classes +│ ├── formulas.js # All wiki-known formulas +│ ├── strategy.js # Port of the userscript strategy logic +│ ├── grindfest.js # Grindfest simulation +│ ├── arena.js # Arena simulation +│ └── runner.js # Run configs, collect stats +├── configs/ +│ └── default.json # Default strategy config +├── results/ # Simulation output +├── package.json +└── README.md +``` + +## Usage + +```bash +node src/runner.js --rounds 1000 --config configs/aggressive.json +``` + +## Strategy Config Format + +```json +{ + "name": "aggressive-items", + "cureHP": 0.35, + "cureItemHP": 0.65, + "manaGemMP": 0.60, + "spiritPotionSP": 0.40, + "spiritStanceOC": 60, + "useAttackSpells": false, + "skillRendingAt": 5, + "skillGreatCleaveOnlyBosses": true +} +``` + +## How It Works + +1. Creates a simulated player at a given level with specified stats/gear +2. Spawns monsters based on difficulty and battle type +3. Runs the same strategy logic as the userscript (ported to Node.js) +4. Tracks every action, proc, damage event +5. Outputs summary stats + per-round logs diff --git a/simulator/configs/aggressive.json b/simulator/configs/aggressive.json new file mode 100644 index 0000000..02f99dc --- /dev/null +++ b/simulator/configs/aggressive.json @@ -0,0 +1,12 @@ +{ + "name": "aggressive-items", + "description": "Higher item usage thresholds for credit-rich players", + "cureHP": 0.40, + "cureItemHP": 0.75, + "cureRegenHP": 0.80, + "manaGemMP": 0.70, + "manaPotionMP": 0.40, + "spiritPotionSP": 0.50, + "spiritStanceOC": 60, + "useAttackSpells": false +} diff --git a/simulator/configs/conservative.json b/simulator/configs/conservative.json new file mode 100644 index 0000000..bd24596 --- /dev/null +++ b/simulator/configs/conservative.json @@ -0,0 +1,12 @@ +{ + "name": "conservative-spirit", + "description": "Higher OC spirit stance, save skills for big moments", + "cureHP": 0.35, + "cureItemHP": 0.65, + "cureRegenHP": 0.75, + "manaGemMP": 0.60, + "manaPotionMP": 0.30, + "spiritPotionSP": 0.40, + "spiritStanceOC": 80, + "useAttackSpells": false +} diff --git a/simulator/configs/default.json b/simulator/configs/default.json new file mode 100644 index 0000000..ab47e16 --- /dev/null +++ b/simulator/configs/default.json @@ -0,0 +1,12 @@ +{ + "name": "current-defaults", + "description": "Current v0.11.0 strategy defaults", + "cureHP": 0.35, + "cureItemHP": 0.65, + "cureRegenHP": 0.75, + "manaGemMP": 0.60, + "manaPotionMP": 0.30, + "spiritPotionSP": 0.40, + "spiritStanceOC": 60, + "useAttackSpells": false +} diff --git a/simulator/package.json b/simulator/package.json new file mode 100644 index 0000000..828093f --- /dev/null +++ b/simulator/package.json @@ -0,0 +1,12 @@ +{ + "name": "simulator", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/simulator/results/test.json b/simulator/results/test.json new file mode 100644 index 0000000..23dfcda --- /dev/null +++ b/simulator/results/test.json @@ -0,0 +1,70 @@ +{ + "config": { + "player": { + "level": 52, + "style": "2H", + "str": 85, + "dex": 34, + "agi": 24, + "end": 60, + "int": 6, + "wis": 15, + "difficulty": "Nightmare" + }, + "strategy": {}, + "rounds": 50 + }, + "rounds": [ + { + "round": 1, + "kills": 0, + "monsters": 5, + "turns": 8, + "startHp": 670, + "endHp": 0, + "startMp": 77, + "endMp": 45, + "startSp": 34, + "endSp": 34, + "oc": 100, + "spirit": false, + "log": [ + "Green Slime hits you, causing 29 points of damage.", + "You cast Protection.", + "Blue Slime hits you, causing 28 points of damage.", + "Cookie Monster hits you, causing 62 points of damage.", + "Punishment Dragon hits you, causing 45 points of damage.", + "Green Slime hits you, causing 39 points of damage.", + "Cookie Monster hits you, causing 27 points of damage.", + "Punishment Dragon hits you, causing 54 points of damage.", + "You cast Protection.", + "Blue Slime hits you, causing 48 points of damage.", + "Tentacle Monster hits you, causing 31 points of damage.", + "Punishment Dragon hits you, causing 50 points of damage.", + "You cast Cure.", + "Blue Slime hits you, causing 25 points of damage.", + "You cast Cure.", + "Blue Slime hits you, causing 65 points of damage.", + "Cookie Monster hits you, causing 52 points of damage.", + "Punishment Dragon hits you, causing 53 points of damage.", + "Tentacle Monster hits you, causing 53 points of damage.", + "Green Slime hits you, causing 60 points of damage." + ] + } + ], + "summary": { + "roundsCompleted": 1, + "playerDied": true, + "totalKills": 0, + "totalDmgDealt": 0, + "totalDmgTaken": 852, + "totalHealed": 0, + "totalExp": 608, + "totalCredits": 24, + "spellsCast": {}, + "skillsUsed": {}, + "itemsUsed": {}, + "avgRounds": 0, + "survival": "Died at round 1" + } +} \ No newline at end of file diff --git a/simulator/src/entities.js b/simulator/src/entities.js new file mode 100644 index 0000000..6f245bc --- /dev/null +++ b/simulator/src/entities.js @@ -0,0 +1,287 @@ +// ── 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 (from a typical Lv52 2H build) + this.str = config.str || 85; + this.dex = config.dex || 34; + this.agi = config.agi || 24; + this.end = config.end || 60; + this.int = config.int || 6; + this.wis = config.wis || 15; + + // Derived stats + this.maxHp = F.calcMaxHp(this.end, this.level); + this.maxMp = F.calcMaxMp(this.wis, this.level); + this.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 = 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.critDamage = F.calcCritDamage(68); // +68% from gear + + // Mitigation + this.physMit = 30; // ~30% from light armor + this.magMit = 23; + + // 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; + const plFactor = 1 + (level - 50) / 100; + this.maxHp = Math.round(template.hpBase * plFactor * difficultyMult); + this.hp = this.maxHp; + this.maxMp = Math.round(this.maxHp * 0.15); + this.mp = this.maxMp; + this.maxSp = this.boss ? Math.round(this.maxHp * 0.3) : 0; + this.sp = this.maxSp; + this.res = { ...template.res }; + 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)); + 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 }; diff --git a/simulator/src/formulas.js b/simulator/src/formulas.js new file mode 100644 index 0000000..97ba231 --- /dev/null +++ b/simulator/src/formulas.js @@ -0,0 +1,105 @@ +// ── All known HV formulas (from EHWiki and forum research) ── + +// ── Player stats ── + +function calcMaxHp(end, level) { + return 50 + end * 6 + level * 5; +} + +function calcMaxMp(wis, level) { + return 10 + wis * 1 + level * 1; +} + +function calcMaxSp(int, level) { + return 5 + int * 0.5 + level * 0.5; +} + +function calcHpRegen(end) { + return 1 + end * 0.2; +} + +function calcMpRegen(wis) { + return 1 + wis * 0.04; +} + +// ── Physical damage ── + +function calcBasePhysDamage(str, dex, level, weaponProf) { + const weaponDamage = 10 + str * 2 + dex * 1; + const profBonus = weaponProf * 0.5; + 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 calcCritDamage(bonus) { + return 1.5 + bonus / 100; // Base 1.5x, +Heartseeker etc. +} + +// ── Overcharge ── + +function calcOCPerHit() { + return 5 + Math.random() * 10; // Each basic attack generates 5-15 OC +} + +// ── Proc chances ── + +function dominoStrikeChance(str, dex, prof2h) { + return Math.min(90, 40 + (str + dex) * 0.02 + prof2h / 10); +} + +function channelingProcChance(spellCost, baseMana) { + return spellCost / (baseMana * 1.2); +} + +// ── Spell damage ── + +function calcSpellDamage(int, wis, prof, spellPower, targetRes) { + const base = 5 + int * 2 + wis * 1; + const profFactor = 1 + prof / 500; + const raw = Math.round(base * spellPower * profFactor); + const resistFactor = 1 - (targetRes || 0) / 100; + return Math.round(raw * resistFactor); +} + +// ── Skill damage ── + +function calcSkillDamage(baseAtk, multiplier, physMit) { + const mitFactor = 1 - physMit / 100; + return Math.round(baseAtk * multiplier * mitFactor); +} + +// ── 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 }, +}; + +module.exports = { + calcMaxHp, calcMaxMp, calcMaxSp, + calcHpRegen, calcMpRegen, + calcBasePhysDamage, + calcHitChance, calcCritChance, calcCritDamage, + calcOCPerHit, + dominoStrikeChance, channelingProcChance, + calcSpellDamage, calcSkillDamage, + DIFFICULTY, +}; diff --git a/simulator/src/grindfest.js b/simulator/src/grindfest.js new file mode 100644 index 0000000..8e49f9e --- /dev/null +++ b/simulator/src/grindfest.js @@ -0,0 +1,283 @@ +// ── Grindfest battle simulator ── + +const { SimPlayer, spawnWave } = require('./entities'); +const { decideAction } = require('./strategy'); +const F = require('./formulas'); + +function simulateGrindfest(playerConfig, strategyConfig, roundCount) { + const player = new SimPlayer(playerConfig); + Object.assign(player.cfg, strategyConfig); + + const diff = F.DIFFICULTY[player.difficulty] || F.DIFFICULTY.Nightmare; + + const results = { + config: { player: playerConfig, strategy: strategyConfig, rounds: roundCount }, + rounds: [], + summary: null, + }; + + let totalKills = 0; + let totalDmgDealt = 0; + let totalDmgTaken = 0; + let totalHealed = 0; + let totalManaGained = 0; + let totalExp = 0; + let totalCredits = 0; + let itemsUsed = {}; + let spellsCast = {}; + let skillsUsed = {}; + let spiritCycles = 0; + let channelingCycles = 0; + let playerDied = false; + + for (let round = 1; round <= roundCount; round++) { + // Regen per round + player.heal(F.calcHpRegen(player.end)); + player.regenMp(F.calcMpRegen(player.wis)); + + // Spawn monsters (5-8 per round on Grindfest) + const monsterCount = 5 + Math.floor(Math.random() * 4); + const monsters = spawnWave(player.level, ['Normal','Hard','Nightmare','Hell','Nintendo','IWBTH','PFUDOR'].indexOf(player.difficulty), monsterCount, false); + + const roundLog = []; + const roundStartHp = player.hp; + const roundStartMp = player.mp; + const roundStartSp = player.sp; + + // Start with some OC built up from previous round + player.oc = Math.min(100, player.oc + 20 + Math.round(Math.random() * 20)); + + // ── Battle loop ── + let turn = 0; + const MAX_TURNS = 500; + + while (monsters.some(m => m.isAlive()) && player.isAlive() && turn < MAX_TURNS) { + turn++; + + // Player turn + const action = decideAction(player, monsters, player.cfg); + if (!action) break; + + executeAction(player, monsters, action, roundLog); + if (!player.isAlive()) { playerDied = true; break; } + + // Check if all monsters dead + if (!monsters.some(m => m.isAlive())) break; + + // Monsters attack (simplified: each monster has ~40% chance to act per turn) + 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.`); + totalDmgTaken += dmg; + } + + if (!player.isAlive()) { playerDied = true; break; } + + // Tick buffs/cooldowns + player.tickBuffs(); + monsters.forEach(m => m.tickBuffs()); + + // OC from basic attacks + player.addOc(F.calcOCPerHit()); + } + + // ── 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); + totalExp += exp; + totalCredits += credits; + + // Heal between rounds (Victory) + if (!playerDied) { + player.heal(Math.round(player.maxHp * 0.05)); // small post-battle regen + } + + results.rounds.push({ + round, + kills: roundKills, + monsters: monsterCount, + turns: turn, + startHp: roundStartHp, + endHp: player.hp, + startMp: roundStartMp, + endMp: player.mp, + startSp: roundStartSp, + endSp: player.sp, + oc: player.oc, + spirit: player.spiritStance, + log: roundLog.slice(-20), // last 20 actions + }); + + if (playerDied) break; + + // Track stats + for (const line of roundLog) { + const mSpell = line.match(/You cast (\w[\w\s-]+)\./); + if (mSpell) spellsCast[mSpell[1]] = (spellsCast[mSpell[1]] || 0) + 1; + const mSkill = line.match(/You use (\w[\w\s-]+)\./); + if (mSkill && !mSkill[1].includes('Draught')) skillsUsed[mSkill[1]] = (skillsUsed[mSkill[1]] || 0) + 1; + const mItem = line.match(/You use (.+?)\.$/); + if (mItem && (mItem[1].includes('Gem') || mItem[1].includes('Draught') || mItem[1].includes('Potion'))) + itemsUsed[mItem[1]] = (itemsUsed[mItem[1]] || 0) + 1; + const mHeal = line.match(/restores (\d+)/); + if (mHeal) totalHealed += parseInt(mHeal[1]); + const mDmg = line.match(/causing (\d+) points/); + if (mDmg) totalDmgDealt += parseInt(mDmg[1]); + } + } + + results.summary = { + roundsCompleted: results.rounds.length, + playerDied, + totalKills, + totalDmgDealt, + totalDmgTaken, + totalHealed, + totalExp, + totalCredits, + spellsCast, + skillsUsed, + itemsUsed, + avgRounds: results.rounds.length > 0 ? Math.round(totalKills / results.rounds.length) : 0, + survival: playerDied ? `Died at round ${results.rounds.length}` : 'Survived all rounds', + }; + + return results; +} + +function executeAction(player, monsters, action, log) { + switch (action.type) { + 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; + let dmg = player.baseAtk; + if (crit) dmg = Math.round(dmg * player.critDamage); + const result = target.takeDamage(dmg, 'physical'); + const prefix = crit ? 'crit ' : ''; + log.push(`You ${prefix}${target.name}, causing ${result.dealt} points of Piercing damage.`); + // Domino Strike proc + const domino = F.dominoStrikeChance(player.str, player.dex, player.prof2h); + if (Math.random() * 100 < domino) { + const others = monsters.filter(m => m.isAlive() && m !== target); + for (const other of others.slice(0, 2)) { + const splash = Math.round(dmg * 0.75); + other.takeDamage(splash, 'physical'); + log.push(` Domino strikes ${other.name} for ${splash} damage.`); + } + } + // Generate OC + player.addOc(F.calcOCPerHit()); + break; + } + + case 'spell': { + const mpCost = player.spells.find(s => s.n === action.name)?.mp || 10; + player.useMp(mpCost); + log.push(`You cast ${action.name}.`); + + // Apply buff effects + const buffMap = { 'Protection': 'protection', 'Haste': 'haste', 'Regen': 'regen', + 'Spark of Life': 'spark_of_life', 'Shadow Veil': 'shadow_veil', 'Absorb': 'absorb', + 'Cure': null, 'Full-Cure': null, 'Imperil': null, 'Weaken': null, 'Slow': null, 'Drain': null }; + const buffIcon = buffMap[action.name]; + if (buffIcon) { + player.addBuff(buffIcon, 20 + Math.round(Math.random() * 10)); + log.push(` ${action.name} is now active.`); + } + + // Cure/Full-Cure healing + if (action.name === 'Cure' || action.name === 'Full-Cure') { + const healAmt = Math.round(player.maxHp * (action.name === 'Full-Cure' ? 0.60 : 0.25)); + player.heal(healAmt); + log.push(` You are healed for ${healAmt}.`); + } + + // Channeling proc + const chProc = F.channelingProcChance(mpCost, player.maxMp); + if (Math.random() < chProc && !player.channeling) { + player.channeling = true; + log.push('You gain the effect Channeling.'); + } + + if (action.target && action.target.isAlive()) { + // Debuffs + if (action.name === 'Imperil') { + action.target.addDebuff('imperil', 10, 1); + log.push(` ${action.target.name} is imperiled.`); + } else if (action.name === 'Weaken') { + action.target.addDebuff('weaken', 8, 1); + log.push(` ${action.target.name} is weakened.`); + } else { + // Damage spell + const dmg = F.calcSpellDamage(player.int, player.wis, player.profElemental, 10, action.target.res.fire || 0); + action.target.takeDamage(dmg, 'fire'); + log.push(` ${action.target.name} takes ${dmg} damage.`); + } + } + break; + } + + case 'skill': { + const cost = player.skillCosts[action.name] || 50; + if (!player.spendOc(cost)) return; + const cd = player.skillCooldowns[action.name] || 5; + player.cooldowns[action.name] = cd; + + log.push(`You use ${action.name}.`); + const alive = monsters.filter(m => m.isAlive()); + + if (action.name === 'Great Cleave' && alive.length > 0) { + const target = alive[0]; + const dmg = player.baseAtk * 10; + const result = target.takeDamage(dmg, 'physical'); + log.push(` ${target.name} was crit for ${result.dealt} Piercing damage.`); + } else if (action.name === 'Rending Blow') { + for (const m of alive) { + const dmg = player.baseAtk * 4; + m.takeDamage(dmg, 'physical'); + m.addDebuff('penetrated_armor', 5, 3); + log.push(` ${m.name} gains Penetrated Armor (x3).`); + } + } else if (action.name === 'Shatter Strike') { + for (const m of alive) { + const dmg = player.baseAtk * 3; + m.takeDamage(dmg, 'physical'); + log.push(` ${m.name} is stunned.`); + } + } + break; + } + + case 'item': { + const item = player.useItem(action.id); + if (item) { + if (item.t === 'heal') player.heal(Math.round(player.maxHp * 0.25)); + else if (item.t === 'mana') player.regenMp(Math.round(player.maxMp * 0.30)); + else if (item.t === 'spirit') player.regenSp(Math.round(player.maxSp * 0.40)); + else if (item.t === 'channel') { + player.channeling = true; + log.push('Channeling extended.'); + } + } + break; + } + + case 'toggle_spirit': { + player.spiritStance = !player.spiritStance; + log.push(`Spirit Stance ${player.spiritStance ? 'Engaged' : 'Disabled'}.`); + break; + } + } +} + +module.exports = { simulateGrindfest }; diff --git a/simulator/src/runner.js b/simulator/src/runner.js new file mode 100644 index 0000000..d5ff866 --- /dev/null +++ b/simulator/src/runner.js @@ -0,0 +1,96 @@ +// ── CLI runner for simulations ── + +const { simulateGrindfest } = require('./grindfest'); +const fs = require('fs'); +const path = require('path'); + +function main() { + const args = process.argv.slice(2); + let rounds = 100; + let configPath = null; + let outputPath = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--rounds' || args[i] === '-r') rounds = parseInt(args[++i]) || 100; + if (args[i] === '--config' || args[i] === '-c') configPath = args[++i]; + if (args[i] === '--output' || args[i] === '-o') outputPath = args[++i]; + if (args[i] === '--help' || args[i] === '-h') { + console.log(` +HV Unified Simulator +Usage: node src/runner.js [options] + +Options: + --rounds, -r N Number of Grindfest rounds to simulate (default: 100) + --config, -c FILE Strategy config JSON file + --output, -o FILE Output results to file + --help, -h Show this help + +Examples: + node src/runner.js --rounds 1000 + node src/runner.js --config configs/aggressive.json -o results/test.json +`); + process.exit(0); + } + } + + // Default player config (matching Lv52 2H profile) + const playerConfig = { + level: 52, + style: '2H', + str: 85, dex: 34, agi: 24, end: 60, int: 6, wis: 15, + difficulty: 'Nightmare', + }; + + // Load strategy config + let strategyConfig = {}; + if (configPath) { + strategyConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } + + console.log(`\n🛡️ HV Unified Simulator`); + console.log(` Player: Lv${playerConfig.level} ${playerConfig.style}`); + console.log(` Difficulty: ${playerConfig.difficulty}`); + console.log(` Rounds: ${rounds}`); + if (configPath) console.log(` Config: ${configPath}`); + console.log(''); + + const start = Date.now(); + const results = simulateGrindfest(playerConfig, strategyConfig, rounds); + const elapsed = ((Date.now() - start) / 1000).toFixed(2); + + const s = results.summary; + + console.log(`⚔️ ${s.roundsCompleted} rounds (${elapsed}s)`); + console.log(` ${s.survival}`); + console.log(` Kills: ${s.totalKills} (avg ${s.avgRounds}/round)`); + console.log(` Dmg dealt: ${s.totalDmgDealt.toLocaleString()}`); + console.log(` Dmg taken: ${s.totalDmgTaken.toLocaleString()}`); + console.log(` Healed: ${s.totalHealed.toLocaleString()}`); + console.log(` EXP: ${s.totalExp.toLocaleString()}`); + console.log(` Credits: ${s.totalCredits.toLocaleString()}`); + + if (Object.keys(s.spellsCast).length > 0) { + console.log(`\n Spells: ${JSON.stringify(s.spellsCast)}`); + } + if (Object.keys(s.skillsUsed).length > 0) { + console.log(` Skills: ${JSON.stringify(s.skillsUsed)}`); + } + if (Object.keys(s.itemsUsed).length > 0) { + console.log(` Items: ${JSON.stringify(s.itemsUsed)}`); + } + + // Save results + if (outputPath) { + const dir = path.dirname(outputPath); + if (dir) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); + console.log(`\n Results saved to ${outputPath}`); + } + + // Return results for programmatic use + return results; +} + +if (require.main === module) { + main(); +} diff --git a/simulator/src/strategy.js b/simulator/src/strategy.js new file mode 100644 index 0000000..ba1f851 --- /dev/null +++ b/simulator/src/strategy.js @@ -0,0 +1,136 @@ +// ── Strategy decision engine (mirrors the userscript's logic) ── + +const RARE_NAMES = [ + 'manbearpig', 'white bunneh', 'mithra', 'dalek', + 'konata', 'mikuru asahina', 'ryouko asakura', 'yuki nagato', + 'skuld', 'urd', 'verdandi', 'yggdrasil', + 'rhaegal', 'viserion', 'drogon', +]; + +const SPELL_T1 = ['Fiery Blast', 'Freeze', 'Shockblast', 'Gale']; +const BUFF_PRIORITY = [ + { icon: 'haste', spell: 'Haste', minTurns: 2 }, + { icon: 'protection', spell: 'Protection', minTurns: 2 }, + { icon: 'spark_of_life', spell: 'Spark of Life', minTurns: 1 }, + { icon: 'shadow_veil', spell: 'Shadow Veil', minTurns: 2 }, + { icon: 'regen', spell: 'Regen', minTurns: 2 }, + { icon: 'absorb', spell: 'Absorb', minTurns: 2 }, +]; + +function hasAnyRare(monsters) { + return monsters.some(m => RARE_NAMES.some(r => m.name.toLowerCase().includes(r))); +} + +// Returns an action object: { type, name?, target? } +// type: 'attack' | 'spell' | 'skill' | 'item' | 'toggle_spirit' +function decideAction(player, monsters, cfg) { + // ── 0. Mystic Gem for channeling ── + if (!player.channeling) { + const gem = player.hasUsableItem('channel'); + if (gem) return { type: 'item', id: gem.id }; + } + + // ── 1. Items proactively ── + if (player.hp / player.maxHp < cfg.cureItemHP) { + const gem = player.hasUsableItem('heal'); + if (gem) return { type: 'item', id: gem.id }; + } + if (player.mp / player.maxMp < cfg.manaGemMP) { + const gem = player.hasUsableItem('mana'); + if (gem) return { type: 'item', id: gem.id }; + } + if (player.sp / player.maxSp < cfg.spiritPotionSP) { + const gem = player.hasUsableItem('spirit'); + if (gem) return { type: 'item', id: gem.id }; + } + + // ── 2. Cure ── + if (player.hp / player.maxHp < cfg.cureHP) { + const cure = player.hasSpell('Full-Cure') ? 'Full-Cure' : player.hasSpell('Cure') ? 'Cure' : null; + if (cure) return { type: 'spell', name: cure }; + } + + // ── 3. Buffs ── + for (const b of BUFF_PRIORITY) { + const dur = player.buffDuration(b.icon); + if ((dur === 0 || dur < b.minTurns) && player.hasSpell(b.spell) && player.mp > 0.15 * player.maxMp) { + if (b.icon === 'regen' && player.hp / player.maxHp >= cfg.cureRegenHP) continue; + return { type: 'spell', name: b.spell }; + } + } + + // ── 4. Debuffs ── (only on important fights) + const importantFight = hasAnyRare(monsters) || monsters.filter(m => m.isAlive()).length <= 2; + if (importantFight && player.mp > 0.2 * player.maxMp) { + // Find strongest alive + const alive = monsters.filter(m => m.isAlive()); + if (alive.length > 0) { + const strongest = alive.reduce((a, b) => a.hp > b.hp ? a : b); + if (player.hasSpell('Imperil') && !strongest.hasDebuff('imperil')) + return { type: 'spell', name: 'Imperil', target: strongest }; + if (player.hasSpell('Weaken') && !strongest.hasDebuff('weaken')) + return { type: 'spell', name: 'Weaken', target: strongest }; + } + } + + // ── 5. Spirit Stance ── + if (player.oc >= cfg.spiritStanceOC && !player.spiritStance && player.sp > 0.10 * player.maxSp) + return { type: 'toggle_spirit' }; + if (player.sp < 0.03 * player.maxSp && player.spiritStance) + return { type: 'toggle_spirit' }; + + // ── 6. Weapon skills ── + const aliveCount = monsters.filter(m => m.isAlive()).length; + if (player.oc >= 80) { + // Great Cleave: bosses only + if (hasAnyRare(monsters) && player.hasSkill('Great Cleave') && !player.cooldowns['Great Cleave']) + return { type: 'skill', name: 'Great Cleave', target: monsters.filter(m => m.isAlive())[0] }; + + // Rending Blow: 5+ enemies + if (aliveCount >= 5 && player.hasSkill('Rending Blow') && !player.cooldowns['Rending Blow']) + return { type: 'skill', name: 'Rending Blow', target: monsters.filter(m => m.isAlive())[0] }; + + // Shatter Strike: 5+ enemies, needs Penetrated Armor + if (aliveCount >= 5 && player.hasSkill('Shatter Strike') && !player.cooldowns['Shatter Strike']) { + const hasArmorBreak = monsters.some(m => m.hasDebuff('penetrated_armor')); + if (hasArmorBreak) + return { type: 'skill', name: 'Shatter Strike', target: monsters.filter(m => m.isAlive())[0] }; + } + } + + // ── 7. Damage spells ── + if (cfg.useAttackSpells && player.mp > 0.15 * player.maxMp) { + const dmg = player.spells.find(s => SPELL_T1.includes(s.n)); + if (dmg) { + const alive = monsters.filter(m => m.isAlive()); + if (alive.length > 0) return { type: 'spell', name: dmg.n, target: alive[0] }; + } + } + + // ── 8. Channeling kickstart ── + if (!player.channeling) { + // Refresh expiring buffs + for (const b of BUFF_PRIORITY) { + const dur = player.buffDuration(b.icon); + if (dur > 0 && dur < 15 && player.mp > 0.15 * player.maxMp && player.hasSpell(b.spell)) { + if (b.icon === 'regen' && player.hp / player.maxHp >= cfg.cureRegenHP) continue; + return { type: 'spell', name: b.spell }; + } + } + // Cast missing buffs + for (const b of BUFF_PRIORITY) { + if (player.buffDuration(b.icon) === 0 && player.mp > 0.15 * player.maxMp && player.hasSpell(b.spell)) { + if (b.icon === 'regen' && player.hp / player.maxHp >= cfg.cureRegenHP) continue; + return { type: 'spell', name: b.spell }; + } + } + } + + // ── 9. Basic attack ── + const alive = monsters.filter(m => m.isAlive()); + if (alive.length > 0) return { type: 'attack', target: alive[0] }; + + return null; +} + +module.exports = { decideAction, RARE_NAMES };