From bf101dae66c1ac355ece887c77b1f14a452a8fbc Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:03:21 +0000 Subject: [PATCH] fix(monsters): wire meleeRange from MonsterArt into reach and reference MonUMod constants TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monsters.ts | 73 ++++++++++++++++++++++++++++++++++------ tests/monsters.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index b62a896..e92e0b2 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -238,6 +238,8 @@ export interface MonsterKind { readonly minions: readonly string[] /** `SetBoss` — the pack leader becomes a unique. */ 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 } +/** + * 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 { + const constants = new Map() + 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. */ export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion' @@ -811,7 +854,7 @@ export function applyEliteModifiers( let damage = base.damage let speed = base.speed 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')) { 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))) // `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite // packs are champions rather than a unique with minions. - const rank: Exclude = rng.next() < 0.2 ? 'champion' : 'unique' + const rank: Exclude = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) ? 'champion' : 'unique' const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2) const modifiers = rollEliteModifiers(rank, new Rng(modSeed)) 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 * 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. @@ -1023,6 +1066,7 @@ export function monsterStatsOf( rng: Rng, walkSpeedPx: number, scale: MonsterScale = UNSCALED, + art?: MonsterArt | { readonly meleeRange?: number } | null, ): MonsterStats { const velocity = kind.velocity > 0 ? kind.velocity : 1 // Roll first, scale second. Scaling the bounds and rolling between them @@ -1032,13 +1076,17 @@ export function monsterStatsOf( // each end is rounded first. 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 meleeRng = art?.meleeRange ?? kind.meleeRange + const reach = (meleeRng !== undefined && meleeRng > 0) + ? Math.max(DEFAULT_REACH_PX, meleeRng * PIXELS_PER_SUBTILE) + : DEFAULT_REACH_PX return { id: kind.id, name: kind.nameKey === '' ? kind.id : kind.nameKey, hp: Math.max(1, Math.round((rolled * scale.health) / 100)), damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)), cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS, - reach: DEFAULT_REACH_PX, + reach, aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX, speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)), xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)), @@ -1053,8 +1101,8 @@ export function monsterStatsOf( */ export const ELITE_HEALTH_MULTIPLIER: Readonly, number>> = { normal: 1, - champion: 3, - unique: 4, + champion: 1 + MONUMOD_CONSTANTS.championHpPct / 100, + unique: 1 + MONUMOD_CONSTANTS.uniqueHpPct / 100, } /** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */ @@ -1274,6 +1322,7 @@ export function planLevelMonsters( readonly levels: D2Table readonly monstats: D2Table readonly monlvl: D2Table + readonly monstats2?: D2Table readonly superuniques?: D2Table }, levelId: number, @@ -1297,6 +1346,7 @@ export function planLevelMonsters( if (plan === null) return empty const kinds = readMonsterKinds(tables.monstats, difficulty) + const artMap = tables.monstats2 ? readMonsterArt(tables.monstats2, kinds) : undefined const scaling = readMonsterScaling(tables.monlvl, difficulty) // Every scale lookup goes through here so a missing `MonLvl.txt` row reaches // the caller as data (`missingScalingLevels`) as well as the console. @@ -1316,9 +1366,10 @@ export function planLevelMonsters( const packs: MonsterPack[] = groups.map(group => { const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank] const members: MonsterStats[] = [] + const groupArt = artMap?.get(group.kind.id) for (let i = 0; i < group.count; i += 1) { 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 // 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. @@ -1348,8 +1399,9 @@ export function planLevelMonsters( const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1')) if (!bossKind) continue 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 bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel)) + const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt) const bossScaled: MonsterStats = { ...bossBase, id: suSpec.monsterId, @@ -1359,13 +1411,14 @@ export function planLevelMonsters( const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id) const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions) const suMembers: MonsterStats[] = [bossStats] + const minionArt = artMap?.get(minionKind.id) for (let m = 0; m < minionCount; m += 1) { 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 = { ...mBase, 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', [])) } diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 625980f..12edbc0 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -11,11 +11,15 @@ import { describe, expect, it, vi } from 'vitest' import type { D2Table } from '../src/game/acts.ts' import { DENSITY_CELLS_PER_MONSTER, + DEFAULT_REACH_PX, + ELITE_HEALTH_MULTIPLIER, + MONUMOD_CONSTANTS, monsterBudget, monsterStatsOf, planMonsterGroups, readEliteModifiers, readLevelMonsterPlan, + readMonUModConstants, planLevelMonsters, monsterLevelFor, monsterScaleFor, monsterScaleLookup, @@ -530,6 +534,59 @@ describe('monsterStatsOf', () => { const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' } 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', () => { @@ -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', () => { const kinds = readMonsterKinds(MONSTATS) const scaling = readMonsterScaling(MONLVL)