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:
parent
402db6bb2f
commit
6c2bb2f8f3
11 changed files with 1077 additions and 0 deletions
52
simulator/README.md
Normal file
52
simulator/README.md
Normal 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
|
||||
12
simulator/configs/aggressive.json
Normal file
12
simulator/configs/aggressive.json
Normal 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
|
||||
}
|
||||
12
simulator/configs/conservative.json
Normal file
12
simulator/configs/conservative.json
Normal 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
|
||||
}
|
||||
12
simulator/configs/default.json
Normal file
12
simulator/configs/default.json
Normal 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
12
simulator/package.json
Normal 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"
|
||||
}
|
||||
70
simulator/results/test.json
Normal file
70
simulator/results/test.json
Normal 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
287
simulator/src/entities.js
Normal 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
105
simulator/src/formulas.js
Normal 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
283
simulator/src/grindfest.js
Normal 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
96
simulator/src/runner.js
Normal 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
136
simulator/src/strategy.js
Normal 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 };
|
||||
Loading…
Reference in a new issue