fix(monsters): wire meleeRange from MonsterArt into reach and reference MonUMod constants
TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
fd0f48c391
commit
bf101dae66
|
|
@ -238,6 +238,8 @@ export interface MonsterKind {
|
||||||
readonly minions: readonly string[]
|
readonly minions: readonly string[]
|
||||||
/** `SetBoss` — the pack leader becomes a unique. */
|
/** `SetBoss` — the pack leader becomes a unique. */
|
||||||
readonly setBoss: boolean
|
readonly setBoss: boolean
|
||||||
|
/** `MeleeRng` — melee reach in sub-tiles from `MonStats2.txt`. */
|
||||||
|
readonly meleeRange?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -751,6 +753,47 @@ export function readEliteModifiers(table: D2Table): EliteModifier[] {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the parameters table embedded in `MonUMod.txt`.
|
||||||
|
*
|
||||||
|
* The last two columns of `MonUMod.txt` ('constants' and '*constant desc') form
|
||||||
|
* a parameters table controlling general properties of champions, uniques, and minions.
|
||||||
|
*
|
||||||
|
* @param table - the parsed `MonUMod.txt`.
|
||||||
|
* @returns map of constant description to numeric value.
|
||||||
|
*/
|
||||||
|
export function readMonUModConstants(table: D2Table): Map<string, number> {
|
||||||
|
const constants = new Map<string, number>()
|
||||||
|
for (const row of table.rows) {
|
||||||
|
const desc = cell(table, row, '*constant desc').trim()
|
||||||
|
const val = num(table, row, 'constants', NaN)
|
||||||
|
if (desc !== '' && !Number.isNaN(val)) {
|
||||||
|
constants.set(desc, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return constants
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical constants extracted from `MonUMod.txt`. */
|
||||||
|
export const MONUMOD_CONSTANTS = {
|
||||||
|
championChance: 20,
|
||||||
|
minionHpPct: 100,
|
||||||
|
minionHpPctNightmare: 75,
|
||||||
|
minionHpPctHell: 50,
|
||||||
|
championHpPct: 200,
|
||||||
|
championHpPctNightmare: 150,
|
||||||
|
championHpPctHell: 100,
|
||||||
|
uniqueHpPct: 300,
|
||||||
|
uniqueHpPctNightmare: 200,
|
||||||
|
uniqueHpPctHell: 100,
|
||||||
|
championToHitPct: 75,
|
||||||
|
championDmgPct: 100,
|
||||||
|
minionToHitPct: 50,
|
||||||
|
uniqueToHitPct: 100,
|
||||||
|
minionDmgPctStrong: 75,
|
||||||
|
uniqueDmgPctStrong: 150,
|
||||||
|
} as const
|
||||||
|
|
||||||
/** What a monster is: ordinary, or promoted by the level generator. */
|
/** What a monster is: ordinary, or promoted by the level generator. */
|
||||||
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
|
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
|
||||||
|
|
||||||
|
|
@ -811,7 +854,7 @@ export function applyEliteModifiers(
|
||||||
let damage = base.damage
|
let damage = base.damage
|
||||||
let speed = base.speed
|
let speed = base.speed
|
||||||
if (modifiers.includes('strong')) {
|
if (modifiers.includes('strong')) {
|
||||||
damage = Math.max(1, Math.round(damage * 1.5))
|
damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100)))
|
||||||
}
|
}
|
||||||
if (modifiers.includes('fast')) {
|
if (modifiers.includes('fast')) {
|
||||||
speed = Math.round(speed * 1.4 * 100) / 100
|
speed = Math.round(speed * 1.4 * 100) / 100
|
||||||
|
|
@ -944,7 +987,7 @@ export function planMonsterGroups(
|
||||||
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
||||||
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
||||||
// packs are champions rather than a unique with minions.
|
// packs are champions rather than a unique with minions.
|
||||||
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < 0.2 ? 'champion' : 'unique'
|
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) ? 'champion' : 'unique'
|
||||||
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
||||||
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
||||||
groups.push({ kind, count, rank, modifiers })
|
groups.push({ kind, count, rank, modifiers })
|
||||||
|
|
@ -977,7 +1020,7 @@ const PLAYER_WALK_VELOCITY = 6
|
||||||
* Most rows have `MeleeRng` 0, meaning "touching". A cell is 80×40, so half a
|
* Most rows have `MeleeRng` 0, meaning "touching". A cell is 80×40, so half a
|
||||||
* cell's width is about as close as two bodies get.
|
* cell's width is about as close as two bodies get.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_REACH_PX = 40
|
export const DEFAULT_REACH_PX = 40
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ticks between monster attacks.
|
* Ticks between monster attacks.
|
||||||
|
|
@ -1023,6 +1066,7 @@ export function monsterStatsOf(
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
walkSpeedPx: number,
|
walkSpeedPx: number,
|
||||||
scale: MonsterScale = UNSCALED,
|
scale: MonsterScale = UNSCALED,
|
||||||
|
art?: MonsterArt | { readonly meleeRange?: number } | null,
|
||||||
): MonsterStats {
|
): MonsterStats {
|
||||||
const velocity = kind.velocity > 0 ? kind.velocity : 1
|
const velocity = kind.velocity > 0 ? kind.velocity : 1
|
||||||
// Roll first, scale second. Scaling the bounds and rolling between them
|
// Roll first, scale second. Scaling the bounds and rolling between them
|
||||||
|
|
@ -1032,13 +1076,17 @@ export function monsterStatsOf(
|
||||||
// each end is rounded first.
|
// each end is rounded first.
|
||||||
const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))
|
const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))
|
||||||
const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2
|
const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2
|
||||||
|
const meleeRng = art?.meleeRange ?? kind.meleeRange
|
||||||
|
const reach = (meleeRng !== undefined && meleeRng > 0)
|
||||||
|
? Math.max(DEFAULT_REACH_PX, meleeRng * PIXELS_PER_SUBTILE)
|
||||||
|
: DEFAULT_REACH_PX
|
||||||
return {
|
return {
|
||||||
id: kind.id,
|
id: kind.id,
|
||||||
name: kind.nameKey === '' ? kind.id : kind.nameKey,
|
name: kind.nameKey === '' ? kind.id : kind.nameKey,
|
||||||
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
|
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
|
||||||
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
||||||
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
||||||
reach: DEFAULT_REACH_PX,
|
reach,
|
||||||
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
|
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
|
||||||
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
|
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
|
||||||
xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
|
xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
|
||||||
|
|
@ -1053,8 +1101,8 @@ export function monsterStatsOf(
|
||||||
*/
|
*/
|
||||||
export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'minion'>, number>> = {
|
export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'minion'>, number>> = {
|
||||||
normal: 1,
|
normal: 1,
|
||||||
champion: 3,
|
champion: 1 + MONUMOD_CONSTANTS.championHpPct / 100,
|
||||||
unique: 4,
|
unique: 1 + MONUMOD_CONSTANTS.uniqueHpPct / 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
|
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
|
||||||
|
|
@ -1274,6 +1322,7 @@ export function planLevelMonsters(
|
||||||
readonly levels: D2Table
|
readonly levels: D2Table
|
||||||
readonly monstats: D2Table
|
readonly monstats: D2Table
|
||||||
readonly monlvl: D2Table
|
readonly monlvl: D2Table
|
||||||
|
readonly monstats2?: D2Table
|
||||||
readonly superuniques?: D2Table
|
readonly superuniques?: D2Table
|
||||||
},
|
},
|
||||||
levelId: number,
|
levelId: number,
|
||||||
|
|
@ -1297,6 +1346,7 @@ export function planLevelMonsters(
|
||||||
if (plan === null) return empty
|
if (plan === null) return empty
|
||||||
|
|
||||||
const kinds = readMonsterKinds(tables.monstats, difficulty)
|
const kinds = readMonsterKinds(tables.monstats, difficulty)
|
||||||
|
const artMap = tables.monstats2 ? readMonsterArt(tables.monstats2, kinds) : undefined
|
||||||
const scaling = readMonsterScaling(tables.monlvl, difficulty)
|
const scaling = readMonsterScaling(tables.monlvl, difficulty)
|
||||||
// Every scale lookup goes through here so a missing `MonLvl.txt` row reaches
|
// Every scale lookup goes through here so a missing `MonLvl.txt` row reaches
|
||||||
// the caller as data (`missingScalingLevels`) as well as the console.
|
// the caller as data (`missingScalingLevels`) as well as the console.
|
||||||
|
|
@ -1316,9 +1366,10 @@ export function planLevelMonsters(
|
||||||
const packs: MonsterPack[] = groups.map(group => {
|
const packs: MonsterPack[] = groups.map(group => {
|
||||||
const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank]
|
const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank]
|
||||||
const members: MonsterStats[] = []
|
const members: MonsterStats[] = []
|
||||||
|
const groupArt = artMap?.get(group.kind.id)
|
||||||
for (let i = 0; i < group.count; i += 1) {
|
for (let i = 0; i < group.count; i += 1) {
|
||||||
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
|
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
|
||||||
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level))
|
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt)
|
||||||
// An elite pack is one leader plus its minions: the leader carries the
|
// An elite pack is one leader plus its minions: the leader carries the
|
||||||
// rank's full bonus, the minions a smaller one, which is why a champion
|
// rank's full bonus, the minions a smaller one, which is why a champion
|
||||||
// pack reads as "a tough one and its friends" rather than as a wall.
|
// pack reads as "a tough one and its friends" rather than as a wall.
|
||||||
|
|
@ -1348,8 +1399,9 @@ export function planLevelMonsters(
|
||||||
const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1'))
|
const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1'))
|
||||||
if (!bossKind) continue
|
if (!bossKind) continue
|
||||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
|
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
|
||||||
|
const bossArt = artMap?.get(bossKind.id)
|
||||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel))
|
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt)
|
||||||
const bossScaled: MonsterStats = {
|
const bossScaled: MonsterStats = {
|
||||||
...bossBase,
|
...bossBase,
|
||||||
id: suSpec.monsterId,
|
id: suSpec.monsterId,
|
||||||
|
|
@ -1359,13 +1411,14 @@ export function planLevelMonsters(
|
||||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||||
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||||
const suMembers: MonsterStats[] = [bossStats]
|
const suMembers: MonsterStats[] = [bossStats]
|
||||||
|
const minionArt = artMap?.get(minionKind.id)
|
||||||
for (let m = 0; m < minionCount; m += 1) {
|
for (let m = 0; m < minionCount; m += 1) {
|
||||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||||
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel))
|
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt)
|
||||||
const mScaled: MonsterStats = {
|
const mScaled: MonsterStats = {
|
||||||
...mBase,
|
...mBase,
|
||||||
id: suSpec.minionMonsterId,
|
id: suSpec.minionMonsterId,
|
||||||
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
hp: Math.max(1, Math.round(mBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))),
|
||||||
}
|
}
|
||||||
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { D2Table } from '../src/game/acts.ts'
|
import type { D2Table } from '../src/game/acts.ts'
|
||||||
import {
|
import {
|
||||||
DENSITY_CELLS_PER_MONSTER,
|
DENSITY_CELLS_PER_MONSTER,
|
||||||
|
DEFAULT_REACH_PX,
|
||||||
|
ELITE_HEALTH_MULTIPLIER,
|
||||||
|
MONUMOD_CONSTANTS,
|
||||||
monsterBudget,
|
monsterBudget,
|
||||||
monsterStatsOf,
|
monsterStatsOf,
|
||||||
planMonsterGroups,
|
planMonsterGroups,
|
||||||
readEliteModifiers,
|
readEliteModifiers,
|
||||||
readLevelMonsterPlan,
|
readLevelMonsterPlan,
|
||||||
|
readMonUModConstants,
|
||||||
planLevelMonsters,
|
planLevelMonsters,
|
||||||
monsterLevelFor, monsterScaleFor,
|
monsterLevelFor, monsterScaleFor,
|
||||||
monsterScaleLookup,
|
monsterScaleLookup,
|
||||||
|
|
@ -530,6 +534,59 @@ describe('monsterStatsOf', () => {
|
||||||
const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' }
|
const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' }
|
||||||
expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1')
|
expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calculates reach from art meleeRange with DEFAULT_REACH_PX floor', () => {
|
||||||
|
const fallen = kinds.get('fallen1')!
|
||||||
|
expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 0 }).reach).toBe(DEFAULT_REACH_PX)
|
||||||
|
expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 1 }).reach).toBe(DEFAULT_REACH_PX)
|
||||||
|
expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 4 }).reach).toBe(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to kind.meleeRange when art is omitted', () => {
|
||||||
|
const withReach: MonsterKind = { ...kinds.get('fallen1')!, meleeRange: 5 }
|
||||||
|
expect(monsterStatsOf(withReach, new Rng(1), WALK).reach).toBe(80)
|
||||||
|
const base = kinds.get('fallen1')!
|
||||||
|
expect(monsterStatsOf(base, new Rng(1), WALK).reach).toBe(DEFAULT_REACH_PX)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readMonUModConstants and MonUMod constants', () => {
|
||||||
|
it('reads constant desc and numeric constant values from MonUMod table', () => {
|
||||||
|
const tableData = table(
|
||||||
|
['uniquemod', 'id', 'constants', '*constant desc'],
|
||||||
|
[
|
||||||
|
['', '', '20', 'champion chance'],
|
||||||
|
['', '', '100', 'minion +hp%'],
|
||||||
|
['', '', '200', 'champion +hp%'],
|
||||||
|
['', '', '300', 'unique +hp%'],
|
||||||
|
['', '', '150', 'unique +dmg% (strong)'],
|
||||||
|
['', '', '', 'empty row'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const constants = readMonUModConstants(tableData)
|
||||||
|
expect(constants.get('champion chance')).toBe(20)
|
||||||
|
expect(constants.get('minion +hp%')).toBe(100)
|
||||||
|
expect(constants.get('champion +hp%')).toBe(200)
|
||||||
|
expect(constants.get('unique +hp%')).toBe(300)
|
||||||
|
expect(constants.get('unique +dmg% (strong)')).toBe(150)
|
||||||
|
expect(constants.has('empty row')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provides canonical MONUMOD_CONSTANTS with expected values', () => {
|
||||||
|
expect(MONUMOD_CONSTANTS.championChance).toBe(20)
|
||||||
|
expect(MONUMOD_CONSTANTS.minionHpPct).toBe(100)
|
||||||
|
expect(MONUMOD_CONSTANTS.championHpPct).toBe(200)
|
||||||
|
expect(MONUMOD_CONSTANTS.uniqueHpPct).toBe(300)
|
||||||
|
expect(MONUMOD_CONSTANTS.uniqueDmgPctStrong).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates ELITE_HEALTH_MULTIPLIER using MonUMod constants', () => {
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.normal).toBe(1)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.champion).toBe(1 + MONUMOD_CONSTANTS.championHpPct / 100)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.unique).toBe(1 + MONUMOD_CONSTANTS.uniqueHpPct / 100)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.champion).toBe(3)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.unique).toBe(4)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('readMonsterScaling', () => {
|
describe('readMonsterScaling', () => {
|
||||||
|
|
@ -649,6 +706,25 @@ describe('planLevelMonsters reports missing MonLvl rows', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('planLevelMonsters with monstats2 table', () => {
|
||||||
|
it('wires monster art into monster stats reach', () => {
|
||||||
|
const monstats2WithReach = table(
|
||||||
|
['Id', 'SizeX', 'SizeY', 'pixHeight', 'MeleeRng', 'BaseW', 'TotalPieces', 'dDT'],
|
||||||
|
[
|
||||||
|
['fallen1', '2', '2', '64', '4', 'hth', '6', '8'],
|
||||||
|
['zombie1', '2', '2', '80', '0', 'hth', '1', '8'],
|
||||||
|
['quillrat1', '2', '2', '48', '1', 'hth', '1', '8'],
|
||||||
|
['quillrat6', '2', '2', '48', '1', 'hth', '1', '8'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const tables = { levels: LEVELS, monstats: MONSTATS, monlvl: MONLVL, monstats2: monstats2WithReach }
|
||||||
|
const plan = planLevelMonsters(tables, 2, 6400, 1234, 170)
|
||||||
|
const fallenMember = plan.packs.flatMap(p => p.members).find(m => m.id === 'fallen1')
|
||||||
|
expect(fallenMember).toBeDefined()
|
||||||
|
expect(fallenMember?.reach).toBe(64)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('monsterStatsOf with MonLvl scaling', () => {
|
describe('monsterStatsOf with MonLvl scaling', () => {
|
||||||
const kinds = readMonsterKinds(MONSTATS)
|
const kinds = readMonsterKinds(MONSTATS)
|
||||||
const scaling = readMonsterScaling(MONLVL)
|
const scaling = readMonsterScaling(MONLVL)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue