From cc12e7bc661e9f2ed80032198ced5f62d2bbe58f Mon Sep 17 00:00:00 2001 From: taodao Date: Tue, 15 Sep 2026 08:22:48 +0000 Subject: [PATCH] =?UTF-8?q?M7=20=E7=AC=AC=E4=BA=8C=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E6=80=AA=E7=89=A9=E6=8E=A5=E8=BF=9B=E5=85=B3=E5=8D=A1=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E4=B8=8A=20MonLvl=20=E7=BC=A9=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage A 把 MonStats.txt 读了出来,接上去才发现读出来的数字不能用: 一只一级堕落者按表里的 A1MinD=51 / A1MaxD=101 算,一击 76 点伤害, 而玩家只有 60 点血。 MonStats.txt 里的数字不是游戏里的数字。真实公式是 最终值 = MonStats[列] × MonLvl[怪物等级][对应列] ÷ 100 这不是猜的。同一个公式让五个互不相干的列同时对上游戏已知数值: fallen1 的 Exp 61→18.3(游戏 18)、zombie1 的 111→33.3(33)、 quillrat1 的 71→21.3(21)、fallen1 的 AC 84→5.0(5)、 A1TH 101→8.1(8)。五发全中不是巧合,所以照做而不是推给 M9。 顺带纠正我在 Stage A 里写下的判断:MinHP(N)/MinHP(H) 不是噩梦和地狱 的血量,594 个有血量的行里有 426 行的 MaxHP(N) 比普通还低。难度靠的 是怪物自己的 Level(N)=36、Level(H)=67 再过一遍 MonLvl 的 HP(N)/HP(H), 这才是噩梦堕落者有 131-289 血而不是 25-55 血的原因。MonLvl 的 L- 前缀 列(扩展版那套)在 1.13c 里和原列逐字节相同,读哪套都一样。 boss 查过要不要豁免缩放,没有豁免:缩放后的 Diablo 是 13818,记忆里 是 13741;缩放后的 Andariel 是 1025,记忆里约 960;但 Duriel 常被引用 的 4757 是原值。凭这种强度的记忆去开特例就是猜了,幕 boss 单独处理。 接线: - monsters.ts 新增 readMonsterScaling / monsterScaleFor / UNSCALED, monsterStatsOf 接受一个 MonsterScale。先掷骰再缩放——低等级的乘数 只有百分之几,先缩放边界会把 21..61 压成 1..4 再四舍五入,一个范围 里四个值有三个取不到。 - 新增 planLevelMonsters:从关卡 id 一路走到掷好点数的怪物群。在线 路径和资源包烘焙都走它,「烘焙出来的关卡和在线的一致」因此是个性质 而不是巧合。 - engine.ts 新增 monsterPacks 选项,优先于 stats + monsterCount。 - act-scene.ts 两条路径都接上;资源包路径读 scene.json 里新烘焙的 monsters 字段,浏览器在这条路径上没有 Levels.txt 可读。 - pack-act-assets.ts 烘焙怪物,每张图 scene.json 增加约 4 KB。 血色荒野现在是:堕落者 2-4 血 / 2 伤 / 18 经验,僵尸 8-13 血 / 33 经验 / 移速 28(堕落者是 142),棘鼠 2-5 血 / 21 经验。51 只怪 分 30 群。 验证:tsc 零错误;npm test 523 通过(基线 461); npm run verify:monsters 65/65;npm run verify:all 通过; npm run verify:packs 1302/1302 逐像素断言通过。 TAG=agy CONV=c89513df-cd12-4749-b5bd-5e9f16639391 --- scripts/pack-act-assets.ts | 30 +++++ scripts/verify-monsters.ts | 75 +++++++++++- src/game/engine.ts | 25 +++- src/game/monsters.ts | 226 ++++++++++++++++++++++++++++++++++--- src/scene/act-scene.ts | 66 +++++++++-- tests/monsters.test.ts | 112 ++++++++++++++++++ 6 files changed, 500 insertions(+), 34 deletions(-) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 0c49650..d332d70 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -38,6 +38,7 @@ import { decodeDt1 } from '../src/formats/dt1.ts' import type { Dt1 } from '../src/formats/dt1.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' +import { planLevelMonsters } from '../src/game/monsters.ts' import type { IsoMapScene } from '../src/game/d2map.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts' import { decodeDcc } from '../src/formats/dcc.ts' @@ -64,6 +65,16 @@ const PAGE_SIZE = 2048 const HOT_RADIUS_CELLS = 8 /** Where object art lives. */ const OBJECT_PREFIX = 'data\\global\\objects\\' +/** + * The player's walking speed in scene pixels, matching `act-scene.ts`. + * + * Monster speeds are expressed relative to it, so the baker has to know the + * same number the browser does. Kept as a literal on both sides rather than + * shared through a module because `act-scene.ts` is a browser entry point and + * this script must not pull it in. + */ +const PLAYER_WALK_SPEED_PX = 170 + /** Where monster and town-NPC art lives. */ /** * Mode directories tried in order when picking an object's art. @@ -850,6 +861,18 @@ async function bakeDs1Variant( } runs.push([current, count]) + // The level's population, planned here so the browser never needs + // `Levels.txt` or `MonStats.txt` on the pack path. The same seed the scene + // geometry used drives it, so a pack and a live mount of the same level agree + // on what lives there. + const population = planLevelMonsters( + tables, + entry.levelId, + scene.cellsX * scene.cellsY, + seed, + PLAYER_WALK_SPEED_PX, + ) + const sceneJson = { version: 1, fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', @@ -877,6 +900,11 @@ async function bakeDs1Variant( roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), objects, npcs, + monsters: { + types: population.types, + budget: population.budget, + packs: population.packs, + }, collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], stats: { @@ -889,6 +917,8 @@ async function bakeDs1Variant( walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length, objects: objects.length, npcs: npcs.length, + monsters: population.packs.reduce((sum, pack) => sum + pack.members.length, 0), + monsterPacks: population.packs.length, objectsWithArt, objectsUnresolved: missingObjects, objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, diff --git a/scripts/verify-monsters.ts b/scripts/verify-monsters.ts index 9f3262c..30c5b0a 100644 --- a/scripts/verify-monsters.ts +++ b/scripts/verify-monsters.ts @@ -25,6 +25,8 @@ import { readLevelMonsterPlan, readMonsterKinds, readMonsterArt, + readMonsterScaling, + monsterScaleFor, readSuperUniques, selectLevelTypes, } from '../src/game/monsters.ts' @@ -128,7 +130,7 @@ let withHealth = 0 let withDamage = 0 const statRng = new Rng(1) for (const kind of kinds.values()) { - const stats = monsterStatsOf(kind, statRng, WALK_SPEED) + const stats = monsterStatsOf(kind, statRng, WALK_SPEED, monsterScaleFor(readMonsterScaling(tables.monlvl), kind)) if (kind.enabled && kind.isSpawn && !kind.npc && !kind.inTown) spawnable += 1 if (kind.maxHp > 0) withHealth += 1 if (kind.attack1.maxDamage > 0) withDamage += 1 @@ -140,8 +142,7 @@ for (const kind of kinds.values()) { Number.isInteger(stats.speed) && stats.speed >= 1 && stats.cooldownTicks > 0 && stats.reach > 0 && stats.aggroRadius > 0 && stats.xp >= 0 && - stats.hp >= Math.min(kind.minHp, kind.maxHp) && - stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp)) + stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp) * 400) if (!sane) { statFailures += 1 if (statFailures <= 5) console.log(` ${kind.id}: ${JSON.stringify(stats)}`) @@ -260,6 +261,74 @@ console.log(`\n== difficulty columns are read, and are not a scaling ==`) // --------------------------------------------------------------------------- // Levels.txt monster columns. // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// MonLvl.txt scaling — the reconciliation that justifies applying it. +// --------------------------------------------------------------------------- +console.log(`\n== MonLvl.txt scaling reconciles the table with the game ==`) +const scaling = readMonsterScaling(tables.monlvl) +check(scaling.size > 90, `${String(scaling.size)} monster levels have multipliers`) + +// Every level any monster claims must have a row, or that monster would be +// silently left unscaled and arrive with hundreds of health. +const unscaled: number[] = [] +for (const kind of kinds.values()) { + for (const level of kind.level) { + if (level > 0 && !scaling.has(level)) unscaled.push(level) + } +} +check(unscaled.length === 0, `every monster level has a MonLvl row (${String(new Set(unscaled).size)} missing)`) + +// The five independent columns that pinned the formula down. If a future change +// breaks one of these, the scaling has stopped being the game's. +interface Reconciliation { + readonly id: string + readonly what: string + readonly expected: number + readonly raw: (kind: MonsterKind) => number + readonly read: (kind: MonsterKind, scale: { armour: number; toHit: number; experience: number }) => number +} +const RECONCILE: readonly Reconciliation[] = [ + { id: 'fallen1', what: 'Exp', expected: 18, raw: k => k.experience, read: (k, s2) => (k.experience * s2.experience) / 100 }, + { id: 'zombie1', what: 'Exp', expected: 33, raw: k => k.experience, read: (k, s2) => (k.experience * s2.experience) / 100 }, + { id: 'quillrat1', what: 'Exp', expected: 21, raw: k => k.experience, read: (k, s2) => (k.experience * s2.experience) / 100 }, + { id: 'fallen1', what: 'AC', expected: 5, raw: k => k.armour, read: (k, s2) => (k.armour * s2.armour) / 100 }, + { id: 'fallen1', what: 'A1TH', expected: 8, raw: k => k.attack1.toHit, read: (k, s2) => (k.attack1.toHit * s2.toHit) / 100 }, +] +for (const want of RECONCILE) { + const kind = kinds.get(want.id)! + const got = want.read(kind, monsterScaleFor(scaling, kind)) + check( + Math.round(got) === want.expected || Math.floor(got) === want.expected, + `${want.id} ${want.what} ${String(want.raw(kind))} → ${got.toFixed(1)}, the game shows ${String(want.expected)}`, + ) +} + +// The scaled numbers have to be playable, which the raw ones are not: a level-1 +// Fallen straight off the table hits for 76, and the player has 60 health. +{ + const fallen = kinds.get('fallen1')! + const raw = monsterStatsOf(fallen, new Rng(1), WALK_SPEED) + const scaled = monsterStatsOf(fallen, new Rng(1), WALK_SPEED, monsterScaleFor(scaling, fallen)) + check(raw.damage > 50, `unscaled fallen1 hits for ${String(raw.damage)}, which would one-shot the player`) + check(scaled.damage <= 3, `scaled fallen1 hits for ${String(scaled.damage)}`) + check(scaled.hp <= 6, `scaled fallen1 has ${String(scaled.hp)} health`) + check(scaled.xp === 18, `scaled fallen1 awards ${String(scaled.xp)} experience`) +} + +// Difficulty raises the monster's level, and the level raises everything else. +{ + const fallen = kinds.get('fallen1')! + const hell = readMonsterKinds(tables.monstats, 'hell').get('fallen1')! + const hellScale = monsterScaleFor(readMonsterScaling(tables.monlvl, 'hell'), hell, 'hell') + const normal = monsterStatsOf(fallen, new Rng(1), WALK_SPEED, monsterScaleFor(scaling, fallen)) + const scaledHell = monsterStatsOf(hell, new Rng(1), WALK_SPEED, hellScale) + check( + scaledHell.hp > normal.hp * 50, + `a Hell Fallen (level ${String(hell.level[2])}) has ${String(scaledHell.hp)} health against a Normal one's ${String(normal.hp)}`, + ) + check(scaledHell.xp > normal.xp * 50, `and awards ${String(scaledHell.xp)} experience against ${String(normal.xp)}`) +} + console.log(`\n== Levels.txt monster plans ==`) interface LevelExpectation { readonly id: number diff --git a/src/game/engine.ts b/src/game/engine.ts index 39a2aec..c6405a8 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -1,5 +1,5 @@ -import { tickCombat, createWorld, spawnMonsters, damageMonster, rebindPlayer } from './combat.ts' -import type { CombatWorld, CombatOptions, MonsterStats } from './combat.ts' +import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts' +import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats } from './combat.ts' import { Inventory, rollDrop, goldItem, totalStat } from './items.ts' import type { Item, ItemBase, Affix } from './items.ts' import { castSkill, tickProjectiles } from './skills.ts' @@ -80,6 +80,18 @@ export interface GameEngineOptions { * disc around the entrance, so this is a sandbox affordance, not a rule. */ monsterSpread?: number + /** + * Pre-planned packs, which take precedence over + * {@link GameEngineOptions.stats} and {@link GameEngineOptions.monsterCount}. + * + * A level read from `Levels.txt` does not produce a count and a type list; it + * produces groups — two to three Fallen here, a Quill Rat pair there — and + * flattening that back into "spawn 20 monsters from these 3 types" loses the + * grouping, which is most of what makes a level feel populated. Scenes that + * have planned their level pass packs; fixtures that just want some monsters + * to hit keep passing a count. + */ + monsterPacks?: readonly MonsterPack[] /** * Whether to place NPCs on a synthetic ring around the spawn. * @@ -148,9 +160,12 @@ export class GameEngine { constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { this.world = createWorld(opts.spawn.x, opts.spawn.y) - spawnMonsters( - this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, opts.monsterSpread ?? 260, this.terrain, - ) + const spread = opts.monsterSpread ?? 260 + if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { + spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain) + } else { + spawnMonsters(this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, spread, this.terrain) + } this.loot = new Rng(opts.lootSeed ?? 0x5eed) this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows) this.questLog = new QuestLog(opts.questDefs) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 9ad03bb..6814912 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -22,26 +22,57 @@ * resistances, `TreasureClass*`, and every `Levels.txt` monster column. * * Engine-side approximations, each marked at its definition: - * - **Difficulty scaling is not applied, and the difficulty columns are not it.** - * `MonStats.txt` has `MinHP(N)`/`MinHP(H)` columns, and they are *not* the - * Nightmare and Hell health. Measured against the 1.13c drop: `fallen1` is - * 21–61 on Normal but 25–55 on both Nightmare and Hell, 426 of the 594 rows - * with health have a **lower** `MaxHP(N)` than `maxHP`, and Duriel — a boss, - * where these columns do apply — drops from 4757 to 1295 on Hell. The real - * scaling comes from `MonLvl.txt`'s per-level multipliers applied to the - * Normal values; the difficulty columns are a boss-only override that the - * data itself uses inconsistently. They are read and kept so M9 (真实战斗数学) - * has them, and this module does not pretend they mean more than that. * - **Speed, reach, aggro radius and attack cooldown** have no direct pixel or * tick equivalent in the tables. They are derived from the player's own * numbers by a documented ratio rather than invented. * - **Density → monster count.** `MonDen` is consumed by the real level * generator per *room*; we have no rooms until M8. The conversion here is an * area-proportional approximation and says so. + * + * ## The numbers in `MonStats.txt` are not the numbers in the game + * + * A Fallen's row says 21–61 health and 51–101 damage. A Fallen in the Blood + * Moor has about three health and hits for one. The row holds *per-mille-ish* + * figures that `MonLvl.txt` turns into real ones: + * + * ``` + * final = MonStats[column] × MonLvl[monster level][matching column] ÷ 100 + * ``` + * + * That was not assumed, it was measured: it reconciles five independent columns + * against values the game is known to show, all at once. + * + * | monster | column | table | × MonLvl ÷ 100 | in game | + * |---|---|---|---|---| + * | fallen1 | `Exp` | 61 | 18.3 | 18 | + * | zombie1 | `Exp` | 111 | 33.3 | 33 | + * | quillrat1 | `Exp` | 71 | 21.3 | 21 | + * | fallen1 | `AC` | 84 | 5.0 | 5 | + * | fallen1 | `A1TH` | 101 | 8.1 | 8 | + * + * Five exact hits is not a coincidence, so the scaling is applied rather than + * deferred. Two consequences worth stating: + * + * - The difficulty columns `MinHP(N)`/`MinHP(H)` are *not* Nightmare and Hell + * health either. `fallen1` is 21–61 on Normal and 25–55 on both harder ones, + * 426 of the 594 rows with health have a **lower** `MaxHP(N)` than `maxHP`, + * and Duriel drops from 4757 to 1295 on Hell. Difficulty comes from the + * monster's own `Level(N)`/`Level(H)` — a Fallen is level 1, then 36, then 67 + * — fed through `MonLvl`'s `HP(N)`/`HP(H)` columns. That is what makes a + * Nightmare Fallen a 131–289 health monster rather than a 25–55 one. + * - `MonLvl.txt`'s `L-` prefixed columns, the expansion set, are byte-identical + * to the classic ones throughout 1.13c, so which set is read makes no + * difference. The classic ones are used. + * + * Bosses were checked for an exemption and did not get one. The data is not + * consistent enough to justify carving one out: scaled Diablo lands on 13818 + * against a remembered 13741 and scaled Andariel on 1025 against ~960, but + * Duriel's raw 4757 is the figure usually quoted for him. Inventing a special + * case on that basis would be guessing; the act bosses get their own pass. */ import type { D2Table } from './acts.ts' import { cell } from './acts.ts' -import type { MonsterStats } from './combat.ts' +import type { MonsterPack, MonsterStats } from './combat.ts' import { Rng } from './rng.ts' /** Which difficulty a number is being read for. */ @@ -353,6 +384,75 @@ export function readMonsterArt(table: D2Table, kinds: ReadonlyMap { + const d = difficultySuffix(difficulty) + const scales = new Map() + for (const row of table.rows) { + const level = num(table, row, 'Level', -1) + if (level < 0) continue + scales.set(level, { + armour: num(table, row, `AC${d}`), + toHit: num(table, row, `TH${d}`), + health: num(table, row, `HP${d}`), + damage: num(table, row, `DM${d}`), + experience: num(table, row, `XP${d}`), + }) + } + return scales +} + +/** No scaling: every column passes through unchanged. */ +export const UNSCALED: MonsterScale = { armour: 100, toHit: 100, health: 100, damage: 100, experience: 100 } + +/** + * The scale one monster is subject to on one difficulty. + * + * The level to look up is the monster's own for that difficulty — `Level`, + * `Level(N)` or `Level(H)` — not the level it is standing in. A Fallen is level + * 1, 36 and 67 on the three difficulties, and that is the whole of what makes + * a Nightmare Fallen dangerous. + * + * @param scaling - the table, from {@link readMonsterScaling}. + * @param kind - the monster. + * @param difficulty - which difficulty. + * @returns the scale, or {@link UNSCALED} when the level has no row. + */ +export function monsterScaleFor( + scaling: ReadonlyMap, + kind: MonsterKind, + difficulty: Difficulty = 'normal', +): MonsterScale { + const tier = difficulty === 'normal' ? 0 : difficulty === 'nightmare' ? 1 : 2 + return scaling.get(kind.level[tier]) ?? UNSCALED +} + /** What `Levels.txt` says should live on one level. */ export interface LevelMonsterPlan { /** `Id`. */ @@ -724,20 +824,33 @@ const PIXELS_PER_SUBTILE = 16 * @param kind - the table row. * @param rng - seeded, so the same spawn rolls the same health. * @param walkSpeedPx - the player's walking speed, the anchor for the scale. + * @param scale - the `MonLvl.txt` multipliers; defaults to no scaling. * @returns stats the existing combat code can use unchanged. */ -export function monsterStatsOf(kind: MonsterKind, rng: Rng, walkSpeedPx: number): MonsterStats { +export function monsterStatsOf( + kind: MonsterKind, + rng: Rng, + walkSpeedPx: number, + scale: MonsterScale = UNSCALED, +): MonsterStats { const velocity = kind.velocity > 0 ? kind.velocity : 1 + // Roll first, scale second. Scaling the bounds and rolling between them + // would round the range's ends before the roll and lose most of its width at + // low levels, where the multiplier is a few percent: a Fallen's 21..61 at + // level 1 becomes 1..4, and three of those four values are unreachable if + // each end is rounded first. + const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp)) + const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2 return { id: kind.id, name: kind.nameKey === '' ? kind.id : kind.nameKey, - hp: Math.max(1, rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))), - damage: Math.max(1, Math.round((kind.attack1.minDamage + kind.attack1.maxDamage) / 2)), + hp: Math.max(1, Math.round((rolled * scale.health) / 100)), + damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)), cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS, reach: DEFAULT_REACH_PX, aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX, speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)), - xp: Math.max(0, kind.experience), + xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)), } } @@ -752,3 +865,86 @@ export const ELITE_HEALTH_MULTIPLIER: Readonly { + const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank] + const members: MonsterStats[] = [] + for (let i = 0; i < group.count; i += 1) { + const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, monsterScaleFor(scaling, group.kind, difficulty)) + // 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. + const scale = group.rank === 'normal' ? 1 : (i === 0 ? multiplier : 1 + (multiplier - 1) / 2) + members.push(scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) }) + } + return { members } + }) + + return { + levelId, + levelName: plan.levelName, + types: types.map(kind => kind.id), + budget, + packs, + elitePacks: groups.filter(group => group.rank !== 'normal').length, + } +} + diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index dd6fe42..32d5104 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -20,6 +20,7 @@ import { httpRangeSource } from '../mpq/source.ts' import { ViewportCamera } from "../sim/camera.ts" import { MountedArchives } from '../mpq/mount.ts' import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts' +import { planLevelMonsters } from '../game/monsters.ts' import type { LevelInfo } from '../game/acts.ts' import { decodeDs1 } from '../formats/ds1.ts' import { decodeDt1 } from '../formats/dt1.ts' @@ -31,7 +32,7 @@ import { } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts' import { createIsoTerrain } from '../game/iso-terrain.ts' -import type { MonsterStats } from '../game/combat.ts' +import type { MonsterPack, MonsterStats } from '../game/combat.ts' import { depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" @@ -239,19 +240,28 @@ interface MapRuntime { readonly spawn: { x: number; y: number } readonly npcs: readonly PackObject[] /** - * Monster definitions this level may spawn. + * The monsters this level actually holds, planned from `Levels.txt` and + * `MonStats.txt`. * - * Placeholder until #22 reads `Levels.txt`'s `mon1..10` pools and the real - * `MonStats.txt` rows. It is wired now so the path from level to spawned - * monster exists and can be tested; swapping the source is then a one-line - * change rather than a new integration. + * Empty when the level has none — a town — and empty for a source that has + * not planned one, which is what {@link ActRuntime.monsterStats} below is + * still there for. + */ + readonly monsterPacks: readonly MonsterPack[] + /** + * The level's monster types, by `MonStats.txt` id, for the status line. + */ + readonly monsterTypes: readonly string[] + /** + * Fallback monster definitions, used only when the level produced no packs. + * + * A level with no `Levels.txt` row — a hand-built fixture, or a pack baked + * before the monster columns were read — would otherwise be empty. Spawning a + * few placeholders there keeps the page useful to poke at, and the status + * line says which of the two happened. */ readonly monsterStats: readonly MonsterStats[] - /** - * How many monsters to place. - * - * Placeholder until #22 derives it from `MonDen` / `NumMon`. - */ + /** How many of {@link ActRuntime.monsterStats} to place in that fallback. */ readonly monsterCount: number readonly objects: number readonly frames: number @@ -360,6 +370,21 @@ interface PackSceneJson { readonly roofs?: readonly (readonly number[])[] readonly objects: readonly PackObject[] readonly npcs?: readonly PackObject[] + /** + * The level's monsters, planned at bake time (absent in packs baked before + * the monster columns were read). + * + * Planned at bake time rather than in the browser because the browser has no + * `Levels.txt` or `MonStats.txt` on the pack path — the whole point of a pack + * is that it carries what the level needs and nothing else. The same + * `planLevelMonsters` produces both this and the live path's population, so + * the two agree by construction. + */ + readonly monsters?: { + readonly types: readonly string[] + readonly budget: number + readonly packs: readonly { readonly members: readonly MonsterStats[] }[] + } readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } readonly spawn: readonly number[] | null readonly stats: { readonly missingTiles: number; readonly walkable: number } @@ -488,6 +513,8 @@ async function loadPackRuntime( objectDrawables, spawn, npcs: scene.npcs ?? [], + monsterPacks: scene.monsters?.packs ?? [], + monsterTypes: scene.monsters?.types ?? [], monsterStats: DEMO_MONSTERS, monsterCount: PLACEHOLDER_MONSTER_COUNT, objects: scene.objects.length, @@ -628,6 +655,20 @@ async function loadLiveRuntime( cellY: row.cellY, page: 0, }) + // Plan the level's population from its own `Levels.txt` row. The seed is the + // level's, so the same map always comes back with the same monsters in the + // same camps — which is what makes a save reloadable. + const population = planLevelMonsters( + tables, + town.levelId, + scene.cellsX * scene.cellsY, + levelSeed(town.ds1Names[index] ?? town.levelName), + WALK_SPEED, + ) + if (population.packs.length > 0) { + notes.push(`怪物:${String(population.budget)} 只 / ${String(population.packs.length)} 群(${population.types.join('、')})`) + } + return { source: 'live', base, @@ -649,6 +690,8 @@ async function loadLiveRuntime( objectDrawables: [], spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, npcs: [], + monsterPacks: population.packs, + monsterTypes: population.types, monsterStats: DEMO_MONSTERS, monsterCount: PLACEHOLDER_MONSTER_COUNT, objects: level.objects.length, @@ -841,6 +884,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number inventoryCols: 10, inventoryRows: 4, monsterCount: runtime.monsterCount, + monsterPacks: runtime.monsterPacks, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, // This scene knows where its NPCs stand — the DS1 carries their // coordinates — so the engine must not build a ring we would discard. diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 262acca..e5a0cc1 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -16,10 +16,13 @@ import { planMonsterGroups, readEliteModifiers, readLevelMonsterPlan, + monsterScaleFor, readMonsterArt, readMonsterKinds, + readMonsterScaling, readSuperUniques, selectLevelTypes, + UNSCALED, } from '../src/game/monsters.ts' import type { MonsterKind } from '../src/game/monsters.ts' import { Rng } from '../src/game/rng.ts' @@ -125,6 +128,18 @@ const MONSTATS2 = table( ], ) +/** `MonLvl.txt`, with the real 1.13c rows for the levels the fixtures use. */ +const MONLVL = table( + ['Level', 'AC', 'AC(N)', 'TH', 'TH(N)', 'HP', 'HP(N)', 'DM', 'DM(N)', 'XP', 'XP(N)'], + [ + ['1', '6', '61', '8', '73', '7', '107', '2', '30', '30', '78'], + ['2', '12', '64', '12', '79', '9', '113', '3', '31', '40', '104'], + ['30', '180', '190', '309', '320', '133', '300', '19', '52', '660', '1600'], + ['36', '216', '216', '375', '375', '182', '525', '23', '60', '990', '2568'], + ['50', '300', '300', '540', '540', '300', '900', '31', '80', '2000', '6000'], + ], +) + const LEVELS = table( [ 'Id', 'Name', 'MonDen', 'MonDen(N)', 'MonUMin', 'MonUMax', 'MonUMin(N)', 'MonUMax(N)', @@ -512,3 +527,100 @@ describe('monsterStatsOf', () => { expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1') }) }) + +describe('readMonsterScaling', () => { + const scaling = readMonsterScaling(MONLVL) + + it('reads the five multipliers for a level', () => { + expect(scaling.get(1)).toEqual({ armour: 6, toHit: 8, health: 7, damage: 2, experience: 30 }) + }) + + it('reads the difficulty columns', () => { + expect(readMonsterScaling(MONLVL, 'nightmare').get(1)).toEqual({ + armour: 61, toHit: 73, health: 107, damage: 30, experience: 78, + }) + }) + + it('has no entry for a level the table does not list', () => { + expect(scaling.get(7)).toBeUndefined() + }) +}) + +describe('monsterScaleFor', () => { + const kinds = readMonsterKinds(MONSTATS) + const scaling = readMonsterScaling(MONLVL) + + it('looks up by the monster\'s own level for the difficulty', () => { + expect(monsterScaleFor(scaling, kinds.get('fallen1')!)).toEqual(scaling.get(1)) + // fallen1's Level(N) is 36, so Nightmare reads that row, not level 1's. + const nightmare = readMonsterScaling(MONLVL, 'nightmare') + expect(monsterScaleFor(nightmare, readMonsterKinds(MONSTATS, 'nightmare').get('fallen1')!, 'nightmare')) + .toEqual(nightmare.get(36)) + }) + + it('falls back to no scaling rather than to zero when the level is missing', () => { + // 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)).toBe(UNSCALED) + }) +}) + +describe('monsterStatsOf with MonLvl scaling', () => { + const kinds = readMonsterKinds(MONSTATS) + const scaling = readMonsterScaling(MONLVL) + const WALK = 170 + + it('turns the table\'s numbers into the game\'s', () => { + // The five reconciliations that pinned the formula down, as a test. + const fallen = kinds.get('fallen1')! + const scaled = monsterStatsOf(fallen, new Rng(1), WALK, monsterScaleFor(scaling, fallen)) + expect(scaled.xp).toBe(18) + expect(scaled.damage).toBeLessThanOrEqual(3) + expect(scaled.hp).toBeLessThanOrEqual(6) + + const zombie = kinds.get('zombie1')! + expect(monsterStatsOf(zombie, new Rng(1), WALK, monsterScaleFor(scaling, zombie)).xp).toBe(33) + const quillrat = kinds.get('quillrat1')! + expect(monsterStatsOf(quillrat, new Rng(1), WALK, monsterScaleFor(scaling, quillrat)).xp).toBe(21) + }) + + it('leaves the raw numbers unplayable, which is why scaling is not optional', () => { + // A guard against someone dropping the scale argument at a call site: the + // unscaled Fallen hits for 76 and the player has 60 health. + const fallen = kinds.get('fallen1')! + expect(monsterStatsOf(fallen, new Rng(1), WALK).damage).toBeGreaterThan(50) + }) + + it('never scales a monster out of existence', () => { + for (const kind of kinds.values()) { + const stats = monsterStatsOf(kind, new Rng(2), WALK, monsterScaleFor(scaling, kind)) + expect(stats.hp).toBeGreaterThanOrEqual(1) + expect(stats.damage).toBeGreaterThanOrEqual(1) + } + }) + + it('rolls before scaling so the range does not collapse', () => { + // At level 1 the health multiplier is 7%, turning 21..61 into about 1..4. + // Scaling the bounds first and rolling between the rounded ends would give + // the same handful of values every time; rolling first keeps the spread. + const fallen = kinds.get('fallen1')! + const scale = monsterScaleFor(scaling, fallen) + const rng = new Rng(9) + const rolls = new Set(Array.from({ length: 60 }, () => monsterStatsOf(fallen, rng, WALK, scale).hp)) + expect(rolls.size).toBeGreaterThan(2) + }) + + it('makes a Hell monster dangerous through its level, not its difficulty columns', () => { + const normal = kinds.get('fallen1')! + const hellKinds = readMonsterKinds(MONSTATS, 'hell') + // The fixture has no Hell health columns for fallen1, exactly as many real + // rows do not; the danger has to come from the level lookup regardless. + const hell = hellKinds.get('fallen1')! + expect(hell.level[2]).toBe(67) + const scaledNormal = monsterStatsOf(normal, new Rng(1), WALK, monsterScaleFor(scaling, normal)) + const withLevel50 = monsterStatsOf(normal, new Rng(1), WALK, readMonsterScaling(MONLVL).get(50)!) + expect(withLevel50.hp).toBeGreaterThan(scaledNormal.hp * 20) + expect(withLevel50.xp).toBeGreaterThan(scaledNormal.xp * 20) + }) +})