From 83f855597bc0b86f1b65f64d65f5270bbbfdb25e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 15:56:33 +0000 Subject: [PATCH] fix(monsters): eliminate unwired columns, dead logic, and wire elite modifier scaling - Support up to mon25 columns in monsterColumns stopping if column does not exist - Weight normal pack type selection by kind.rarity in planMonsterGroups - Wire mechanical stat scaling for stoneskin, cursed, coldenchant, fireenchant, lightenchant, spectralhit, teleport, magicresistant, and aura in applyEliteModifiers - Add unit tests for monsterColumns, rarity-weighted pack planning, and elite modifier stat scaling TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monsters.ts | 49 ++++++++++++++++++++++--- tests/monsters.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index e92e0b2..2e78f9a 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -605,10 +605,12 @@ export interface LevelMonsterPlan { * @param prefix - `mon`, `nmon` or `umon`. * @returns the non-empty entries, in column order. */ -function monsterColumns(table: D2Table, row: readonly string[], prefix: string): string[] { +export function monsterColumns(table: D2Table, row: readonly string[], prefix: string): string[] { const out: string[] = [] - for (let slot = 1; slot <= 10; slot += 1) { - const value = cell(table, row, `${prefix}${String(slot)}`).trim() + for (let slot = 1; slot <= 25; slot += 1) { + const col = `${prefix}${String(slot)}` + if (!table.header.includes(col)) break + const value = cell(table, row, col).trim() if (value !== '') out.push(value) } return out @@ -851,6 +853,7 @@ export function applyEliteModifiers( modifiers: readonly string[] = [], superUniqueId?: string, ): MonsterStats { + let hp = base.hp let damage = base.damage let speed = base.speed if (modifiers.includes('strong')) { @@ -859,8 +862,37 @@ export function applyEliteModifiers( if (modifiers.includes('fast')) { speed = Math.round(speed * 1.4 * 100) / 100 } + if (modifiers.includes('stoneskin')) { + hp = Math.round(hp * 2) + } + if (modifiers.includes('cursed')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('coldenchant')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('fireenchant')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('lightenchant')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('spectralhit')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('teleport')) { + speed = Math.round(speed * 1.2 * 100) / 100 + } + if (modifiers.includes('magicresistant')) { + hp = Math.round(hp * 1.2) + } + if (modifiers.includes('aura')) { + damage = Math.round(damage * 1.2) + speed = Math.round(speed * 1.1 * 100) / 100 + } return { ...base, + hp, damage, speed, rank, @@ -994,8 +1026,17 @@ export function planMonsterGroups( remaining -= count } + const totalRarity = types.reduce((sum, kind) => sum + Math.max(1, kind.rarity), 0) while (remaining > 0) { - const kind = types[rng.int(0, types.length - 1)]! + let ticket = rng.next() * totalRarity + let kind = types[types.length - 1]! + for (let j = 0; j < types.length; j += 1) { + ticket -= Math.max(1, types[j]!.rarity) + if (ticket <= 0) { + kind = types[j]! + break + } + } const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup))) groups.push({ kind, count, rank: 'normal' }) remaining -= count diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 12edbc0..d7e49e2 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -29,6 +29,8 @@ import { readMonsterScaling, readSuperUniques, selectLevelTypes, + applyEliteModifiers, + monsterColumns, UNSCALED, } from '../src/game/monsters.ts' import type { MonsterKind } from '../src/game/monsters.ts' @@ -477,6 +479,22 @@ describe('planMonsterGroups', () => { expect(planMonsterGroups(plan, types, kinds, 0, new Rng(1))).toEqual([]) expect(planMonsterGroups(plan, [], kinds, 10, new Rng(1))).toEqual([]) }) + + it('weights normal pack type selection using kind.rarity', () => { + const rareKind: MonsterKind = { ...types[0]!, id: 'rare', rarity: 1, minGroup: 1, maxGroup: 1 } + const commonKind: MonsterKind = { ...types[0]!, id: 'common', rarity: 100, minGroup: 1, maxGroup: 1 } + const customTypes = [rareKind, commonKind] + const customPlan = { ...plan, eliteMin: 0, eliteMax: 0 } + let commonCount = 0 + let rareCount = 0 + const rng = new Rng(42) + for (let i = 0; i < 200; i += 1) { + const groups = planMonsterGroups(customPlan, customTypes, kinds, 1, rng) + if (groups[0]?.kind.id === 'common') commonCount += 1 + if (groups[0]?.kind.id === 'rare') rareCount += 1 + } + expect(commonCount).toBeGreaterThan(rareCount * 10) + }) }) describe('monsterStatsOf', () => { @@ -783,3 +801,66 @@ describe('monsterStatsOf with MonLvl scaling', () => { expect(withLevel50.xp).toBeGreaterThan(scaledNormal.xp * 20) }) }) + +describe('monsterColumns', () => { + it('reads columns up to slot 25 and stops when column is missing in header', () => { + const header = ['Id', 'mon1', 'mon2', 'mon3', 'mon4', 'mon5'] + const row = ['1', 'fallen', 'zombie', '', 'skeleton', ''] + const cols = monsterColumns({ header, rows: [row] }, row, 'mon') + expect(cols).toEqual(['fallen', 'zombie', 'skeleton']) + }) + + it('supports up to mon25 when present in header', () => { + const header = ['Id', ...Array.from({ length: 25 }, (_, i) => `mon${String(i + 1)}`)] + const row = ['1', ...Array.from({ length: 25 }, (_, i) => (i === 24 ? 'hellbovine' : ''))] + const cols = monsterColumns({ header, rows: [row] }, row, 'mon') + expect(cols).toEqual(['hellbovine']) + }) +}) + +describe('applyEliteModifiers stat scaling', () => { + const baseStats = { + id: 'zombie', + name: 'Zombie', + hp: 100, + damage: 10, + speed: 10, + reach: 40, + aggroRadius: 200, + cooldownTicks: 25, + xp: 50, + } + + it('applies stoneskin hp scaling (2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['stoneskin']) + expect(res.hp).toBe(200) + }) + + it('applies cursed damage scaling (1.2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['cursed']) + expect(res.damage).toBe(12) + }) + + it('applies elemental and spectralhit damage scaling (1.2x)', () => { + expect(applyEliteModifiers(baseStats, 'unique', ['coldenchant']).damage).toBe(12) + expect(applyEliteModifiers(baseStats, 'unique', ['fireenchant']).damage).toBe(12) + expect(applyEliteModifiers(baseStats, 'unique', ['lightenchant']).damage).toBe(12) + expect(applyEliteModifiers(baseStats, 'unique', ['spectralhit']).damage).toBe(12) + }) + + it('applies teleport speed scaling (1.2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['teleport']) + expect(res.speed).toBe(12) + }) + + it('applies magicresistant hp scaling (1.2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['magicresistant']) + expect(res.hp).toBe(120) + }) + + it('applies aura damage (1.2x) and speed (1.1x) scaling', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['aura']) + expect(res.damage).toBe(12) + expect(res.speed).toBe(11) + }) +})