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
This commit is contained in:
parent
bd7486cc99
commit
83f855597b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue