diff --git a/src/game/monsters.ts b/src/game/monsters.ts index d4133e4..ed687bd 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -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() + +/** + * 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, 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, 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() + 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 } : {}), } } diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index b7fb471..625980f 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -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() + } }) })