fix(hud): enforce 1.13c Line 1 rank color prefix, MonUMod champion subtype IDs, and elite=all demotion

- Apply abilityColorPrefix (ÿc4 for Unique/Boss, ÿc3 for Champion, '' for Normal/Minion) across the entire Line 1 subtext in buildMonsterHoverSubtext per D2Client.dll 0x6fb719b0 / 0x6fb71a20 so <Race> (Demon/Undead) and race-only Champions/Bosses (DescStr == 'dummy') render in their rank color instead of white (ÿc0), and single-token overflow truncation preserves the rank color.
- Support numeric MonUMod.txt Champion subtype IDs ('16', '36'..'39' from .rdata:0x6fb86d64), case-insensitive modifier keys, and target.affixes fallback in resolveMonsterTitleAndColor, and forward descStr, subType, and affixes in monsterToTargetHudInfo.
- Demote already-elite packs in rebudgetMonsterPacks before re-promoting when elite === 'all' to prevent double-multiplying HP (9x/16x) and level (+4/+6).
- Call mouseController.syncMonsterTargetHud(camX, camY) before reading getActiveTargetMonster() in onRender so moving monsters/camera have zero 1-frame lag on the [1.25, 1.25, 1.25, 1.0] sprite highlight tint.
- Add Round 2 regression tests in tests/monster-target-bar.test.ts.

TAG=agy
CONV=4761a588-f887-4d60-a654-44297372fd6a
This commit is contained in:
troytt 2026-09-28 06:19:49 +00:00
parent 3a4cb77d06
commit 0be447fab3
3 changed files with 201 additions and 21 deletions

View File

@ -1046,6 +1046,11 @@ export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques)
: monster.stats
const r = stats.rank
const statsAny = stats as MonsterStats & {
readonly descStr?: string
readonly subType?: string
readonly affixes?: readonly string[]
}
return {
name: stats.name || stats.id,
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
@ -1067,7 +1072,10 @@ export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}),
...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}),
...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}),
...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}),
...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}),
...(stats.resists !== undefined ? { resists: stats.resists } : {}),
...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}),
}
@ -1131,9 +1139,10 @@ export function rebudgetMonsterPacks(
// 2. Elite scaling on regular packs
if (elite === 'all') {
scaledRegularPacks = scaledRegularPacks.map((pack, idx) => {
const normalPack = demoteToNormal(pack)
const packRng = rng.fork(`all-elite-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
return isChampion ? promoteToChampion(pack, packRng) : promoteToUnique(pack, packRng)
return isChampion ? promoteToChampion(normalPack, packRng) : promoteToUnique(normalPack, packRng)
})
} else if (elite !== 1) {
const currentEliteIndices: number[] = []
@ -9346,6 +9355,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
})
mouseController.syncMonsterTargetHud(camX, camY)
const activeTargetMonster = mouseController.getActiveTargetMonster()
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' && monster.corpseTicks <= 0) {

View File

@ -3104,14 +3104,21 @@ export function lookupMonsterTblString(key: string, locale: 'en' | 'zh' = 'en'):
}
const CHAMPION_SUBTYPE_TBL_KEYS: Readonly<Record<string, string>> = {
'16': 'Champion',
champion: 'Champion',
'36': 'champghostlyX',
ghostly: 'champghostlyX',
champghostlyX: 'champghostlyX',
champghostlyx: 'champghostlyX',
'37': 'champfanaticX',
fanatic: 'champfanaticX',
champfanaticX: 'champfanaticX',
champfanaticx: 'champfanaticX',
'38': 'champpossessedX',
possessed: 'champpossessedX',
champpossessedX: 'champpossessedX',
champpossessedx: 'champpossessedX',
'39': 'champberserkX',
berserk: 'champberserkX',
champberserkX: 'champberserkX',
berserker: 'champberserkX',
champberserkx: 'champberserkX',
}
/**
@ -3146,13 +3153,16 @@ export function resolveMonsterTitleAndColor(
if (target.rank === 'champion') {
let champTblKey = 'Champion'
if (target.modifiers) {
for (const mod of target.modifiers) {
const mapped = CHAMPION_SUBTYPE_TBL_KEYS[mod]
if (mapped) {
champTblKey = mapped
break
}
const mods =
target.modifiers && target.modifiers.length > 0
? target.modifiers
: (target.affixes ?? [])
for (const mod of mods) {
const normalized = String(mod).trim().toLowerCase()
const mapped = CHAMPION_SUBTYPE_TBL_KEYS[normalized]
if (mapped) {
champTblKey = mapped
break
}
}
const prefix = lookupMonsterTblString(champTblKey, locale) ?? 'Champion'
@ -3330,9 +3340,9 @@ const MONSTER_UMOD_TBL_KEYS: Readonly<Record<string, { readonly tblKey: string;
*
* - Line 1 (`y = 55`):
* - Race (`Undead` if `isUndead`, `Demon` if `isDemon`, omitted for animals/beasts)
* - Unique/SuperUnique: `" "`-separated modifiers + `DescStr` in `gold` (`ÿc4`)
* - Minion: `Minion` (`3221`) in `white` (`ÿc0`)
* - Normal/Champion/Boss: `DescStr` in rank color (`white` / `blue` / `gold`)
* - Unique/SuperUnique: `" "`-separated `<Race> <Modifiers> <DescStr>` in `gold` (`ÿc4`)
* - Minion: `<Race> Minion` (`3221`) in `white` (`ÿc0`)
* - Normal/Champion/Boss: `<Race> <DescStr>` in rank color (`white` / `blue` / `gold`)
* - Line 2 (`y = 69` when Line 1 is non-empty, `y = 55` when Line 1 is empty):
* - `", "`-separated immunities (`res >= 100`) in exact `.rdata:0x6FB834EC` order.
*/
@ -3390,19 +3400,15 @@ export function buildMonsterHoverSubtext(
}
let line1 = ''
let line1Colored = ''
if (raceText !== '' && abilityPlain !== '') {
line1 = `${raceText} ${abilityPlain}`
line1Colored = abilityColorPrefix
? `ÿc0${raceText} ${abilityColorPrefix}${abilityPlain}`
: `${raceText} ${abilityPlain}`
} else if (raceText !== '') {
line1 = raceText
line1Colored = raceText
} else if (abilityPlain !== '') {
line1 = abilityPlain
line1Colored = abilityColorPrefix ? `${abilityColorPrefix}${abilityPlain}` : abilityPlain
}
const line1Colored =
line1 !== '' && abilityColorPrefix !== '' ? `${abilityColorPrefix}${line1}` : line1
const res = target.resists ?? target.resistances
const immPlainParts: string[] = []

View File

@ -28,6 +28,7 @@ import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../sr
import {
SceneMouseController,
monsterToTargetHudInfo,
rebudgetMonsterPacks,
type MapRuntime,
} from '../src/scene/act-scene.ts'
import {
@ -990,5 +991,168 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
expect(controller.lockedMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('renders entire Subtext Line 1 (including Race and race-only Champions/Bosses) in rank color per 0x6fb719b0 and 0x6fb71a20', () => {
// 1. Unique Undead with modifiers + DescStr -> entire line1Colored starts with ÿc4 (gold)
const uniqueUndead = buildMonsterHoverSubtext(
{
name: 'Corpsefire',
nameKey: 'Corpsefire',
descKey: 'raises Fallen',
hp: 100,
maxHp: 100,
rank: 'unique',
isUndead: true,
modifiers: ['strong', 'spectralhit'],
},
'en',
)
expect(uniqueUndead.line1).toBe('Undead Extra Strong Spectral Hit Raises Fallen')
expect(uniqueUndead.line1Colored).toBe('ÿc4Undead Extra Strong Spectral Hit Raises Fallen')
// 2. Champion Demon without DescStr -> line1Colored is blue ('ÿc3Demon'), not white
const champFallen = buildMonsterHoverSubtext(
{
name: 'Fallen',
nameKey: 'Fallen',
descKey: 'dummy',
hp: 60,
maxHp: 60,
rank: 'champion',
isDemon: true,
},
'en',
)
expect(champFallen.line1).toBe('Demon')
expect(champFallen.line1Colored).toBe('ÿc3Demon')
// 3. Champion Demon with DescStr -> entire line1Colored is blue ('ÿc3Demon Raises Fallen')
const champShaman = buildMonsterHoverSubtext(
{
name: 'Fallen Shaman',
nameKey: 'FallenShaman',
descKey: 'raises Fallen',
hp: 90,
maxHp: 90,
rank: 'champion',
isDemon: true,
},
'en',
)
expect(champShaman.line1).toBe('Demon Raises Fallen')
expect(champShaman.line1Colored).toBe('ÿc3Demon Raises Fallen')
// 4. Act Boss (Andariel, descKey = 'dummy') -> line1Colored is gold ('ÿc4Demon')
const andarielSub = buildMonsterHoverSubtext(
{
name: 'Andariel',
nameKey: 'Andariel',
descKey: 'dummy',
hp: 500,
maxHp: 500,
rank: 'boss',
isBoss: true,
isDemon: true,
},
'en',
)
expect(andarielSub.line1).toBe('Demon')
expect(andarielSub.line1Colored).toBe('ÿc4Demon')
// 5. Minion Demon -> line1Colored is white ('Demon Minion')
const minionSub = buildMonsterHoverSubtext(
{
name: 'Fallen',
nameKey: 'Fallen',
hp: 30,
maxHp: 30,
rank: 'minion',
isDemon: true,
},
'en',
)
expect(minionSub.line1).toBe('Demon Minion')
expect(minionSub.line1Colored).toBe('Demon Minion')
})
it('resolves Champion subtype prefixes from numeric MonUMod IDs (16, 36..39), case-variants, and affixes fallback (.rdata:0x6fb86d64)', () => {
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['16'] },
'en',
).displayName,
).toBe('Champion Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['36'] },
'en',
).displayName,
).toBe('Ghostly Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['37'] },
'zh',
).displayName,
).toBe(`${lookupMonsterTblString('champfanaticX', 'zh')} 沈淪魔`)
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['38'] },
'en',
).displayName,
).toBe('Possessed Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', affixes: ['39'] },
'en',
).displayName,
).toBe('Berserker Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', affixes: ['Berserker'] },
'zh',
).displayName,
).toBe(`${lookupMonsterTblString('champberserkX', 'zh')} 沈淪魔`)
})
it('demotes already-elite packs before promoting when rebudgetMonsterPacks is called with eliteMultiplier="all"', () => {
const normalBaseHp = 30
const alreadyChampionPack = {
members: [
{
id: 'fallen1',
name: 'Fallen',
level: 3, // base level 1 + 2 champion bonus
hp: normalBaseHp * 3, // 90 (already 3x champion HP)
damage: 5,
cooldownTicks: 15,
reach: 48,
aggroRadius: 200,
speed: 6,
xp: 25,
rank: 'champion' as const,
modifiers: ['ghostly'],
},
],
}
const rebudgeted = rebudgetMonsterPacks([alreadyChampionPack], {
levelId: 2,
densityMultiplier: 1,
eliteMultiplier: 'all',
seed: 777,
})
expect(rebudgeted).toHaveLength(1)
const leader = rebudgeted[0]!.members[0]!
// Leader HP must be 3x (90) or 4x (120) of normalBaseHp (30), never 9x (270) or 12x (360)!
expect(leader.hp === normalBaseHp * 3 || leader.hp === normalBaseHp * 4).toBe(true)
// Leader level must be base 1 + 2 (3) or base 1 + 3 (4), never +4 or +5!
expect(leader.level === 3 || leader.level === 4).toBe(true)
})
})
})