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:
parent
e817ace5ef
commit
952ad00429
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue