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
This commit is contained in:
GaboGG 2026-07-20 19:36:55 -04:00
parent 402db6bb2f
commit 6c2bb2f8f3
11 changed files with 1077 additions and 0 deletions

52
simulator/README.md Normal file
View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

12
simulator/package.json Normal file
View file

@ -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"
}

View file

@ -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"
}
}

287
simulator/src/entities.js Normal file
View file

@ -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 };

105
simulator/src/formulas.js Normal file
View file

@ -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,
};

283
simulator/src/grindfest.js Normal file
View file

@ -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 };

96
simulator/src/runner.js Normal file
View file

@ -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();
}

136
simulator/src/strategy.js Normal file
View file

@ -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 };