fix(monsters): MonLvl 缺行时告警而非静默返回 UNSCALED

缩放公式是 `原始表值 × scale ÷ 100`,回退到 `UNSCALED`(全 100)等于直接使用
`MonStats.txt` 的原始值:一只堕落者拿到 21-61 HP 而不是约 3 HP,约 20 倍。
原来这一步完全静默。

- `monsterScaleFor` 缺行时 `console.warn`(按 level 去重,避免每只怪刷一行),
  并新增 `setMonsterScalingStrict(true)` 让烘焙 / 校验脚本直接 throw。
- 新增 `monsterScaleLookup`,返回 `{ scale, missing }`,供调用方在不读 console
  的前提下自行判断。
- `PlannedLevel` 新增 `missingScalingLevels`,`planLevelMonsters` 把本关所有
  缺行的 level 汇总上报,烘焙方因此可以拒绝一个「怪物强度是假的」的关卡。

新增 4 个用例:告警一次、strict 抛错、lookup 不写 console、
`planLevelMonsters` 在 MonLvl 抽掉 level 1 行时报告 `[1]`。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 11:35:13 +00:00
parent e817ace5ef
commit 952ad00429
2 changed files with 171 additions and 7 deletions

View File

@ -482,15 +482,85 @@ export function monsterLevelFor(kind: MonsterKind, difficulty: Difficulty, areaL
return areaLevel > 0 ? areaLevel : kind.level[tier]
}
/**
* Whether a missing `MonLvl.txt` row is fatal.
*
* Off by default, because a hand-built fixture legitimately ships a two-row
* table. Turn it on in a bake or a verification script, where a missing row is a
* broken input rather than a deliberately small one.
*/
let scalingStrict = false
/**
* Levels already warned about, so one missing row is one line and not one line
* per monster rolled at that level.
*/
const warnedScaleLevels = new Set<number>()
/**
* Make a missing `MonLvl.txt` row throw instead of warn.
*
* @param strict - true to throw.
*/
export function setMonsterScalingStrict(strict: boolean): void {
scalingStrict = strict
if (!strict) warnedScaleLevels.clear()
}
/** The levels {@link monsterScaleFor} has had to fall back to {@link UNSCALED} for. */
export function monsterScalingMisses(): readonly number[] {
return [...warnedScaleLevels].sort((a, b) => a - b)
}
/** A scale lookup that says whether it found a row. */
export interface MonsterScaleLookup {
readonly scale: MonsterScale
/** True when the level had no `MonLvl.txt` row and {@link UNSCALED} was used. */
readonly missing: boolean
}
/**
* The multipliers for a level, with the miss reported rather than hidden.
*
* The caller needs this distinction because of what {@link UNSCALED} means: the
* formula is `raw × scale ÷ 100`, so falling back to 100 across the board uses
* the *raw table values* as game values. A Fallen then arrives with `MonStats`'
* 21–61 HP instead of the ~3 HP the level-1 row scales it down to, which reads
* as a balance problem and is in fact a missing row.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param level - the level, from {@link monsterLevelFor}.
* @returns the scale and whether the row was missing.
*/
export function monsterScaleLookup(scaling: ReadonlyMap<number, MonsterScale>, level: number): MonsterScaleLookup {
const found = scaling.get(level)
if (found !== undefined) return { scale: found, missing: false }
return { scale: UNSCALED, missing: true }
}
/**
* The multipliers for a level.
*
* A level with no row used to return {@link UNSCALED} silently; it now warns
* once per level, and throws when {@link setMonsterScalingStrict} is on. Callers
* that need to react rather than read the console use {@link monsterScaleLookup}.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param level - the level, from {@link monsterLevelFor}.
* @returns the scale, or {@link UNSCALED} when the level has no row.
* @throws when strict mode is on and the level has no row.
*/
export function monsterScaleFor(scaling: ReadonlyMap<number, MonsterScale>, level: number): MonsterScale {
return scaling.get(level) ?? UNSCALED
const lookup = monsterScaleLookup(scaling, level)
if (!lookup.missing) return lookup.scale
const message = `MonLvl.txt has no row for level ${String(level)}: `
+ `monsters there keep their raw MonStats values (about 20x too strong)`
if (scalingStrict) throw new Error(message)
if (!warnedScaleLevels.has(level)) {
warnedScaleLevels.add(level)
console.warn(message)
}
return lookup.scale
}
/** What `Levels.txt` says should live on one level. */
@ -1358,6 +1428,15 @@ export interface PlannedLevel {
readonly packs: readonly MonsterPack[]
/** How many packs are champion or unique. */
readonly elitePacks: number
/**
* Levels whose `MonLvl.txt` row was missing while rolling this population.
*
* Non-empty means some of {@link PlannedLevel.packs} carry raw `MonStats.txt`
* numbers rather than scaled ones — roughly 20x too strong — because
* {@link monsterScaleFor} had to fall back to {@link UNSCALED}. Empty is the
* healthy case, and the only one a bake should accept.
*/
readonly missingScalingLevels: readonly number[]
/** Fixed SuperUnique bosses planned for this level (e.g. Bishibosh, Rakanishu). */
readonly superUniques?: readonly string[]
}
@ -1399,12 +1478,21 @@ export function planLevelMonsters(
}[]
},
): PlannedLevel {
const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 }
const empty: PlannedLevel = {
levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0, missingScalingLevels: [],
}
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty)
if (plan === null) return empty
const kinds = readMonsterKinds(tables.monstats, difficulty)
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.
const missingScaling = new Set<number>()
const scaleAt = (level: number): MonsterScale => {
if (monsterScaleLookup(scaling, level).missing) missingScaling.add(level)
return monsterScaleFor(scaling, level)
}
// Three independent streams off the one seed. Sharing a single stream would
// make the type choice depend on how many monsters the density happened to
// ask for, so a level would change its monsters when its size changed.
@ -1418,7 +1506,7 @@ export function planLevelMonsters(
const members: MonsterStats[] = []
for (let i = 0; i < group.count; i += 1) {
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, monsterScaleFor(scaling, level))
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level))
// 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.
@ -1446,7 +1534,7 @@ export function planLevelMonsters(
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel))
const bossScaled: MonsterStats = {
...bossBase,
id: suSpec.monsterId,
@ -1458,7 +1546,7 @@ export function planLevelMonsters(
const suMembers: MonsterStats[] = [bossStats]
for (let m = 0; m < minionCount; m += 1) {
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel))
const mScaled: MonsterStats = {
...mBase,
id: suSpec.minionMonsterId,
@ -1486,6 +1574,7 @@ export function planLevelMonsters(
budget,
packs,
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
missingScalingLevels: [...missingScaling].sort((a, b) => a - b),
...(superUniques.length > 0 ? { superUniques } : {}),
}
}

View File

@ -7,7 +7,7 @@
* the expected answer exactly. `scripts/verify-monsters.ts` covers the other
* half, that the column names match the real 255-column file.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { D2Table } from '../src/game/acts.ts'
import {
DENSITY_CELLS_PER_MONSTER,
@ -16,7 +16,10 @@ import {
planMonsterGroups,
readEliteModifiers,
readLevelMonsterPlan,
planLevelMonsters,
monsterLevelFor, monsterScaleFor,
monsterScaleLookup,
setMonsterScalingStrict,
readMonsterArt,
readMonsterKinds,
readMonsterScaling,
@ -570,7 +573,79 @@ describe('monsterScaleFor', () => {
// Zero multipliers would silently produce a monster with one health and one
// damage, which looks like a working monster rather than like a gap.
const offTable: MonsterKind = { ...kinds.get('fallen1')!, level: [7, 7, 7] }
expect(monsterScaleFor(scaling, offTable.level[0])).toBe(UNSCALED)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(monsterScaleFor(scaling, offTable.level[0])).toBe(UNSCALED)
} finally {
warn.mockRestore()
}
})
it('warns instead of silently substituting UNSCALED', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
// 4242 has never been warned about, so the once-per-level guard cannot
// swallow this one.
monsterScaleFor(scaling, 4242)
expect(warn).toHaveBeenCalledTimes(1)
expect(String(warn.mock.calls[0]?.[0])).toContain('MonLvl.txt has no row for level 4242')
// Once per level, not once per monster rolled at that level.
monsterScaleFor(scaling, 4242)
expect(warn).toHaveBeenCalledTimes(1)
} finally {
warn.mockRestore()
}
})
it('throws in strict mode', () => {
setMonsterScalingStrict(true)
try {
expect(() => monsterScaleFor(scaling, 4243)).toThrow(/MonLvl\.txt has no row for level 4243/)
// A level the table does carry is still fine.
expect(monsterScaleFor(scaling, 1)).toEqual(scaling.get(1))
} finally {
setMonsterScalingStrict(false)
}
})
it('reports the miss to a caller that asks, without touching the console', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(monsterScaleLookup(scaling, 1)).toEqual({ scale: scaling.get(1), missing: false })
expect(monsterScaleLookup(scaling, 4244)).toEqual({ scale: UNSCALED, missing: true })
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
})
describe('planLevelMonsters reports missing MonLvl rows', () => {
const tables = { levels: LEVELS, monstats: MONSTATS, monlvl: MONLVL }
it('reports nothing when every rolled level has a row', () => {
const plan = planLevelMonsters(tables, 2, 6400, 1234, 170)
expect(plan.packs.length).toBeGreaterThan(0)
expect(plan.missingScalingLevels).toEqual([])
})
it('names the level whose row is missing instead of pretending it scaled', () => {
// Same table with the level-1 and level-2 rows taken out: the Blood Moor's
// monsters are all `Level` 1, so every one of them now rolls unscaled.
const thin = table(
['Level', 'AC', 'AC(N)', 'TH', 'TH(N)', 'HP', 'HP(N)', 'DM', 'DM(N)', 'XP', 'XP(N)'],
[['30', '180', '190', '309', '320', '133', '300', '19', '52', '660', '1600']],
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const plan = planLevelMonsters({ ...tables, monlvl: thin }, 2, 6400, 1234, 170)
expect(plan.packs.length).toBeGreaterThan(0)
expect(plan.missingScalingLevels).toEqual([1])
expect(warn).toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
})