Merge branch 'fix/issue-59'
refactor(monsters): 用 MPQ 表取代手写怪物数据、清理无效 token 与消除红方块 1. 将超级独特怪与随从 ID 注入 PlannedLevel.types 与 act-scene 预加载,根治 Boss 生成时变成红方块的问题 2. 更新 hellbovine token 为原版全套动作目录 EC(原 CW 仅为 6 个残存桩),清理 18 个开发期 "XX" token 3. 消除 resolveMonsterArtSpec 中按字典序迭代的 startsWith 模糊猜测,改为确定性 O(1) 查找 4. 用 buildSuperUniqueLandmarkSpecs / lookupSuperUnique 取代 344 行手写 CANONICAL_SUPER_UNIQUES_BY_LEVEL,彻底消除 fallen1 默认兜底降级 5. 接入 MonUMod.txt 词缀常量与 MonStats2.MeleeRng 驱动的近战攻击范围计算 验证:typecheck 零错误;vitest 61 files / 968 tests passed(基线 61 / 954,新增 14 个测试全绿)。 verify:generators 与 origin/main 逐位一致(19 条既有失败未变)。 Closes #59 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
commit
5f484e5921
|
|
@ -109,11 +109,6 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
||||||
"bloodlord5": { token: "L3", weapon: "hth" },
|
"bloodlord5": { token: "L3", weapon: "hth" },
|
||||||
"bloodlord6": { token: "L3", weapon: "hth" },
|
"bloodlord6": { token: "L3", weapon: "hth" },
|
||||||
"bloodlord7": { token: "L3", weapon: "hth" },
|
"bloodlord7": { token: "L3", weapon: "hth" },
|
||||||
"bloodmage1": { token: "XX", weapon: "hth" },
|
|
||||||
"bloodmage2": { token: "XX", weapon: "hth" },
|
|
||||||
"bloodmage3": { token: "XX", weapon: "hth" },
|
|
||||||
"bloodmage4": { token: "XX", weapon: "hth" },
|
|
||||||
"bloodmage5": { token: "XX", weapon: "hth" },
|
|
||||||
"bloodraven": { token: "CR", weapon: "bow" },
|
"bloodraven": { token: "CR", weapon: "bow" },
|
||||||
"blunderbore1": { token: "PN", weapon: "hth" },
|
"blunderbore1": { token: "PN", weapon: "hth" },
|
||||||
"blunderbore2": { token: "PN", weapon: "hth" },
|
"blunderbore2": { token: "PN", weapon: "hth" },
|
||||||
|
|
@ -212,11 +207,6 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
||||||
"crownest3": { token: "BN", weapon: "hth" },
|
"crownest3": { token: "BN", weapon: "hth" },
|
||||||
"crownest4": { token: "BN", weapon: "hth" },
|
"crownest4": { token: "BN", weapon: "hth" },
|
||||||
"cycleoflife": { token: "K9", weapon: "hth" },
|
"cycleoflife": { token: "K9", weapon: "hth" },
|
||||||
"darkguard1": { token: "XX", weapon: "hth" },
|
|
||||||
"darkguard2": { token: "XX", weapon: "hth" },
|
|
||||||
"darkguard3": { token: "XX", weapon: "hth" },
|
|
||||||
"darkguard4": { token: "XX", weapon: "hth" },
|
|
||||||
"darkguard5": { token: "XX", weapon: "hth" },
|
|
||||||
"darkwanderer": { token: "1Z", weapon: "hth" },
|
"darkwanderer": { token: "1Z", weapon: "hth" },
|
||||||
"deathmauler1": { token: "M5", weapon: "hth" },
|
"deathmauler1": { token: "M5", weapon: "hth" },
|
||||||
"deathmauler2": { token: "M5", weapon: "hth" },
|
"deathmauler2": { token: "M5", weapon: "hth" },
|
||||||
|
|
@ -345,7 +335,7 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
||||||
"griswold": { token: "GZ", weapon: "hth" },
|
"griswold": { token: "GZ", weapon: "hth" },
|
||||||
"halbu": { token: "20", weapon: "hth" },
|
"halbu": { token: "20", weapon: "hth" },
|
||||||
"heartofwolverine": { token: "X3", weapon: "hth" },
|
"heartofwolverine": { token: "X3", weapon: "hth" },
|
||||||
"hellbovine": { token: "CW", weapon: "hth" },
|
"hellbovine": { token: "EC", weapon: "hth" },
|
||||||
"hellmeteor": { token: "K9", weapon: "hth" },
|
"hellmeteor": { token: "K9", weapon: "hth" },
|
||||||
"hephasto": { token: "5P", weapon: "hth" },
|
"hephasto": { token: "5P", weapon: "hth" },
|
||||||
"hratli": { token: "HR", weapon: "hth" },
|
"hratli": { token: "HR", weapon: "hth" },
|
||||||
|
|
@ -375,7 +365,6 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
||||||
"kashya": { token: "RC", weapon: "hth" },
|
"kashya": { token: "RC", weapon: "hth" },
|
||||||
"larva": { token: "LV", weapon: "hth" },
|
"larva": { token: "LV", weapon: "hth" },
|
||||||
"larzuk": { token: "XR", weapon: "hth" },
|
"larzuk": { token: "XR", weapon: "hth" },
|
||||||
"lightningbeast": { token: "XX", weapon: "hth" },
|
|
||||||
"lightningsentry": { token: "LG", weapon: "hth" },
|
"lightningsentry": { token: "LG", weapon: "hth" },
|
||||||
"lightningspire": { token: "AE", weapon: "hth" },
|
"lightningspire": { token: "AE", weapon: "hth" },
|
||||||
"lysander": { token: "LY", weapon: "hth" },
|
"lysander": { token: "LY", weapon: "hth" },
|
||||||
|
|
@ -409,17 +398,6 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
||||||
"mephistospirit": { token: "M6", weapon: "hth" },
|
"mephistospirit": { token: "M6", weapon: "hth" },
|
||||||
"meshif1": { token: "MS", weapon: "hth" },
|
"meshif1": { token: "MS", weapon: "hth" },
|
||||||
"meshif2": { token: "M3", weapon: "hth" },
|
"meshif2": { token: "M3", weapon: "hth" },
|
||||||
"minion1": { token: "XX", weapon: "hth" },
|
|
||||||
"minion10": { token: "XX", weapon: "hth" },
|
|
||||||
"minion11": { token: "XX", weapon: "hth" },
|
|
||||||
"minion2": { token: "XX", weapon: "hth" },
|
|
||||||
"minion3": { token: "XX", weapon: "hth" },
|
|
||||||
"minion4": { token: "XX", weapon: "hth" },
|
|
||||||
"minion5": { token: "XX", weapon: "hth" },
|
|
||||||
"minion6": { token: "XX", weapon: "hth" },
|
|
||||||
"minion7": { token: "XX", weapon: "hth" },
|
|
||||||
"minion8": { token: "XX", weapon: "hth" },
|
|
||||||
"minion9": { token: "XX", weapon: "hth" },
|
|
||||||
"minionspawner1": { token: "XA", weapon: "hth" },
|
"minionspawner1": { token: "XA", weapon: "hth" },
|
||||||
"minionspawner2": { token: "XA", weapon: "hth" },
|
"minionspawner2": { token: "XA", weapon: "hth" },
|
||||||
"minionspawner3": { token: "XA", weapon: "hth" },
|
"minionspawner3": { token: "XA", weapon: "hth" },
|
||||||
|
|
@ -609,7 +587,6 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
||||||
"snowyeti2": { token: "IO", weapon: "hth" },
|
"snowyeti2": { token: "IO", weapon: "hth" },
|
||||||
"snowyeti3": { token: "IO", weapon: "hth" },
|
"snowyeti3": { token: "IO", weapon: "hth" },
|
||||||
"snowyeti4": { token: "IO", weapon: "hth" },
|
"snowyeti4": { token: "IO", weapon: "hth" },
|
||||||
"spiritmummy": { token: "XX", weapon: "hth" },
|
|
||||||
"spiritofbarbs": { token: "X4", weapon: "hth" },
|
"spiritofbarbs": { token: "X4", weapon: "hth" },
|
||||||
"spiritwolf": { token: "WF", weapon: "hth" },
|
"spiritwolf": { token: "WF", weapon: "hth" },
|
||||||
"succubus1": { token: "0B", weapon: "hth" },
|
"succubus1": { token: "0B", weapon: "hth" },
|
||||||
|
|
@ -765,7 +742,7 @@ export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
|
||||||
2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }],
|
2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }],
|
||||||
3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }],
|
3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }],
|
||||||
4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }],
|
4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }],
|
||||||
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "XX", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }],
|
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -773,13 +750,15 @@ export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
|
||||||
*/
|
*/
|
||||||
export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
|
export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
|
||||||
const lower = id.toLowerCase()
|
const lower = id.toLowerCase()
|
||||||
if (MONSTER_ART_MAP[lower]) {
|
const direct = MONSTER_ART_MAP[lower]
|
||||||
return MONSTER_ART_MAP[lower]
|
if (direct && direct.token !== 'XX' && direct.token.trim() !== '') {
|
||||||
|
return direct
|
||||||
}
|
}
|
||||||
const base = lower.replace(/\d+$/, '')
|
const base = lower.replace(/\d+$/, '')
|
||||||
for (const [key, spec] of Object.entries(MONSTER_ART_MAP)) {
|
if (base !== lower) {
|
||||||
if (key.startsWith(base)) {
|
const baseSpec = MONSTER_ART_MAP[base]
|
||||||
return spec
|
if (baseSpec && baseSpec.token !== 'XX' && baseSpec.token.trim() !== '') {
|
||||||
|
return baseSpec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
* case on that basis would be guessing; the act bosses get their own pass.
|
* case on that basis would be guessing; the act bosses get their own pass.
|
||||||
*/
|
*/
|
||||||
import type { D2Table } from './acts.ts'
|
import type { D2Table } from './acts.ts'
|
||||||
import { cell } from './acts.ts'
|
import { cell, parseTable } from './acts.ts'
|
||||||
import type { MonsterPack, MonsterStats } from './combat.ts'
|
import type { MonsterPack, MonsterStats } from './combat.ts'
|
||||||
import { Rng } from './rng.ts'
|
import { Rng } from './rng.ts'
|
||||||
|
|
||||||
|
|
@ -238,6 +238,8 @@ export interface MonsterKind {
|
||||||
readonly minions: readonly string[]
|
readonly minions: readonly string[]
|
||||||
/** `SetBoss` — the pack leader becomes a unique. */
|
/** `SetBoss` — the pack leader becomes a unique. */
|
||||||
readonly setBoss: boolean
|
readonly setBoss: boolean
|
||||||
|
/** `MeleeRng` — melee reach in sub-tiles from `MonStats2.txt`. */
|
||||||
|
readonly meleeRange?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -751,6 +753,47 @@ export function readEliteModifiers(table: D2Table): EliteModifier[] {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the parameters table embedded in `MonUMod.txt`.
|
||||||
|
*
|
||||||
|
* The last two columns of `MonUMod.txt` ('constants' and '*constant desc') form
|
||||||
|
* a parameters table controlling general properties of champions, uniques, and minions.
|
||||||
|
*
|
||||||
|
* @param table - the parsed `MonUMod.txt`.
|
||||||
|
* @returns map of constant description to numeric value.
|
||||||
|
*/
|
||||||
|
export function readMonUModConstants(table: D2Table): Map<string, number> {
|
||||||
|
const constants = new Map<string, number>()
|
||||||
|
for (const row of table.rows) {
|
||||||
|
const desc = cell(table, row, '*constant desc').trim()
|
||||||
|
const val = num(table, row, 'constants', NaN)
|
||||||
|
if (desc !== '' && !Number.isNaN(val)) {
|
||||||
|
constants.set(desc, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return constants
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical constants extracted from `MonUMod.txt`. */
|
||||||
|
export const MONUMOD_CONSTANTS = {
|
||||||
|
championChance: 20,
|
||||||
|
minionHpPct: 100,
|
||||||
|
minionHpPctNightmare: 75,
|
||||||
|
minionHpPctHell: 50,
|
||||||
|
championHpPct: 200,
|
||||||
|
championHpPctNightmare: 150,
|
||||||
|
championHpPctHell: 100,
|
||||||
|
uniqueHpPct: 300,
|
||||||
|
uniqueHpPctNightmare: 200,
|
||||||
|
uniqueHpPctHell: 100,
|
||||||
|
championToHitPct: 75,
|
||||||
|
championDmgPct: 100,
|
||||||
|
minionToHitPct: 50,
|
||||||
|
uniqueToHitPct: 100,
|
||||||
|
minionDmgPctStrong: 75,
|
||||||
|
uniqueDmgPctStrong: 150,
|
||||||
|
} as const
|
||||||
|
|
||||||
/** What a monster is: ordinary, or promoted by the level generator. */
|
/** What a monster is: ordinary, or promoted by the level generator. */
|
||||||
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
|
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
|
||||||
|
|
||||||
|
|
@ -811,7 +854,7 @@ export function applyEliteModifiers(
|
||||||
let damage = base.damage
|
let damage = base.damage
|
||||||
let speed = base.speed
|
let speed = base.speed
|
||||||
if (modifiers.includes('strong')) {
|
if (modifiers.includes('strong')) {
|
||||||
damage = Math.max(1, Math.round(damage * 1.5))
|
damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100)))
|
||||||
}
|
}
|
||||||
if (modifiers.includes('fast')) {
|
if (modifiers.includes('fast')) {
|
||||||
speed = Math.round(speed * 1.4 * 100) / 100
|
speed = Math.round(speed * 1.4 * 100) / 100
|
||||||
|
|
@ -944,7 +987,7 @@ export function planMonsterGroups(
|
||||||
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
||||||
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
||||||
// packs are champions rather than a unique with minions.
|
// packs are champions rather than a unique with minions.
|
||||||
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < 0.2 ? 'champion' : 'unique'
|
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) ? 'champion' : 'unique'
|
||||||
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
||||||
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
||||||
groups.push({ kind, count, rank, modifiers })
|
groups.push({ kind, count, rank, modifiers })
|
||||||
|
|
@ -977,7 +1020,7 @@ const PLAYER_WALK_VELOCITY = 6
|
||||||
* Most rows have `MeleeRng` 0, meaning "touching". A cell is 80×40, so half a
|
* Most rows have `MeleeRng` 0, meaning "touching". A cell is 80×40, so half a
|
||||||
* cell's width is about as close as two bodies get.
|
* cell's width is about as close as two bodies get.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_REACH_PX = 40
|
export const DEFAULT_REACH_PX = 40
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ticks between monster attacks.
|
* Ticks between monster attacks.
|
||||||
|
|
@ -1023,6 +1066,7 @@ export function monsterStatsOf(
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
walkSpeedPx: number,
|
walkSpeedPx: number,
|
||||||
scale: MonsterScale = UNSCALED,
|
scale: MonsterScale = UNSCALED,
|
||||||
|
art?: MonsterArt | { readonly meleeRange?: number } | null,
|
||||||
): MonsterStats {
|
): MonsterStats {
|
||||||
const velocity = kind.velocity > 0 ? kind.velocity : 1
|
const velocity = kind.velocity > 0 ? kind.velocity : 1
|
||||||
// Roll first, scale second. Scaling the bounds and rolling between them
|
// Roll first, scale second. Scaling the bounds and rolling between them
|
||||||
|
|
@ -1032,13 +1076,17 @@ export function monsterStatsOf(
|
||||||
// each end is rounded first.
|
// each end is rounded first.
|
||||||
const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))
|
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
|
const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2
|
||||||
|
const meleeRng = art?.meleeRange ?? kind.meleeRange
|
||||||
|
const reach = (meleeRng !== undefined && meleeRng > 0)
|
||||||
|
? Math.max(DEFAULT_REACH_PX, meleeRng * PIXELS_PER_SUBTILE)
|
||||||
|
: DEFAULT_REACH_PX
|
||||||
return {
|
return {
|
||||||
id: kind.id,
|
id: kind.id,
|
||||||
name: kind.nameKey === '' ? kind.id : kind.nameKey,
|
name: kind.nameKey === '' ? kind.id : kind.nameKey,
|
||||||
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
|
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
|
||||||
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
||||||
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
||||||
reach: DEFAULT_REACH_PX,
|
reach,
|
||||||
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
|
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
|
||||||
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
|
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
|
||||||
xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
|
xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
|
||||||
|
|
@ -1053,8 +1101,8 @@ export function monsterStatsOf(
|
||||||
*/
|
*/
|
||||||
export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'minion'>, number>> = {
|
export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'minion'>, number>> = {
|
||||||
normal: 1,
|
normal: 1,
|
||||||
champion: 3,
|
champion: 1 + MONUMOD_CONSTANTS.championHpPct / 100,
|
||||||
unique: 4,
|
unique: 1 + MONUMOD_CONSTANTS.uniqueHpPct / 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
|
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
|
||||||
|
|
@ -1069,351 +1117,158 @@ export interface SuperUniqueLandmarkSpec {
|
||||||
readonly minionMonsterId: string
|
readonly minionMonsterId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
|
/** Landmark and minion mapping for canonical outdoor and landmark SuperUniques. */
|
||||||
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>> = {
|
export interface SuperUniqueLandmarkMapping {
|
||||||
// Act 1
|
readonly id: string
|
||||||
3: {
|
readonly levelIds: readonly number[]
|
||||||
id: 'Bishibosh',
|
readonly landmark: string
|
||||||
name: 'Bishibosh',
|
readonly minionMonsterId?: string
|
||||||
monsterId: 'fallenshaman1',
|
|
||||||
landmark: 'Act 1 - Fallen Camp Bishibosh',
|
|
||||||
modifiers: ['magicresistant', 'fireenchant'],
|
|
||||||
minMinions: 5,
|
|
||||||
maxMinions: 7,
|
|
||||||
minionMonsterId: 'fallen1',
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
id: 'Rakanishu',
|
|
||||||
name: 'Rakanishu',
|
|
||||||
monsterId: 'fallen1',
|
|
||||||
landmark: 'Act 1 - Cairn Stones',
|
|
||||||
modifiers: ['lightenchant', 'fast'],
|
|
||||||
minMinions: 6,
|
|
||||||
maxMinions: 8,
|
|
||||||
minionMonsterId: 'fallen1',
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
id: 'Treehead WoodFist',
|
|
||||||
name: 'Treehead WoodFist',
|
|
||||||
monsterId: 'brute1',
|
|
||||||
landmark: 'Act 1 - Inifus',
|
|
||||||
modifiers: ['strong', 'fast'],
|
|
||||||
minMinions: 3,
|
|
||||||
maxMinions: 4,
|
|
||||||
minionMonsterId: 'brute1',
|
|
||||||
},
|
|
||||||
6: {
|
|
||||||
id: 'The Countess',
|
|
||||||
name: 'The Countess',
|
|
||||||
monsterId: 'corruptrogue5',
|
|
||||||
landmark: 'Act 1 - Tower 1',
|
|
||||||
modifiers: ['fireenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'corruptrogue5',
|
|
||||||
},
|
|
||||||
17: {
|
|
||||||
id: 'Blood Raven',
|
|
||||||
name: 'Blood Raven',
|
|
||||||
monsterId: 'corruptrogue1',
|
|
||||||
landmark: 'Act 1 - Graveyard',
|
|
||||||
modifiers: ['fireenchant', 'fast'],
|
|
||||||
minMinions: 6,
|
|
||||||
maxMinions: 8,
|
|
||||||
minionMonsterId: 'zombie1',
|
|
||||||
},
|
|
||||||
25: {
|
|
||||||
id: 'The Countess',
|
|
||||||
name: 'The Countess',
|
|
||||||
monsterId: 'corruptrogue5',
|
|
||||||
landmark: 'Act 1 - Tower 1',
|
|
||||||
modifiers: ['fireenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'corruptrogue5',
|
|
||||||
},
|
|
||||||
// Act 2
|
|
||||||
41: {
|
|
||||||
id: 'Creeping Feature',
|
|
||||||
name: 'Creeping Feature',
|
|
||||||
monsterId: 'mummy1',
|
|
||||||
landmark: 'Act 2 - Desert Tomb 1',
|
|
||||||
modifiers: ['strong', 'coldenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'mummy1',
|
|
||||||
},
|
|
||||||
43: {
|
|
||||||
id: 'Beetleburst',
|
|
||||||
name: 'Beetleburst',
|
|
||||||
monsterId: 'beetle2',
|
|
||||||
landmark: 'Act 2 - Desert Oasis 1',
|
|
||||||
modifiers: ['magicresistant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'beetle2',
|
|
||||||
},
|
|
||||||
44: {
|
|
||||||
id: 'Dark Elder',
|
|
||||||
name: 'Dark Elder',
|
|
||||||
monsterId: 'plaguebearer1',
|
|
||||||
landmark: 'Act 2 - Desert Ruins Elder',
|
|
||||||
modifiers: ['fast', 'magicresistant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'plaguebearer1',
|
|
||||||
},
|
|
||||||
45: [
|
|
||||||
{
|
|
||||||
id: 'Bloodwitch the Wild',
|
|
||||||
name: 'Bloodwitch the Wild',
|
|
||||||
monsterId: 'huntress2',
|
|
||||||
landmark: 'Act 2 - Desert Tomb 2',
|
|
||||||
modifiers: ['cursed', 'strong'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'huntress2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Fangskin',
|
|
||||||
name: 'Fangskin',
|
|
||||||
monsterId: 'clawviper3',
|
|
||||||
landmark: 'Act 2 - Desert Valley Ruin 2',
|
|
||||||
modifiers: ['lightenchant', 'fast'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'clawviper3',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
55: {
|
|
||||||
id: 'Creeping Feature',
|
|
||||||
name: 'Creeping Feature',
|
|
||||||
monsterId: 'mummy1',
|
|
||||||
landmark: 'Act 2 - Desert Tomb 1',
|
|
||||||
modifiers: ['strong', 'coldenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'mummy1',
|
|
||||||
},
|
|
||||||
59: {
|
|
||||||
id: 'Creeping Feature',
|
|
||||||
name: 'Creeping Feature',
|
|
||||||
monsterId: 'mummy1',
|
|
||||||
landmark: 'Act 2 - Desert Tomb 1',
|
|
||||||
modifiers: ['strong', 'coldenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'mummy1',
|
|
||||||
},
|
|
||||||
60: {
|
|
||||||
id: 'Bloodwitch the Wild',
|
|
||||||
name: 'Bloodwitch the Wild',
|
|
||||||
monsterId: 'huntress2',
|
|
||||||
landmark: 'Act 2 - Desert Tomb 2',
|
|
||||||
modifiers: ['cursed', 'strong'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'huntress2',
|
|
||||||
},
|
|
||||||
61: {
|
|
||||||
id: 'Fangskin',
|
|
||||||
name: 'Fangskin',
|
|
||||||
monsterId: 'clawviper3',
|
|
||||||
landmark: 'Act 2 - Desert Valley Ruin 2',
|
|
||||||
modifiers: ['lightenchant', 'fast'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'clawviper3',
|
|
||||||
},
|
|
||||||
// Act 3
|
|
||||||
76: {
|
|
||||||
id: 'Sszark the Burning',
|
|
||||||
name: 'Sszark the Burning',
|
|
||||||
monsterId: 'spider2',
|
|
||||||
landmark: 'Act 3 - Spider Cavern',
|
|
||||||
modifiers: ['strong', 'cursed'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'spider2',
|
|
||||||
},
|
|
||||||
78: [
|
|
||||||
{
|
|
||||||
id: 'Stormtree',
|
|
||||||
name: 'Stormtree',
|
|
||||||
monsterId: 'thornhulk3',
|
|
||||||
landmark: 'Act 3 - Flayer Jungle Entrance',
|
|
||||||
modifiers: ['lightenchant', 'fast'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'thornhulk3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Witch Doctor Endugu',
|
|
||||||
name: 'Witch Doctor Endugu',
|
|
||||||
monsterId: 'flayershaman4',
|
|
||||||
landmark: 'Act 3 - Flayer Dungeon',
|
|
||||||
modifiers: ['magicresistant', 'fireenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'flayer4',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
80: {
|
|
||||||
id: 'Battlemaid Sarina',
|
|
||||||
name: 'Battlemaid Sarina',
|
|
||||||
monsterId: 'corruptrogue8',
|
|
||||||
landmark: 'Act 3 - Burbs Temple',
|
|
||||||
modifiers: ['fast', 'spectralhit'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'corruptrogue8',
|
|
||||||
},
|
|
||||||
84: {
|
|
||||||
id: 'Sszark the Burning',
|
|
||||||
name: 'Sszark the Burning',
|
|
||||||
monsterId: 'spider2',
|
|
||||||
landmark: 'Act 3 - Spider Cavern',
|
|
||||||
modifiers: ['strong', 'cursed'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'spider2',
|
|
||||||
},
|
|
||||||
88: {
|
|
||||||
id: 'Witch Doctor Endugu',
|
|
||||||
name: 'Witch Doctor Endugu',
|
|
||||||
monsterId: 'flayershaman4',
|
|
||||||
landmark: 'Act 3 - Flayer Dungeon',
|
|
||||||
modifiers: ['magicresistant', 'fireenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'flayer4',
|
|
||||||
},
|
|
||||||
91: {
|
|
||||||
id: 'Witch Doctor Endugu',
|
|
||||||
name: 'Witch Doctor Endugu',
|
|
||||||
monsterId: 'flayershaman4',
|
|
||||||
landmark: 'Act 3 - Flayer Dungeon',
|
|
||||||
modifiers: ['magicresistant', 'fireenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'flayer4',
|
|
||||||
},
|
|
||||||
94: {
|
|
||||||
id: 'Battlemaid Sarina',
|
|
||||||
name: 'Battlemaid Sarina',
|
|
||||||
monsterId: 'corruptrogue8',
|
|
||||||
landmark: 'Act 3 - Burbs Temple',
|
|
||||||
modifiers: ['fast', 'spectralhit'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'corruptrogue8',
|
|
||||||
},
|
|
||||||
// Act 4
|
|
||||||
105: {
|
|
||||||
id: 'Izual',
|
|
||||||
name: 'Izual',
|
|
||||||
monsterId: 'izual',
|
|
||||||
landmark: 'Act 4 - Plains of Despair',
|
|
||||||
modifiers: ['coldenchant'],
|
|
||||||
minMinions: 0,
|
|
||||||
maxMinions: 0,
|
|
||||||
minionMonsterId: 'izual',
|
|
||||||
},
|
|
||||||
106: {
|
|
||||||
id: 'Hephasto the Armorer',
|
|
||||||
name: 'Hephasto the Armorer',
|
|
||||||
monsterId: 'hephasto',
|
|
||||||
landmark: 'Act 4 - Hellforge',
|
|
||||||
modifiers: ['spectralhit', 'aura'],
|
|
||||||
minMinions: 0,
|
|
||||||
maxMinions: 0,
|
|
||||||
minionMonsterId: 'hephasto',
|
|
||||||
},
|
|
||||||
107: {
|
|
||||||
id: 'Hephasto the Armorer',
|
|
||||||
name: 'Hephasto the Armorer',
|
|
||||||
monsterId: 'hephasto',
|
|
||||||
landmark: 'Act 4 - Hellforge',
|
|
||||||
modifiers: ['spectralhit', 'aura'],
|
|
||||||
minMinions: 0,
|
|
||||||
maxMinions: 0,
|
|
||||||
minionMonsterId: 'hephasto',
|
|
||||||
},
|
|
||||||
// Act 5
|
|
||||||
110: [
|
|
||||||
{
|
|
||||||
id: 'Dac Farren',
|
|
||||||
name: 'Dac Farren',
|
|
||||||
monsterId: 'demonimp1',
|
|
||||||
landmark: 'Act 5 - Barricade Prison 1',
|
|
||||||
modifiers: ['coldenchant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'demonimp1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Shenk the Overseer',
|
|
||||||
name: 'Shenk the Overseer',
|
|
||||||
monsterId: 'overseer2',
|
|
||||||
landmark: 'Act 5 - Barricade Building',
|
|
||||||
modifiers: ['strong'],
|
|
||||||
minMinions: 15,
|
|
||||||
maxMinions: 20,
|
|
||||||
minionMonsterId: 'enslaved1',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
111: {
|
|
||||||
id: 'Eyeback the Unleashed',
|
|
||||||
name: 'Eyeback the Unleashed',
|
|
||||||
monsterId: 'bloodlord2',
|
|
||||||
landmark: 'Act 5 - Barricade Hell Portal N',
|
|
||||||
modifiers: ['fast', 'strong'],
|
|
||||||
minMinions: 3,
|
|
||||||
maxMinions: 5,
|
|
||||||
minionMonsterId: 'bloodlord2',
|
|
||||||
},
|
|
||||||
112: {
|
|
||||||
id: 'Thresh Socket',
|
|
||||||
name: 'Thresh Socket',
|
|
||||||
monsterId: 'siegebeast1',
|
|
||||||
landmark: 'Act 5 - Barricade Snow Unique',
|
|
||||||
modifiers: ['cursed'],
|
|
||||||
minMinions: 2,
|
|
||||||
maxMinions: 4,
|
|
||||||
minionMonsterId: 'siegebeast1',
|
|
||||||
},
|
|
||||||
114: {
|
|
||||||
id: 'Frozenstein',
|
|
||||||
name: 'Frozenstein',
|
|
||||||
monsterId: 'yeti2',
|
|
||||||
landmark: 'Act 5 - Frozen River',
|
|
||||||
modifiers: ['coldenchant', 'manahit'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'yeti2',
|
|
||||||
},
|
|
||||||
115: {
|
|
||||||
id: 'Bonesaw Breaker',
|
|
||||||
name: 'Bonesaw Breaker',
|
|
||||||
monsterId: 'reanimatedhorde2',
|
|
||||||
landmark: 'Act 5 - Glacial Trail',
|
|
||||||
modifiers: ['strong', 'magicresistant'],
|
|
||||||
minMinions: 4,
|
|
||||||
maxMinions: 6,
|
|
||||||
minionMonsterId: 'reanimatedhorde2',
|
|
||||||
},
|
|
||||||
124: {
|
|
||||||
id: 'Nihlathak',
|
|
||||||
name: 'Nihlathak',
|
|
||||||
monsterId: 'nihlathakboss',
|
|
||||||
landmark: 'Act 5 - Halls of Vaught',
|
|
||||||
modifiers: ['coldenchant'],
|
|
||||||
minMinions: 6,
|
|
||||||
maxMinions: 8,
|
|
||||||
minionMonsterId: 'iceprowler2',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMapping[] = [
|
||||||
|
// Act 1
|
||||||
|
{ id: 'Bishibosh', levelIds: [3], landmark: 'Act 1 - Fallen Camp Bishibosh', minionMonsterId: 'fallen1' },
|
||||||
|
{ id: 'Rakanishu', levelIds: [4], landmark: 'Act 1 - Cairn Stones', minionMonsterId: 'fallen1' },
|
||||||
|
{ id: 'Treehead WoodFist', levelIds: [5], landmark: 'Act 1 - Inifus', minionMonsterId: 'brute1' },
|
||||||
|
{ id: 'The Countess', levelIds: [6, 25], landmark: 'Act 1 - Tower 1', minionMonsterId: 'corruptrogue5' },
|
||||||
|
{ id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' },
|
||||||
|
// Act 2
|
||||||
|
{ id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' },
|
||||||
|
{ id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'beetle2' },
|
||||||
|
{ id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' },
|
||||||
|
{ id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'huntress2' },
|
||||||
|
{ id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' },
|
||||||
|
// Act 3
|
||||||
|
{ id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'spider2' },
|
||||||
|
{ id: 'Stormtree', levelIds: [78], landmark: 'Act 3 - Flayer Jungle Entrance', minionMonsterId: 'thornhulk3' },
|
||||||
|
{ id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'flayer4' },
|
||||||
|
{ id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue8' },
|
||||||
|
// Act 4
|
||||||
|
{ id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' },
|
||||||
|
{ id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' },
|
||||||
|
// Act 5
|
||||||
|
{ id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'demonimp1' },
|
||||||
|
{ id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'enslaved1' },
|
||||||
|
{ id: 'Eyeback the Unleashed', levelIds: [111], landmark: 'Act 5 - Barricade Hell Portal N', minionMonsterId: 'bloodlord2' },
|
||||||
|
{ id: 'Thresh Socket', levelIds: [112], landmark: 'Act 5 - Barricade Snow Unique', minionMonsterId: 'siegebeast1' },
|
||||||
|
{ id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'yeti2' },
|
||||||
|
{ id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' },
|
||||||
|
{ id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'iceprowler2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Canonical SuperUniques TSV extracted from game data for landmark bosses. */
|
||||||
|
export const CANONICAL_SUPERUNIQUES_TSV = `Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)
|
||||||
|
Bishibosh\tBishibosh\tfallenshaman1\t8\t9\t0\t5\t7\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A
|
||||||
|
Rakanishu\tRakanishu\tfallen1\t17\t6\t0\t6\t8\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A
|
||||||
|
Treehead WoodFist\tTreehead WoodFist\tbrute1\t5\t6\t0\t3\t4\t0\t0\t0\t0\tAct 1 Super B\tAct 1 (N) Super B\tAct 1 (H) Super B
|
||||||
|
The Countess\tThe Countess\tcorruptrogue5\t9\t0\t0\t4\t6\t0\t0\t0\t0\tCountess\tCountess (N)\tCountess (H)
|
||||||
|
Blood Raven\tBlood Raven\tcorruptrogue1\t9\t6\t0\t6\t8\t0\t0\t0\t0\tBlood Raven\tBlood Raven (N)\tBlood Raven (H)
|
||||||
|
Creeping Feature\tCreeping Feature\tmummy1\t5\t18\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
|
||||||
|
Beetleburst\tBeetleburst\tbeetle2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
|
||||||
|
Dark Elder\tDark Elder\tplaguebearer1\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
|
||||||
|
Bloodwitch the Wild\tBloodwitch the Wild\thuntress2\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
|
||||||
|
Fangskin\tFangskin\tclawviper3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super C\tAct 2 (N) Super C\tAct 2 (H) Super C
|
||||||
|
Sszark the Burning\tSszark the Burning\tspider2\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A
|
||||||
|
Stormtree\tStormtree\tthornhulk3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
|
||||||
|
Witch Doctor Endugu\tWitch Doctor Endugu\tflayershaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
|
||||||
|
Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue8\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C
|
||||||
|
Izual\tIzual\tizual\t18\t0\t0\t0\t0\t0\t0\t0\t0\tIzual\tIzual (N)\tIzual (H)
|
||||||
|
Hephasto the Armorer\tHephasto the Armorer\thephasto\t27\t30\t0\t0\t0\t0\t0\t0\t0\tHephasto\tHephasto (N)\tHephasto (H)
|
||||||
|
Dac Farren\tDac Farren\tdemonimp1\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
|
||||||
|
Shenk the Overseer\tShenk the Overseer\toverseer2\t5\t0\t0\t15\t20\t0\t0\t0\t0\tShenk\tShenk (N)\tShenk (H)
|
||||||
|
Eyeback the Unleashed\tEyeback the Unleashed\tbloodlord2\t6\t5\t0\t3\t5\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
|
||||||
|
Thresh Socket\tThresh Socket\tsiegebeast1\t7\t0\t0\t2\t4\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
|
||||||
|
Frozenstein\tFrozenstein\tyeti2\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
|
||||||
|
Bonesaw Breaker\tBonesaw Breaker\treanimatedhorde2\t5\t8\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super C\tAct 5 (N) Super C\tAct 5 (H) Super C
|
||||||
|
Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)`
|
||||||
|
|
||||||
|
/** Canonical SuperUniques table parsed from canonical game TSV. */
|
||||||
|
export const CANONICAL_SUPERUNIQUES_TABLE: D2Table = parseTable(
|
||||||
|
new TextEncoder().encode(CANONICAL_SUPERUNIQUES_TSV.trim()),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a SuperUnique boss by id or name from a parsed `SuperUniques.txt` table.
|
||||||
|
*
|
||||||
|
* @param table - the parsed `SuperUniques.txt`.
|
||||||
|
* @param id - the superunique ID or name (case-insensitive).
|
||||||
|
* @param difficulty - which difficulty's treasure class to read.
|
||||||
|
* @returns the matched SuperUnique, or undefined if not found.
|
||||||
|
*/
|
||||||
|
export function lookupSuperUnique(
|
||||||
|
table: D2Table,
|
||||||
|
id: string,
|
||||||
|
difficulty: Difficulty = 'normal',
|
||||||
|
): SuperUnique | undefined {
|
||||||
|
const suList = readSuperUniques(table, difficulty)
|
||||||
|
const target = id.toLowerCase()
|
||||||
|
return suList.find(su => su.id.toLowerCase() === target || su.nameKey.toLowerCase() === target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build landmark specifications for canonical SuperUniques from a `SuperUniques.txt` table.
|
||||||
|
*
|
||||||
|
* @param table - the parsed `SuperUniques.txt` (defaults to `CANONICAL_SUPERUNIQUES_TABLE`).
|
||||||
|
* @param difficulty - which difficulty's modifiers and treasure class to read.
|
||||||
|
* @returns map of levelId to SuperUniqueLandmarkSpec or array of specs.
|
||||||
|
*/
|
||||||
|
export function buildSuperUniqueLandmarkSpecs(
|
||||||
|
table: D2Table = CANONICAL_SUPERUNIQUES_TABLE,
|
||||||
|
difficulty: Difficulty = 'normal',
|
||||||
|
): Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]> {
|
||||||
|
const suList = readSuperUniques(table, difficulty)
|
||||||
|
const suMap = new Map<string, SuperUnique>()
|
||||||
|
for (const su of suList) {
|
||||||
|
suMap.set(su.id.toLowerCase(), su)
|
||||||
|
if (su.nameKey) {
|
||||||
|
suMap.set(su.nameKey.toLowerCase(), su)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modNameById = new Map<number, string>()
|
||||||
|
for (const m of CANONICAL_ELITE_MODIFIERS) {
|
||||||
|
modNameById.set(m.id, m.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byLevel: Record<number, SuperUniqueLandmarkSpec[]> = {}
|
||||||
|
|
||||||
|
for (const mapping of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
|
||||||
|
const su = suMap.get(mapping.id.toLowerCase())
|
||||||
|
if (!su) continue
|
||||||
|
const modifiers = su.modifiers
|
||||||
|
.map(id => modNameById.get(id))
|
||||||
|
.filter((name): name is string => name !== undefined)
|
||||||
|
const spec: SuperUniqueLandmarkSpec = {
|
||||||
|
id: su.id,
|
||||||
|
name: su.nameKey || su.id,
|
||||||
|
monsterId: su.monsterId,
|
||||||
|
landmark: mapping.landmark,
|
||||||
|
modifiers,
|
||||||
|
minMinions: su.minMinions,
|
||||||
|
maxMinions: su.maxMinions,
|
||||||
|
minionMonsterId: mapping.minionMonsterId ?? su.monsterId,
|
||||||
|
}
|
||||||
|
for (const levelId of mapping.levelIds) {
|
||||||
|
if (!byLevel[levelId]) {
|
||||||
|
byLevel[levelId] = []
|
||||||
|
}
|
||||||
|
byLevel[levelId].push(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]> = {}
|
||||||
|
for (const [lvlStr, specs] of Object.entries(byLevel)) {
|
||||||
|
const lvl = Number(lvlStr)
|
||||||
|
result[lvl] = specs.length === 1 ? specs[0]! : specs
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
|
||||||
|
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<
|
||||||
|
Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>
|
||||||
|
> = buildSuperUniqueLandmarkSpecs()
|
||||||
|
|
||||||
/** What one level's population came out as. */
|
/** What one level's population came out as. */
|
||||||
export interface PlannedLevel {
|
export interface PlannedLevel {
|
||||||
/** `Levels.txt` id. */
|
/** `Levels.txt` id. */
|
||||||
|
|
@ -1463,7 +1318,13 @@ export interface PlannedLevel {
|
||||||
* @returns the population, empty for a town or an unknown level.
|
* @returns the population, empty for a town or an unknown level.
|
||||||
*/
|
*/
|
||||||
export function planLevelMonsters(
|
export function planLevelMonsters(
|
||||||
tables: { readonly levels: D2Table; readonly monstats: D2Table; readonly monlvl: D2Table },
|
tables: {
|
||||||
|
readonly levels: D2Table
|
||||||
|
readonly monstats: D2Table
|
||||||
|
readonly monlvl: D2Table
|
||||||
|
readonly monstats2?: D2Table
|
||||||
|
readonly superuniques?: D2Table
|
||||||
|
},
|
||||||
levelId: number,
|
levelId: number,
|
||||||
cells: number,
|
cells: number,
|
||||||
seed: number,
|
seed: number,
|
||||||
|
|
@ -1485,6 +1346,7 @@ export function planLevelMonsters(
|
||||||
if (plan === null) return empty
|
if (plan === null) return empty
|
||||||
|
|
||||||
const kinds = readMonsterKinds(tables.monstats, difficulty)
|
const kinds = readMonsterKinds(tables.monstats, difficulty)
|
||||||
|
const artMap = tables.monstats2 ? readMonsterArt(tables.monstats2, kinds) : undefined
|
||||||
const scaling = readMonsterScaling(tables.monlvl, difficulty)
|
const scaling = readMonsterScaling(tables.monlvl, difficulty)
|
||||||
// Every scale lookup goes through here so a missing `MonLvl.txt` row reaches
|
// Every scale lookup goes through here so a missing `MonLvl.txt` row reaches
|
||||||
// the caller as data (`missingScalingLevels`) as well as the console.
|
// the caller as data (`missingScalingLevels`) as well as the console.
|
||||||
|
|
@ -1504,9 +1366,10 @@ export function planLevelMonsters(
|
||||||
const packs: MonsterPack[] = groups.map(group => {
|
const packs: MonsterPack[] = groups.map(group => {
|
||||||
const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank]
|
const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank]
|
||||||
const members: MonsterStats[] = []
|
const members: MonsterStats[] = []
|
||||||
|
const groupArt = artMap?.get(group.kind.id)
|
||||||
for (let i = 0; i < group.count; i += 1) {
|
for (let i = 0; i < group.count; i += 1) {
|
||||||
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
|
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
|
||||||
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level))
|
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt)
|
||||||
// An elite pack is one leader plus its minions: the leader carries the
|
// 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
|
// 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.
|
// pack reads as "a tough one and its friends" rather than as a wall.
|
||||||
|
|
@ -1520,7 +1383,10 @@ export function planLevelMonsters(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if this level has fixed SuperUnique boss(es)
|
// Check if this level has fixed SuperUnique boss(es)
|
||||||
const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
const suMap = tables.superuniques
|
||||||
|
? buildSuperUniqueLandmarkSpecs(tables.superuniques, difficulty)
|
||||||
|
: CANONICAL_SUPER_UNIQUES_BY_LEVEL
|
||||||
|
const suRaw = suMap[levelId]
|
||||||
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
|
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
|
||||||
? suRaw
|
? suRaw
|
||||||
: (suRaw !== undefined ? [suRaw] : [])
|
: (suRaw !== undefined ? [suRaw] : [])
|
||||||
|
|
@ -1530,11 +1396,12 @@ export function planLevelMonsters(
|
||||||
for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) {
|
for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) {
|
||||||
const suSpec = suSpecs[suIdx]!
|
const suSpec = suSpecs[suIdx]!
|
||||||
const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f))
|
const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f))
|
||||||
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1'))
|
||||||
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
if (!bossKind) continue
|
||||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
|
||||||
|
const bossArt = artMap?.get(bossKind.id)
|
||||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel))
|
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt)
|
||||||
const bossScaled: MonsterStats = {
|
const bossScaled: MonsterStats = {
|
||||||
...bossBase,
|
...bossBase,
|
||||||
id: suSpec.monsterId,
|
id: suSpec.monsterId,
|
||||||
|
|
@ -1544,13 +1411,14 @@ export function planLevelMonsters(
|
||||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||||
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||||
const suMembers: MonsterStats[] = [bossStats]
|
const suMembers: MonsterStats[] = [bossStats]
|
||||||
|
const minionArt = artMap?.get(minionKind.id)
|
||||||
for (let m = 0; m < minionCount; m += 1) {
|
for (let m = 0; m < minionCount; m += 1) {
|
||||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||||
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel))
|
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt)
|
||||||
const mScaled: MonsterStats = {
|
const mScaled: MonsterStats = {
|
||||||
...mBase,
|
...mBase,
|
||||||
id: suSpec.minionMonsterId,
|
id: suSpec.minionMonsterId,
|
||||||
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
hp: Math.max(1, Math.round(mBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))),
|
||||||
}
|
}
|
||||||
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||||
}
|
}
|
||||||
|
|
@ -1567,10 +1435,27 @@ export function planLevelMonsters(
|
||||||
packs.unshift(...suPacks)
|
packs.unshift(...suPacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allMonsterTypes = [...types.map(kind => kind.id)]
|
||||||
|
for (const pack of packs) {
|
||||||
|
for (const member of pack.members) {
|
||||||
|
if (!allMonsterTypes.includes(member.id)) {
|
||||||
|
allMonsterTypes.push(member.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const suSpec of suSpecs) {
|
||||||
|
if (!allMonsterTypes.includes(suSpec.monsterId)) {
|
||||||
|
allMonsterTypes.push(suSpec.monsterId)
|
||||||
|
}
|
||||||
|
if (!allMonsterTypes.includes(suSpec.minionMonsterId)) {
|
||||||
|
allMonsterTypes.push(suSpec.minionMonsterId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
levelId,
|
levelId,
|
||||||
levelName: plan.levelName,
|
levelName: plan.levelName,
|
||||||
types: types.map(kind => kind.id),
|
types: allMonsterTypes,
|
||||||
budget,
|
budget,
|
||||||
packs,
|
packs,
|
||||||
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
|
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
|
||||||
|
|
|
||||||
|
|
@ -760,13 +760,16 @@ async function buildPackRuntime(
|
||||||
spawn,
|
spawn,
|
||||||
npcs: scene.npcs ?? [],
|
npcs: scene.npcs ?? [],
|
||||||
monsterPacks: scene.monsters?.packs ?? [],
|
monsterPacks: scene.monsters?.packs ?? [],
|
||||||
monsterTypes: scene.monsters?.types ?? [],
|
monsterTypes: Array.from(new Set([
|
||||||
|
...(scene.monsters?.types ?? []),
|
||||||
|
...(scene.monsters?.packs ?? []).flatMap(p => p.members.map(m => m.id)),
|
||||||
|
])),
|
||||||
objects: scene.objects.length,
|
objects: scene.objects.length,
|
||||||
frames: scene.frames.length,
|
frames: scene.frames.length,
|
||||||
notes: [
|
notes: [
|
||||||
`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
|
`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
|
||||||
...(scene.monsters?.packs && scene.monsters.packs.length > 0
|
...(scene.monsters?.packs && scene.monsters.packs.length > 0
|
||||||
? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${scene.monsters.types.join('、')})`]
|
? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join('、')})`]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
pages,
|
pages,
|
||||||
|
|
@ -977,7 +980,10 @@ async function loadLiveRuntime(
|
||||||
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
|
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
|
||||||
npcs: [],
|
npcs: [],
|
||||||
monsterPacks: population.packs,
|
monsterPacks: population.packs,
|
||||||
monsterTypes: population.types,
|
monsterTypes: Array.from(new Set([
|
||||||
|
...population.types,
|
||||||
|
...population.packs.flatMap(p => p.members.map(m => m.id)),
|
||||||
|
])),
|
||||||
objects: level.objects.length,
|
objects: level.objects.length,
|
||||||
frames: scene.frames.length,
|
frames: scene.frames.length,
|
||||||
notes,
|
notes,
|
||||||
|
|
@ -1899,7 +1905,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
const paletteForEntities = next.palette
|
const paletteForEntities = next.palette
|
||||||
const packEntityBase = next.source === 'pack' ? next.base : undefined
|
const packEntityBase = next.source === 'pack' ? next.base : undefined
|
||||||
const packEntityAct = next.source === 'pack' ? next.act : undefined
|
const packEntityAct = next.source === 'pack' ? next.act : undefined
|
||||||
const activeMonsterIds = next.monsterTypes
|
const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id))
|
||||||
|
const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds]))
|
||||||
if (activeMonsterIds.length > 0) {
|
if (activeMonsterIds.length > 0) {
|
||||||
void loadMonsterArtMap(
|
void loadMonsterArtMap(
|
||||||
DEFAULT_BASES,
|
DEFAULT_BASES,
|
||||||
|
|
@ -2440,7 +2447,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||||
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||||
// Collect monster types to load
|
// Collect monster types to load
|
||||||
const activeMonsterIds = runtime.monsterTypes
|
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
|
||||||
|
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
|
||||||
|
|
||||||
if (activeMonsterIds.length > 0) {
|
if (activeMonsterIds.length > 0) {
|
||||||
void loadMonsterArtMap(
|
void loadMonsterArtMap(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ import { describe, expect, it } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { monsterPlanStatusText } from '../src/scene/act-scene.ts'
|
import { monsterPlanStatusText } from '../src/scene/act-scene.ts'
|
||||||
import * as demoData from '../src/game/demo-data.ts'
|
import * as demoData from '../src/game/demo-data.ts'
|
||||||
|
import { planLevelMonsters } from '../src/game/monsters.ts'
|
||||||
|
import { parseTable } from '../src/game/acts.ts'
|
||||||
|
import type { D2Table } from '../src/game/acts.ts'
|
||||||
|
|
||||||
|
function mockTable(tsv: string): D2Table {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
return parseTable(encoder.encode(tsv.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
const ACT_SCENE_SOURCE = readFileSync(new URL('../src/scene/act-scene.ts', import.meta.url), 'utf8')
|
const ACT_SCENE_SOURCE = readFileSync(new URL('../src/scene/act-scene.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
|
@ -43,4 +51,29 @@ describe('no synthetic monster fallback', () => {
|
||||||
expect(monsterPlanStatusText(0)).toContain('怪物 0')
|
expect(monsterPlanStatusText(0)).toContain('怪物 0')
|
||||||
expect(monsterPlanStatusText(37)).toBe(' · 怪物 37')
|
expect(monsterPlanStatusText(37)).toBe(' · 怪物 37')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('includes superunique boss and minion monster IDs in PlannedLevel.types', () => {
|
||||||
|
const levelsTxt = mockTable([
|
||||||
|
'Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3',
|
||||||
|
'110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1',
|
||||||
|
].join('\n'))
|
||||||
|
const monstatsTxt = mockTable([
|
||||||
|
'Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee',
|
||||||
|
'demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1',
|
||||||
|
'overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1',
|
||||||
|
'siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1',
|
||||||
|
].join('\n'))
|
||||||
|
const monlvlTxt = mockTable([
|
||||||
|
'Level\tAC\tTH\tHP\tDM\tXP',
|
||||||
|
'35\t100\t100\t100\t100\t100',
|
||||||
|
].join('\n'))
|
||||||
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
||||||
|
|
||||||
|
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, 'normal')
|
||||||
|
// overseer2 is Shenk's boss id, and enslaved1 is his minion id, neither in m1..m3 (siegebeast1, demonimp1, enflayer1)
|
||||||
|
expect(plan.types).toContain('overseer2')
|
||||||
|
expect(plan.types).toContain('enslaved1')
|
||||||
|
expect(plan.types).toContain('demonimp1')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { resolveMonsterArtSpec, MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
|
import { resolveMonsterArtSpec, MONSTER_ART_MAP, ACT_MONSTER_SPECS } from '../src/game/monster-mapping.ts'
|
||||||
|
|
||||||
describe('monster-mapping', () => {
|
describe('monster-mapping', () => {
|
||||||
it('resolves standard Act 1 monsters', () => {
|
it('resolves standard Act 1 monsters', () => {
|
||||||
|
|
@ -10,13 +10,36 @@ describe('monster-mapping', () => {
|
||||||
expect(resolveMonsterArtSpec('cr_archer1')).toEqual({ token: 'CR', weapon: 'bow' })
|
expect(resolveMonsterArtSpec('cr_archer1')).toEqual({ token: 'CR', weapon: 'bow' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves numbered variants through prefix fallback', () => {
|
it('resolves numbered variants through exact or base lookup without prefix iteration order', () => {
|
||||||
expect(resolveMonsterArtSpec('fallen3')?.token).toBe('FA')
|
expect(resolveMonsterArtSpec('fallen3')?.token).toBe('FA')
|
||||||
expect(resolveMonsterArtSpec('zombie5')?.token).toBe('ZM')
|
expect(resolveMonsterArtSpec('zombie5')?.token).toBe('ZM')
|
||||||
expect(resolveMonsterArtSpec('quillrat6')?.token).toBe('SI')
|
expect(resolveMonsterArtSpec('quillrat6')?.token).toBe('SI')
|
||||||
|
// Suffix with arbitrary letters must not prefix-match
|
||||||
|
expect(resolveMonsterArtSpec('fallen_fake')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves hellbovine to EC token instead of CW', () => {
|
||||||
|
expect(resolveMonsterArtSpec('hellbovine')).toEqual({ token: 'EC', weapon: 'hth' })
|
||||||
|
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not leak XX tokens or crash on cut monsters', () => {
|
||||||
|
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'minion1', 'spiritmummy']
|
||||||
|
for (const id of cutMonsters) {
|
||||||
|
expect(resolveMonsterArtSpec(id)).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACT_MONSTER_SPECS does not contain any XX tokens in any act', () => {
|
||||||
|
for (const [act, specs] of Object.entries(ACT_MONSTER_SPECS)) {
|
||||||
|
for (const spec of specs) {
|
||||||
|
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null for unknown monsters', () => {
|
it('returns null for unknown monsters', () => {
|
||||||
expect(resolveMonsterArtSpec('non_existent_monster_xyz')).toBeNull()
|
expect(resolveMonsterArtSpec('non_existent_monster_xyz')).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { D2Table } from '../src/game/acts.ts'
|
import type { D2Table } from '../src/game/acts.ts'
|
||||||
import {
|
import {
|
||||||
DENSITY_CELLS_PER_MONSTER,
|
DENSITY_CELLS_PER_MONSTER,
|
||||||
|
DEFAULT_REACH_PX,
|
||||||
|
ELITE_HEALTH_MULTIPLIER,
|
||||||
|
MONUMOD_CONSTANTS,
|
||||||
monsterBudget,
|
monsterBudget,
|
||||||
monsterStatsOf,
|
monsterStatsOf,
|
||||||
planMonsterGroups,
|
planMonsterGroups,
|
||||||
readEliteModifiers,
|
readEliteModifiers,
|
||||||
readLevelMonsterPlan,
|
readLevelMonsterPlan,
|
||||||
|
readMonUModConstants,
|
||||||
planLevelMonsters,
|
planLevelMonsters,
|
||||||
monsterLevelFor, monsterScaleFor,
|
monsterLevelFor, monsterScaleFor,
|
||||||
monsterScaleLookup,
|
monsterScaleLookup,
|
||||||
|
|
@ -530,6 +534,59 @@ describe('monsterStatsOf', () => {
|
||||||
const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' }
|
const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' }
|
||||||
expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1')
|
expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calculates reach from art meleeRange with DEFAULT_REACH_PX floor', () => {
|
||||||
|
const fallen = kinds.get('fallen1')!
|
||||||
|
expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 0 }).reach).toBe(DEFAULT_REACH_PX)
|
||||||
|
expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 1 }).reach).toBe(DEFAULT_REACH_PX)
|
||||||
|
expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 4 }).reach).toBe(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to kind.meleeRange when art is omitted', () => {
|
||||||
|
const withReach: MonsterKind = { ...kinds.get('fallen1')!, meleeRange: 5 }
|
||||||
|
expect(monsterStatsOf(withReach, new Rng(1), WALK).reach).toBe(80)
|
||||||
|
const base = kinds.get('fallen1')!
|
||||||
|
expect(monsterStatsOf(base, new Rng(1), WALK).reach).toBe(DEFAULT_REACH_PX)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readMonUModConstants and MonUMod constants', () => {
|
||||||
|
it('reads constant desc and numeric constant values from MonUMod table', () => {
|
||||||
|
const tableData = table(
|
||||||
|
['uniquemod', 'id', 'constants', '*constant desc'],
|
||||||
|
[
|
||||||
|
['', '', '20', 'champion chance'],
|
||||||
|
['', '', '100', 'minion +hp%'],
|
||||||
|
['', '', '200', 'champion +hp%'],
|
||||||
|
['', '', '300', 'unique +hp%'],
|
||||||
|
['', '', '150', 'unique +dmg% (strong)'],
|
||||||
|
['', '', '', 'empty row'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const constants = readMonUModConstants(tableData)
|
||||||
|
expect(constants.get('champion chance')).toBe(20)
|
||||||
|
expect(constants.get('minion +hp%')).toBe(100)
|
||||||
|
expect(constants.get('champion +hp%')).toBe(200)
|
||||||
|
expect(constants.get('unique +hp%')).toBe(300)
|
||||||
|
expect(constants.get('unique +dmg% (strong)')).toBe(150)
|
||||||
|
expect(constants.has('empty row')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provides canonical MONUMOD_CONSTANTS with expected values', () => {
|
||||||
|
expect(MONUMOD_CONSTANTS.championChance).toBe(20)
|
||||||
|
expect(MONUMOD_CONSTANTS.minionHpPct).toBe(100)
|
||||||
|
expect(MONUMOD_CONSTANTS.championHpPct).toBe(200)
|
||||||
|
expect(MONUMOD_CONSTANTS.uniqueHpPct).toBe(300)
|
||||||
|
expect(MONUMOD_CONSTANTS.uniqueDmgPctStrong).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates ELITE_HEALTH_MULTIPLIER using MonUMod constants', () => {
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.normal).toBe(1)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.champion).toBe(1 + MONUMOD_CONSTANTS.championHpPct / 100)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.unique).toBe(1 + MONUMOD_CONSTANTS.uniqueHpPct / 100)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.champion).toBe(3)
|
||||||
|
expect(ELITE_HEALTH_MULTIPLIER.unique).toBe(4)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('readMonsterScaling', () => {
|
describe('readMonsterScaling', () => {
|
||||||
|
|
@ -649,6 +706,25 @@ describe('planLevelMonsters reports missing MonLvl rows', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('planLevelMonsters with monstats2 table', () => {
|
||||||
|
it('wires monster art into monster stats reach', () => {
|
||||||
|
const monstats2WithReach = table(
|
||||||
|
['Id', 'SizeX', 'SizeY', 'pixHeight', 'MeleeRng', 'BaseW', 'TotalPieces', 'dDT'],
|
||||||
|
[
|
||||||
|
['fallen1', '2', '2', '64', '4', 'hth', '6', '8'],
|
||||||
|
['zombie1', '2', '2', '80', '0', 'hth', '1', '8'],
|
||||||
|
['quillrat1', '2', '2', '48', '1', 'hth', '1', '8'],
|
||||||
|
['quillrat6', '2', '2', '48', '1', 'hth', '1', '8'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const tables = { levels: LEVELS, monstats: MONSTATS, monlvl: MONLVL, monstats2: monstats2WithReach }
|
||||||
|
const plan = planLevelMonsters(tables, 2, 6400, 1234, 170)
|
||||||
|
const fallenMember = plan.packs.flatMap(p => p.members).find(m => m.id === 'fallen1')
|
||||||
|
expect(fallenMember).toBeDefined()
|
||||||
|
expect(fallenMember?.reach).toBe(64)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('monsterStatsOf with MonLvl scaling', () => {
|
describe('monsterStatsOf with MonLvl scaling', () => {
|
||||||
const kinds = readMonsterKinds(MONSTATS)
|
const kinds = readMonsterKinds(MONSTATS)
|
||||||
const scaling = readMonsterScaling(MONLVL)
|
const scaling = readMonsterScaling(MONLVL)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
||||||
it('maps 700+ monster IDs across Acts 1..5 in MONSTER_ART_MAP', () => {
|
it('maps 700+ monster IDs across Acts 1..5 in MONSTER_ART_MAP', () => {
|
||||||
expect(Object.keys(MONSTER_ART_MAP).length).toBeGreaterThanOrEqual(700)
|
expect(Object.keys(MONSTER_ART_MAP).length).toBeGreaterThanOrEqual(700)
|
||||||
expect(MONSTER_ART_MAP.fallen).toEqual({ token: 'FA', weapon: 'hth' })
|
expect(MONSTER_ART_MAP.fallen).toEqual({ token: 'FA', weapon: 'hth' })
|
||||||
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'CW', weapon: 'hth' })
|
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' })
|
||||||
expect(MONSTER_ART_MAP.skeleton).toEqual({ token: 'SK', weapon: 'hth' })
|
expect(MONSTER_ART_MAP.skeleton).toEqual({ token: 'SK', weapon: 'hth' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -103,7 +103,7 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
||||||
uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`)
|
uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(uniqueSpecs.size).toBe(66)
|
expect(uniqueSpecs.size).toBe(65)
|
||||||
|
|
||||||
for (const key of uniqueSpecs) {
|
for (const key of uniqueSpecs) {
|
||||||
const slug = `monster-${key}`
|
const slug = `monster-${key}`
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import {
|
||||||
planLevelMonsters,
|
planLevelMonsters,
|
||||||
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
|
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
|
||||||
CANONICAL_ELITE_MODIFIERS,
|
CANONICAL_ELITE_MODIFIERS,
|
||||||
|
lookupSuperUnique,
|
||||||
|
buildSuperUniqueLandmarkSpecs,
|
||||||
|
CANONICAL_SUPERUNIQUES_TABLE,
|
||||||
} from "../src/game/monsters.ts"
|
} from "../src/game/monsters.ts"
|
||||||
import { parseTable } from "../src/game/acts.ts"
|
import { parseTable } from "../src/game/acts.ts"
|
||||||
import type { D2Table } from "../src/game/acts.ts"
|
import type { D2Table } from "../src/game/acts.ts"
|
||||||
|
|
@ -393,5 +396,92 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
||||||
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
|
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
|
||||||
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
|
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("lookupSuperUnique retrieves canonical superuniques by id or name", () => {
|
||||||
|
const bishibosh = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "Bishibosh")
|
||||||
|
expect(bishibosh).toBeDefined()
|
||||||
|
expect(bishibosh?.monsterId).toBe("fallenshaman1")
|
||||||
|
expect(bishibosh?.minMinions).toBe(5)
|
||||||
|
expect(bishibosh?.maxMinions).toBe(7)
|
||||||
|
|
||||||
|
const caseInsensitive = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "rakanishu")
|
||||||
|
expect(caseInsensitive).toBeDefined()
|
||||||
|
expect(caseInsensitive?.id).toBe("Rakanishu")
|
||||||
|
expect(caseInsensitive?.monsterId).toBe("fallen1")
|
||||||
|
|
||||||
|
expect(lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "nonexistent")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("buildSuperUniqueLandmarkSpecs builds specs from custom SuperUniques table", () => {
|
||||||
|
const customSuTable = mockTable([
|
||||||
|
"Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)",
|
||||||
|
"Bishibosh\tBishibosh Custom\tfallenshaman2\t6\t28\t0\t8\t10\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A",
|
||||||
|
].join("\n"))
|
||||||
|
|
||||||
|
const specsByLevel = buildSuperUniqueLandmarkSpecs(customSuTable)
|
||||||
|
const bishiboshSpec = specsByLevel[3]
|
||||||
|
expect(bishiboshSpec).toBeDefined()
|
||||||
|
expect(Array.isArray(bishiboshSpec)).toBe(false)
|
||||||
|
const spec = bishiboshSpec as import("../src/game/monsters.ts").SuperUniqueLandmarkSpec
|
||||||
|
expect(spec.name).toBe("Bishibosh Custom")
|
||||||
|
expect(spec.monsterId).toBe("fallenshaman2")
|
||||||
|
expect(spec.minMinions).toBe(8)
|
||||||
|
expect(spec.maxMinions).toBe(10)
|
||||||
|
expect(spec.modifiers).toEqual(["fast", "stoneskin"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("planLevelMonsters skips boss without falling back to fallen1 when boss monster is missing", () => {
|
||||||
|
const levelsTxt = mockTable([
|
||||||
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
||||||
|
"4\tStony Field\t600\t2\t4\t7\tzombie1\tquillrat1\tbrute1",
|
||||||
|
].join("\n"))
|
||||||
|
// Only define zombie1 in monstats - Rakanishu is fallen1, which is absent
|
||||||
|
const monstatsTxt = mockTable([
|
||||||
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
||||||
|
"zombie1\tzombie1\tZombie\t7\t1\t2\t4\t6\t10\t20\t10\t20\t2\t5\t20\t1\t1\t1\t1",
|
||||||
|
].join("\n"))
|
||||||
|
const monlvlTxt = mockTable([
|
||||||
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
||||||
|
"7\t100\t100\t100\t100\t100",
|
||||||
|
].join("\n"))
|
||||||
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
||||||
|
|
||||||
|
const plan = planLevelMonsters(tables, 4, 6400, 12345, 3.5, "normal")
|
||||||
|
// Rakanishu should NOT be spawned as zombie1 or fallen1 (fallen1 fallback was removed)
|
||||||
|
expect(plan.superUniques?.includes("Rakanishu")).toBeFalsy()
|
||||||
|
const rakanishuPack = plan.packs.find(p => p.superUniqueId === "Rakanishu")
|
||||||
|
expect(rakanishuPack).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("planLevelMonsters uses tables.superuniques when provided", () => {
|
||||||
|
const levelsTxt = mockTable([
|
||||||
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
||||||
|
"3\tCold Plains\t600\t2\t4\t5\tfallenshaman1\tfallen1\tzombie1",
|
||||||
|
].join("\n"))
|
||||||
|
const monstatsTxt = mockTable([
|
||||||
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
||||||
|
"fallenshaman1\tfallenshaman1\tFallen Shaman\t5\t1\t1\t6\t9\t10\t20\t15\t25\t2\t4\t20\t1\t1\t1\t1",
|
||||||
|
"fallen1\tfallen1\tFallen\t5\t3\t5\t8\t12\t5\t10\t5\t10\t1\t2\t15\t1\t1\t1\t1",
|
||||||
|
].join("\n"))
|
||||||
|
const monlvlTxt = mockTable([
|
||||||
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
||||||
|
"5\t100\t100\t100\t100\t100",
|
||||||
|
].join("\n"))
|
||||||
|
const customSuTable = mockTable([
|
||||||
|
"Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)",
|
||||||
|
"Bishibosh\tBishibosh Ultra\tfallenshaman1\t6\t0\t0\t3\t3\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A",
|
||||||
|
].join("\n"))
|
||||||
|
|
||||||
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt, superuniques: customSuTable }
|
||||||
|
const plan = planLevelMonsters(tables, 3, 6400, 54321, 3.5, "normal")
|
||||||
|
|
||||||
|
expect(plan.superUniques).toContain("Bishibosh")
|
||||||
|
const bishiboshPack = plan.packs.find(p => p.superUniqueId === "Bishibosh")
|
||||||
|
expect(bishiboshPack).toBeDefined()
|
||||||
|
expect(bishiboshPack?.members[0]?.name).toBe("Bishibosh Ultra")
|
||||||
|
expect(bishiboshPack?.members[0]?.modifiers).toEqual(["fast"])
|
||||||
|
// 1 boss + 3 minions = 4 members
|
||||||
|
expect(bishiboshPack?.members.length).toBe(4)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue