From 0be447fab3d079d9405ee956ea4946f254388349 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 06:19:49 +0000 Subject: [PATCH] fix(hud): enforce 1.13c Line 1 rank color prefix, MonUMod champion subtype IDs, and elite=all demotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (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 --- src/scene/act-scene.ts | 12 ++- src/ui/world-panels.ts | 46 +++++---- tests/monster-target-bar.test.ts | 164 +++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 21 deletions(-) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index aece955..2484c8b 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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) { diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 13188dd..3f98894 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -3104,14 +3104,21 @@ export function lookupMonsterTblString(key: string, locale: 'en' | 'zh' = 'en'): } const CHAMPION_SUBTYPE_TBL_KEYS: Readonly> = { + '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 ` in `gold` (`ÿc4`) + * - Minion: ` Minion` (`3221`) in `white` (`ÿc0`) + * - Normal/Champion/Boss: ` ` 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[] = [] diff --git a/tests/monster-target-bar.test.ts b/tests/monster-target-bar.test.ts index b01ba56..5403f86 100644 --- a/tests/monster-target-bar.test.ts +++ b/tests/monster-target-bar.test.ts @@ -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) + }) }) }) +