1286 lines
48 KiB
TypeScript
1286 lines
48 KiB
TypeScript
/**
|
|
* tests/monster-target-bar.test.ts
|
|
*
|
|
* Comprehensive unit & integration tests for Diablo II 1.13c Monster Hover Lifebar
|
|
* and 2-Line Subtext Tooltip Parity:
|
|
* - D2Client.dll `0x6fb739e0` (`UpdateSelectedUnitOnMouseMove`), `0x6fb01a80` (`FindSelectableUnitUnderCursor`)
|
|
* - D2Client.dll `0x6fb70850` (`DrawSelectedMonsterLifeBar`), `0x6facfdd0` (`BuildMonsterHoverSubtext`)
|
|
* - D2Client.dll `0x6fb6fca0` (`AppendMonsterTypeAndDesc`), `0x6fb719b0` (`AppendMonsterModifiers`), `0x6fb71a20` (`AppendMonsterImmunities`)
|
|
* - D2Win.dll `Ordinal_10070` (`DrawMonsterLifeBarBox`)
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
WorldPanelsHud,
|
|
lookupMonsterTblString,
|
|
resolveMonsterTitleAndColor,
|
|
computeMonsterLifebarLayout,
|
|
buildMonsterHoverSubtext,
|
|
CANONICAL_IMMUNITY_DESCRIPTORS,
|
|
type MonsterTargetHudInfo,
|
|
} from '../src/ui/world-panels.ts'
|
|
import { D2FontRenderer } from '../src/ui/font.ts'
|
|
import { HudManager } from '../src/ui/hud-manager.ts'
|
|
import { ViewportCamera } from '../src/sim/camera.ts'
|
|
import { KeyboardInput } from '../src/sim/input.ts'
|
|
import { WaypointNetwork } from '../src/game/portal.ts'
|
|
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts'
|
|
import {
|
|
SceneMouseController,
|
|
monsterToTargetHudInfo,
|
|
rebudgetMonsterPacks,
|
|
type MapRuntime,
|
|
} from '../src/scene/act-scene.ts'
|
|
import {
|
|
readMonsterKinds,
|
|
readSuperUniques,
|
|
monsterStatsOf,
|
|
applyEliteModifiers,
|
|
enrichMonsterPacks,
|
|
} from '../src/game/monsters.ts'
|
|
import { Rng } from '../src/game/rng.ts'
|
|
import { parseTable } from '../src/game/acts.ts'
|
|
import { RAW_MONSTATS, RAW_SUPERUNIQUES } from '../src/data/canonical-drop-data.ts'
|
|
|
|
// Headless DOM mocks for SceneMouseController testing
|
|
class MockWindow extends EventTarget {
|
|
innerWidth = 800
|
|
innerHeight = 600
|
|
devicePixelRatio = 1
|
|
}
|
|
|
|
class MockCanvas extends EventTarget {
|
|
clientWidth = 800
|
|
clientHeight = 600
|
|
width = 800
|
|
height = 600
|
|
style = { pointerEvents: 'none' }
|
|
getBoundingClientRect() {
|
|
return { left: 0, top: 0, width: 800, height: 600 }
|
|
}
|
|
getContext() {
|
|
return null
|
|
}
|
|
}
|
|
|
|
class SyntheticPointerEvent extends Event {
|
|
readonly clientX: number
|
|
readonly clientY: number
|
|
readonly button: number
|
|
readonly buttons: number
|
|
readonly shiftKey: boolean
|
|
|
|
constructor(
|
|
type: string,
|
|
init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean },
|
|
) {
|
|
super(type, { cancelable: true })
|
|
this.clientX = init.clientX
|
|
this.clientY = init.clientY
|
|
this.button = init.button ?? 0
|
|
this.buttons = init.buttons !== undefined ? init.buttons : 1 << (init.button ?? 0)
|
|
this.shiftKey = init.shiftKey ?? false
|
|
}
|
|
}
|
|
|
|
if (typeof globalThis.window === 'undefined') {
|
|
// @ts-ignore
|
|
globalThis.window = new MockWindow()
|
|
}
|
|
if (typeof globalThis.HTMLInputElement === 'undefined') {
|
|
// @ts-ignore
|
|
globalThis.HTMLInputElement = class HTMLInputElement {}
|
|
}
|
|
if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
|
// @ts-ignore
|
|
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
|
}
|
|
|
|
function createMockFont(charWidth = 8): D2FontRenderer & {
|
|
calls: Array<{ text: string; x: number; y: number; opts?: any }>
|
|
} {
|
|
const calls: Array<{ text: string; x: number; y: number; opts?: any }> = []
|
|
return {
|
|
calls,
|
|
measureText: vi.fn((text: string) => text.length * charWidth),
|
|
drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => {
|
|
calls.push({ text, x, y, opts })
|
|
}),
|
|
} as unknown as D2FontRenderer & {
|
|
calls: Array<{ text: string; x: number; y: number; opts?: any }>
|
|
}
|
|
}
|
|
|
|
function createHoverTestScene() {
|
|
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
|
|
const win = new MockWindow()
|
|
const input = new KeyboardInput(win as unknown as Window)
|
|
input.attach()
|
|
|
|
const dummyTerrain: WorldMapProvider = {
|
|
widthPx: 2000,
|
|
heightPx: 2000,
|
|
overlap: () => 0,
|
|
}
|
|
|
|
const dummyOpts: GameEngineOptions = {
|
|
spawn: { x: 500, y: 500 },
|
|
stats: [
|
|
{
|
|
id: 'fallen1',
|
|
name: 'Fallen',
|
|
nameKey: 'Fallen',
|
|
descKey: '',
|
|
isUndead: false,
|
|
isDemon: true,
|
|
isBoss: false,
|
|
killable: true,
|
|
inTown: false,
|
|
rank: 'normal',
|
|
modifiers: [],
|
|
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
|
|
hp: 20,
|
|
damage: 2,
|
|
cooldownTicks: 25,
|
|
reach: 24,
|
|
aggroRadius: 100,
|
|
speed: 0,
|
|
xp: 18,
|
|
},
|
|
{
|
|
id: 'zombie1',
|
|
name: 'Zombie',
|
|
nameKey: 'Zombie',
|
|
descKey: '',
|
|
isUndead: true,
|
|
isDemon: false,
|
|
isBoss: false,
|
|
killable: true,
|
|
inTown: false,
|
|
rank: 'unique',
|
|
modifiers: ['strong', 'cursed'],
|
|
resistances: { physical: 50, magic: 0, fire: 0, lightning: 0, cold: 100, poison: 75 },
|
|
hp: 80,
|
|
damage: 5,
|
|
cooldownTicks: 25,
|
|
reach: 24,
|
|
aggroRadius: 100,
|
|
speed: 0,
|
|
xp: 50,
|
|
},
|
|
],
|
|
xpTable: [0, 0, 100, 200],
|
|
itemBases: [],
|
|
prefixAffixes: [],
|
|
suffixAffixes: [],
|
|
skills: [],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {
|
|
playerSpeed: 100,
|
|
playerReach: 52,
|
|
playerCooldownTicks: 10,
|
|
playerDamage: 10,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 100,
|
|
},
|
|
lootSeed: 42,
|
|
talkRadius: 80,
|
|
pickupRadius: 50,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
}
|
|
|
|
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
|
// Keep exactly 2 deterministic monsters for hover hit-testing
|
|
engine.world.monsters.splice(2)
|
|
;(engine.world.monsters[0]! as { stats: unknown }).stats = dummyOpts.stats[0]!
|
|
engine.world.monsters[0]!.hp = dummyOpts.stats[0]!.hp
|
|
engine.world.monsters[0]!.x = 560
|
|
engine.world.monsters[0]!.y = 500
|
|
engine.world.monsters[0]!.drawBounds = { x: 544, y: 450, w: 32, h: 54 }
|
|
|
|
;(engine.world.monsters[1]! as { stats: unknown }).stats = dummyOpts.stats[1]!
|
|
engine.world.monsters[1]!.hp = dummyOpts.stats[1]!.hp
|
|
engine.world.monsters[1]!.x = 650
|
|
engine.world.monsters[1]!.y = 500
|
|
engine.world.monsters[1]!.drawBounds = { x: 630, y: 440, w: 40, h: 64 }
|
|
|
|
const camera = new ViewportCamera(canvas, 2000, 2000)
|
|
camera.zoom = 1
|
|
|
|
const hudManager = new HudManager(canvas, {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
|
|
const waypointNetwork = new WaypointNetwork()
|
|
const status = { textContent: '' } as HTMLElement
|
|
|
|
const runtime: MapRuntime = {
|
|
base: 'samples/d2-packs',
|
|
act: 1,
|
|
level: 'Blood Moor',
|
|
levelId: 2,
|
|
variant: 'default',
|
|
variants: ['default'],
|
|
quadrant: 'default',
|
|
quadrants: ['default'],
|
|
cellsX: 100,
|
|
cellsY: 100,
|
|
widthPx: 2000,
|
|
heightPx: 2000,
|
|
floors: [],
|
|
walls: [],
|
|
roofs: [],
|
|
objectDrawables: [],
|
|
pages: [],
|
|
priorityPages: [],
|
|
objectPages: [],
|
|
grid: {
|
|
originX: 0,
|
|
originY: 0,
|
|
cellsX: 100,
|
|
cellsY: 100,
|
|
blocked: new Uint8Array(500 * 500),
|
|
gridWidth: 500,
|
|
},
|
|
npcs: [],
|
|
entrances: [],
|
|
warps: [],
|
|
waypoints: [],
|
|
animatedDrawables: [],
|
|
animSpeed: 1,
|
|
frameDurationMs: 40,
|
|
palette: null,
|
|
charBases: [],
|
|
monsterTypes: [],
|
|
monsterPacks: [],
|
|
entryPath: '',
|
|
loadPages: async () => {},
|
|
} as unknown as MapRuntime
|
|
|
|
const controller = new SceneMouseController({
|
|
canvas,
|
|
engine,
|
|
camera,
|
|
input,
|
|
getRuntime: () => runtime,
|
|
hudManager,
|
|
waypointNetwork,
|
|
status,
|
|
})
|
|
controller.attach()
|
|
|
|
return { canvas, engine, camera, hudManager, controller }
|
|
}
|
|
|
|
describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () => {
|
|
describe('R1: Canonical .tbl Strings & MonStats.txt Metadata Pipeline', () => {
|
|
it('packs required MonStats.txt columns and resolves canonical ENG & CHI .tbl strings', () => {
|
|
const monstatsTable = parseTable(new TextEncoder().encode(RAW_MONSTATS))
|
|
expect(monstatsTable.header).toContain('DescStr')
|
|
expect(monstatsTable.header).toContain('lUndead')
|
|
expect(monstatsTable.header).toContain('hUndead')
|
|
expect(monstatsTable.header).toContain('demon')
|
|
expect(monstatsTable.header).toContain('killable')
|
|
expect(monstatsTable.header).toContain('inTown')
|
|
expect(monstatsTable.header).toContain('ResDm')
|
|
expect(monstatsTable.header).toContain('ResCo(H)')
|
|
|
|
// Base monster names
|
|
expect(lookupMonsterTblString('Fallen', 'en')).toBe('Fallen')
|
|
expect(lookupMonsterTblString('Fallen', 'zh')).toBe('沈淪魔')
|
|
expect(lookupMonsterTblString('Zombie', 'en')).toBe('Zombie')
|
|
expect(lookupMonsterTblString('Zombie', 'zh')).toBe('僵屍')
|
|
|
|
// SuperUniques
|
|
expect(lookupMonsterTblString('Bishibosh', 'en')).toBe('Bishibosh')
|
|
expect(lookupMonsterTblString('Bishibosh', 'zh')).toBe('畢須博須')
|
|
expect(lookupMonsterTblString('Corpsefire', 'en')).toBe('Corpsefire')
|
|
expect(lookupMonsterTblString('Corpsefire', 'zh')).toBe('屍體發火')
|
|
|
|
// Base type & rank affixes
|
|
expect(lookupMonsterTblString('DemonID', 'en')).toBe('Demon')
|
|
expect(lookupMonsterTblString('DemonID', 'zh')).toBe('惡魔')
|
|
expect(lookupMonsterTblString('UndeadDescriptX', 'en')).toBe('Undead')
|
|
expect(lookupMonsterTblString('UndeadDescriptX', 'zh')).toBe('不死的')
|
|
expect(lookupMonsterTblString('Champion', 'en')).toBe('Champion')
|
|
expect(lookupMonsterTblString('Champion', 'zh')).toBe('頭目')
|
|
expect(lookupMonsterTblString('minion', 'en')).toBe('Minion')
|
|
expect(lookupMonsterTblString('minion', 'zh')).toBe('嘍囉')
|
|
|
|
// Elite modifiers (3205..3218)
|
|
expect(lookupMonsterTblString('uniquextrastrong', 'en')).toBe('Extra Strong')
|
|
expect(lookupMonsterTblString('uniquextrastrong', 'zh')).toBe('特別強壯')
|
|
expect(lookupMonsterTblString('uniqueextrafast', 'en')).toBe('Extra Fast')
|
|
expect(lookupMonsterTblString('uniqueextrafast', 'zh')).toBe('特別快速')
|
|
expect(lookupMonsterTblString('uniquecursed', 'en')).toBe('Cursed')
|
|
expect(lookupMonsterTblString('uniquecursed', 'zh')).toBe('特別詛咒')
|
|
expect(lookupMonsterTblString('monsteruniqueprop6', 'en')).toBe('Stone Skin')
|
|
expect(lookupMonsterTblString('monsteruniqueprop6', 'zh')).toBe('皮膚硬化')
|
|
|
|
// Immunity strings (10990..10995)
|
|
expect(lookupMonsterTblString('strScaryMonDesc1', 'en')).toBe('Immune to Fire')
|
|
expect(lookupMonsterTblString('strScaryMonDesc1', 'zh')).toBe('火焰系無效')
|
|
expect(lookupMonsterTblString('strScaryMonDesc2', 'en')).toBe('Immune to Cold')
|
|
expect(lookupMonsterTblString('strScaryMonDesc2', 'zh')).toBe('冰冷系無效')
|
|
expect(lookupMonsterTblString('strScaryMonDesc3', 'en')).toBe('Immune to Lightning')
|
|
expect(lookupMonsterTblString('strScaryMonDesc3', 'zh')).toBe('閃電系無效')
|
|
expect(lookupMonsterTblString('strScaryMonDesc4', 'en')).toBe('Immune to Poison')
|
|
expect(lookupMonsterTblString('strScaryMonDesc4', 'zh')).toBe('毒素系無效')
|
|
expect(lookupMonsterTblString('strScaryMonDesc5', 'en')).toBe('Immune to Physical')
|
|
expect(lookupMonsterTblString('strScaryMonDesc5', 'zh')).toBe('實體攻擊無效')
|
|
expect(lookupMonsterTblString('strScaryMonDesc6', 'en')).toBe('Immune to Magic')
|
|
expect(lookupMonsterTblString('strScaryMonDesc6', 'zh')).toBe('魔法系無效')
|
|
})
|
|
|
|
it('populates descKey, isUndead, isDemon, isBoss, killable, inTown, and resistances in readMonsterKinds and enrichMonsterPacks', () => {
|
|
const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'hell')
|
|
const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'hell')
|
|
const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s]))
|
|
|
|
const fallen = kinds.get('fallen1')!
|
|
const zombie = kinds.get('zombie1')!
|
|
const quillRat = kinds.get('quillrat1')!
|
|
const fallenShaman = kinds.get('fallenshaman1')!
|
|
const andariel = kinds.get('andariel')!
|
|
|
|
expect(fallen.isDemon).toBe(true)
|
|
expect(fallen.isUndead).toBe(false)
|
|
expect(fallen.resistances.fire).toBeGreaterThanOrEqual(100) // Hell Fallen is Fire Immune
|
|
|
|
expect(zombie.isUndead).toBe(true)
|
|
expect(zombie.isDemon).toBe(false)
|
|
|
|
expect(quillRat.isUndead).toBe(false)
|
|
expect(quillRat.isDemon).toBe(false)
|
|
|
|
expect(fallenShaman.descKey).toBe('raises Fallen')
|
|
expect(andariel.isBoss).toBe(true)
|
|
|
|
const stats = monsterStatsOf(fallen, new Rng(123), 4)
|
|
expect(stats.nameKey).toBe('Fallen')
|
|
expect(stats.isDemon).toBe(true)
|
|
expect(stats.isUndead).toBe(false)
|
|
expect(stats.resistances?.fire).toBeGreaterThanOrEqual(100)
|
|
|
|
// Verify enrichMonsterPacks enriches pre-baked pack stats with MonStats & SuperUniques metadata
|
|
const rawPacks = [
|
|
{
|
|
kindId: 'fallenshaman1',
|
|
rank: 'unique' as const,
|
|
superUniqueId: 'Bishibosh',
|
|
members: [
|
|
{
|
|
id: 'fallenshaman1',
|
|
name: 'Bishibosh',
|
|
rank: 'unique' as const,
|
|
hp: 120,
|
|
damage: 10,
|
|
cooldownTicks: 25,
|
|
reach: 24,
|
|
aggroRadius: 100,
|
|
speed: 4,
|
|
xp: 90,
|
|
},
|
|
],
|
|
},
|
|
]
|
|
const enriched = enrichMonsterPacks(rawPacks, kinds, superUniques)
|
|
expect(enriched[0]!.members[0]!.nameKey).toBe('Bishibosh')
|
|
expect(enriched[0]!.members[0]!.descKey).toBe('raises Fallen')
|
|
expect(enriched[0]!.members[0]!.isDemon).toBe(true)
|
|
expect(enriched[0]!.members[0]!.isUndead).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('R3: Authentic 1.13c Lifebar Plaque Layout, Title, and 2-Line Subtext', () => {
|
|
it('computes 1.13c panel-aware lifebar layout and [2, 128] HP fill clamping (0x6fb70850 & Ordinal_10070)', () => {
|
|
// Short name (" Fallen " = 10 chars * 8 = 80px -> clamped to min width 128)
|
|
const layoutClosed = computeMonsterLifebarLayout(800, false, false, 80, 100, 100)
|
|
expect(layoutClosed.visible).toBe(true)
|
|
expect(layoutClosed.centerX).toBe(400)
|
|
expect(layoutClosed.barWidth).toBe(128)
|
|
expect(layoutClosed.left).toBe(400 - 64)
|
|
expect(layoutClosed.y).toBe(32)
|
|
expect(layoutClosed.top).toBe(19)
|
|
expect(layoutClosed.bottom).toBe(36)
|
|
expect(layoutClosed.barHeight).toBe(17) // 16 + 1 inclusive
|
|
expect(layoutClosed.fillRatio128).toBe(128)
|
|
expect(layoutClosed.fillWidth).toBe(128)
|
|
|
|
// Long name (textWidth = 264px > 128)
|
|
const layoutLong = computeMonsterLifebarLayout(800, false, false, 264, 50, 100)
|
|
expect(layoutLong.barWidth).toBe(264)
|
|
expect(layoutLong.left).toBe(400 - 132)
|
|
expect(layoutLong.fillRatio128).toBe(64)
|
|
expect(layoutLong.fillWidth).toBe(132)
|
|
|
|
// Sliver of HP (1 / 10000) -> fillRatio128 clamped to minimum 2 per 0x6fb7092f
|
|
const layoutSliver = computeMonsterLifebarLayout(800, false, false, 80, 1, 10000)
|
|
expect(layoutSliver.fillRatio128).toBe(2)
|
|
expect(layoutSliver.fillWidth).toBe(2)
|
|
|
|
// Dead (0 / 100) -> visible = false, fillRatio128 = 0
|
|
const layoutDead = computeMonsterLifebarLayout(800, false, false, 80, 0, 100)
|
|
expect(layoutDead.visible).toBe(false)
|
|
expect(layoutDead.fillRatio128).toBe(0)
|
|
expect(layoutDead.fillWidth).toBe(0)
|
|
|
|
// Left panel open, right closed -> shifts right by +200 to centerX = 600
|
|
const layoutLeftOpen = computeMonsterLifebarLayout(800, true, false, 80, 100, 100)
|
|
expect(layoutLeftOpen.visible).toBe(true)
|
|
expect(layoutLeftOpen.centerX).toBe(600)
|
|
|
|
// Right panel open, left closed -> shifts left by -200 to centerX = 200
|
|
const layoutRightOpen = computeMonsterLifebarLayout(800, false, true, 80, 100, 100)
|
|
expect(layoutRightOpen.visible).toBe(true)
|
|
expect(layoutRightOpen.centerX).toBe(200)
|
|
|
|
// Both panels open -> hidden
|
|
const layoutBothOpen = computeMonsterLifebarLayout(800, true, true, 80, 100, 100)
|
|
expect(layoutBothOpen.visible).toBe(false)
|
|
})
|
|
|
|
it('resolves monster title and color across Normal, Champion, Unique, Minion, and Boss ranks in ENG and CHI', () => {
|
|
// Normal
|
|
const normalEn = resolveMonsterTitleAndColor(
|
|
{ name: 'Fallen', nameKey: 'Fallen', hp: 10, maxHp: 10, rank: 'normal' },
|
|
'en',
|
|
)
|
|
expect(normalEn).toEqual({
|
|
displayName: 'Fallen',
|
|
paddedTitle: ' Fallen ',
|
|
titleColor: 'white',
|
|
})
|
|
const normalZh = resolveMonsterTitleAndColor(
|
|
{ name: 'Fallen', nameKey: 'Fallen', hp: 10, maxHp: 10, rank: 'normal' },
|
|
'zh',
|
|
)
|
|
expect(normalZh).toEqual({
|
|
displayName: '沈淪魔',
|
|
paddedTitle: ' 沈淪魔 ',
|
|
titleColor: 'white',
|
|
})
|
|
|
|
// Champion
|
|
const champEn = resolveMonsterTitleAndColor(
|
|
{ name: 'Fallen', nameKey: 'Fallen', hp: 20, maxHp: 20, rank: 'champion' },
|
|
'en',
|
|
)
|
|
expect(champEn).toEqual({
|
|
displayName: 'Champion Fallen',
|
|
paddedTitle: ' Champion Fallen ',
|
|
titleColor: 'blue',
|
|
})
|
|
const champZh = resolveMonsterTitleAndColor(
|
|
{ name: 'Fallen', nameKey: 'Fallen', hp: 20, maxHp: 20, rank: 'champion' },
|
|
'zh',
|
|
)
|
|
expect(champZh).toEqual({
|
|
displayName: '頭目 沈淪魔',
|
|
paddedTitle: ' 頭目 沈淪魔 ',
|
|
titleColor: 'blue',
|
|
})
|
|
|
|
// SuperUnique / Unique
|
|
const uniqueEn = resolveMonsterTitleAndColor(
|
|
{ name: 'Bishibosh', nameKey: 'Bishibosh', hp: 100, maxHp: 100, rank: 'unique' },
|
|
'en',
|
|
)
|
|
expect(uniqueEn).toEqual({
|
|
displayName: 'Bishibosh',
|
|
paddedTitle: ' Bishibosh ',
|
|
titleColor: 'gold',
|
|
})
|
|
const uniqueZh = resolveMonsterTitleAndColor(
|
|
{ name: 'Bishibosh', nameKey: 'Bishibosh', hp: 100, maxHp: 100, rank: 'unique' },
|
|
'zh',
|
|
)
|
|
expect(uniqueZh).toEqual({
|
|
displayName: '畢須博須',
|
|
paddedTitle: ' 畢須博須 ',
|
|
titleColor: 'gold',
|
|
})
|
|
|
|
// Minion (title is white base name, subtext line 1 carries Minion / 僕人)
|
|
const minionEn = resolveMonsterTitleAndColor(
|
|
{ name: 'Fallen', nameKey: 'Fallen', hp: 15, maxHp: 15, rank: 'minion' },
|
|
'en',
|
|
)
|
|
expect(minionEn).toEqual({
|
|
displayName: 'Fallen',
|
|
paddedTitle: ' Fallen ',
|
|
titleColor: 'white',
|
|
})
|
|
|
|
// Act Boss
|
|
const bossZh = resolveMonsterTitleAndColor(
|
|
{ name: 'Andariel', nameKey: 'Andariel', hp: 500, maxHp: 500, rank: 'boss', isBoss: true },
|
|
'zh',
|
|
)
|
|
expect(bossZh).toEqual({
|
|
displayName: '安達利爾',
|
|
paddedTitle: ' 安達利爾 ',
|
|
titleColor: 'gold',
|
|
})
|
|
})
|
|
|
|
it('builds 2-line subtext (0x6facfdd0): Undead/Demon/Animal, DescStr, Unique Modifiers, and 6-element Immunities with line promotion', () => {
|
|
// 1. Undead Unique with DescStr ('raises Fallen'), 2 modifiers, and Cold + Poison immunities
|
|
const undeadUnique: MonsterTargetHudInfo = {
|
|
name: 'Corpsefire',
|
|
nameKey: 'Corpsefire',
|
|
descKey: 'raises Fallen',
|
|
hp: 100,
|
|
maxHp: 100,
|
|
rank: 'unique',
|
|
isUndead: true,
|
|
isDemon: false,
|
|
modifiers: ['strong', 'spectralhit'],
|
|
resistances: {
|
|
cold: 100,
|
|
fire: 50,
|
|
lightning: 0,
|
|
poison: 120,
|
|
physical: 99,
|
|
magic: 0,
|
|
},
|
|
}
|
|
const subEn = buildMonsterHoverSubtext(undeadUnique, 'en')
|
|
expect(subEn.line1).toBe('Undead Extra Strong Spectral Hit Raises Fallen')
|
|
expect(subEn.line1Y).toBe(55)
|
|
expect(subEn.line2).toBe('Immune to Cold, Immune to Poison')
|
|
expect(subEn.line2Y).toBe(69)
|
|
|
|
const subZh = buildMonsterHoverSubtext(undeadUnique, 'zh')
|
|
expect(subZh.line1).toBe('不死的 特別強壯 幽靈一擊 復甦的沉淪魔')
|
|
expect(subZh.line2).toBe('冰冷系無效, 毒素系無效')
|
|
|
|
// 2. Animal/Beast (neither Undead nor Demon, no DescStr, normal rank) -> empty subtext when no immunities
|
|
const quillRat: MonsterTargetHudInfo = {
|
|
name: 'Quill Rat',
|
|
nameKey: 'QuillRat',
|
|
descKey: '',
|
|
hp: 10,
|
|
maxHp: 10,
|
|
rank: 'normal',
|
|
isUndead: false,
|
|
isDemon: false,
|
|
modifiers: [],
|
|
resistances: { cold: 0, fire: 0, lightning: 0, poison: 0, physical: 0, magic: 0 },
|
|
}
|
|
const emptySub = buildMonsterHoverSubtext(quillRat, 'en')
|
|
expect(emptySub.line1).toBe('')
|
|
expect(emptySub.line2).toBe('')
|
|
|
|
// 3. Animal/Beast with Physical + Magic immunity: Line 1 is empty, so Line 2 renders at y = 55 per 0x6fb70850!
|
|
const immuneBeast: MonsterTargetHudInfo = {
|
|
...quillRat,
|
|
resistances: { cold: 0, fire: 0, lightning: 0, poison: 0, physical: 100, magic: 100 },
|
|
}
|
|
const promotedEn = buildMonsterHoverSubtext(immuneBeast, 'en')
|
|
expect(promotedEn.line1).toBe('')
|
|
expect(promotedEn.line2).toBe('Immune to Physical, Immune to Magic')
|
|
expect(promotedEn.line2Y).toBe(55)
|
|
|
|
// 4. Verify all 6 immunity descriptors in canonical 1.13c .rdata:0x6FB834EC order
|
|
expect(CANONICAL_IMMUNITY_DESCRIPTORS.map(d => d.key)).toEqual([
|
|
'physical',
|
|
'magic',
|
|
'fire',
|
|
'lightning',
|
|
'cold',
|
|
'poison',
|
|
])
|
|
})
|
|
|
|
it('renders plaque backdrop at top=19 (h=17), title at y=32, line 1 at y=55, and line 2 at y=69 in WorldPanelsHud.drawMonsterTargetBar', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const font = createMockFont(8)
|
|
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
|
|
|
|
const ctx = {
|
|
fillStyle: '',
|
|
fillRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) {
|
|
fillRectCalls.push({ x, y, w, h, fillStyle: this.fillStyle })
|
|
}),
|
|
} as unknown as CanvasRenderingContext2D
|
|
|
|
const target: MonsterTargetHudInfo = {
|
|
name: 'Bishibosh',
|
|
nameKey: 'Bishibosh',
|
|
descKey: 'FallenShamanDesc',
|
|
hp: 50,
|
|
maxHp: 100,
|
|
rank: 'unique',
|
|
isDemon: true,
|
|
isUndead: false,
|
|
modifiers: ['fireenchant', 'magicresistant'],
|
|
resistances: { fire: 100, cold: 0, lightning: 0, poison: 0, physical: 0, magic: 75 },
|
|
}
|
|
|
|
hud.drawMonsterTargetBar(ctx, target, font, {
|
|
leftPanelOpen: false,
|
|
rightPanelOpen: false,
|
|
canvasWidth: 800,
|
|
locale: 'en',
|
|
})
|
|
|
|
// Two fillRect calls: dark backdrop (w=128, h=17 at top=19) and crimson HP fill (w=64, h=17 at top=19)
|
|
expect(fillRectCalls).toHaveLength(2)
|
|
expect(fillRectCalls[0]).toEqual({
|
|
x: 400 - 64,
|
|
y: 19,
|
|
w: 128,
|
|
h: 17,
|
|
fillStyle: 'rgba(0, 0, 0, 0.72)',
|
|
})
|
|
expect(fillRectCalls[1]).toEqual({
|
|
x: 400 - 64,
|
|
y: 19,
|
|
w: 64,
|
|
h: 17,
|
|
fillStyle: 'rgba(156, 20, 20, 0.88)',
|
|
})
|
|
|
|
// Three drawText calls: title at y=32, line 1 at y=55, line 2 at y=69
|
|
expect(font.calls).toHaveLength(3)
|
|
expect(font.calls[0]!.y).toBe(32)
|
|
expect(font.calls[0]!.opts?.color).toBe('gold')
|
|
expect(font.calls[1]!.y).toBe(55)
|
|
expect(font.calls[1]!.opts?.font).toBe('font6')
|
|
expect(font.calls[2]!.y).toBe(69)
|
|
expect(font.calls[2]!.opts?.font).toBe('font6')
|
|
})
|
|
})
|
|
|
|
describe('R2: Authentic Mouse Hover Hit-Testing & Target Clearing in SceneMouseController', () => {
|
|
it('does NOT auto-populate targetMonster by proximity when cursor is not hovering over a monster', () => {
|
|
const { engine, hudManager, controller } = createHoverTestScene()
|
|
|
|
// Monster 0 is only 60px away from player (560, 500 vs 500, 500)
|
|
expect(
|
|
Math.hypot(
|
|
engine.world.monsters[0]!.x - engine.world.player.x,
|
|
engine.world.monsters[0]!.y - engine.world.player.y,
|
|
),
|
|
).toBe(60)
|
|
|
|
// Sync HUD without hovering any monster -> targetMonster MUST be null!
|
|
controller.syncMonsterTargetHud(engine.world.player.x, engine.world.player.y - 16)
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
})
|
|
|
|
it('populates targetMonster on cursor hover over monster drawBounds and clears on move to empty ground, HUD, or pointerleave', () => {
|
|
const { canvas, engine, hudManager, controller } = createHoverTestScene()
|
|
const camX = engine.world.player.x // 500
|
|
const camY = engine.world.player.y - 16 // 484
|
|
|
|
// Monster 0 drawBounds: x=[544, 576], y=[450, 504]
|
|
// World (560, 480) -> screen (400 + (560 - 500) = 460, 300 + (480 - 484) = 296)
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
|
|
expect(controller.hoveredMonster).toBe(engine.world.monsters[0])
|
|
expect(hudManager.targetMonster).not.toBeNull()
|
|
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
|
|
expect(hudManager.targetMonster?.isDemon).toBe(true)
|
|
expect(hudManager.targetMonster?.hp).toBe(20)
|
|
|
|
// Move cursor to empty ground at World (450, 450) -> screen (350, 266)
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 350, clientY: 266, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
|
|
// Hover Monster 1 at World (650, 480) -> screen (550, 296)
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 550, clientY: 296, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(controller.hoveredMonster).toBe(engine.world.monsters[1])
|
|
expect(hudManager.targetMonster?.nameKey).toBe('Zombie')
|
|
expect(hudManager.targetMonster?.isUndead).toBe(true)
|
|
expect(hudManager.targetMonster?.modifiers).toEqual(['strong', 'cursed'])
|
|
expect(hudManager.targetMonster?.resistances?.cold).toBe(100)
|
|
|
|
// Move cursor onto bottom HUD control bar (y = 580) -> clears hover
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 400, clientY: 580, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
|
|
// Hover Monster 0 again, then trigger pointerleave -> clears hover
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(hudManager.targetMonster).not.toBeNull()
|
|
|
|
canvas.dispatchEvent(new SyntheticPointerEvent('pointerleave', { clientX: 0, clientY: 0, buttons: 0 }))
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
})
|
|
|
|
it('selects foreground monster by isometric depth (higher y) when two monsters overlap and locks target during left-click hold', () => {
|
|
const { canvas, engine, hudManager, controller } = createHoverTestScene()
|
|
const camX = engine.world.player.x // 500
|
|
const camY = engine.world.player.y - 16 // 484
|
|
|
|
// Overlap Monster 0 (y = 490, background) and Monster 1 (y = 505, foreground) at world (560, 480)
|
|
engine.world.monsters[0]!.x = 560
|
|
engine.world.monsters[0]!.y = 490
|
|
engine.world.monsters[0]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 }
|
|
|
|
engine.world.monsters[1]!.x = 560
|
|
engine.world.monsters[1]!.y = 505
|
|
engine.world.monsters[1]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 }
|
|
|
|
const hit = controller.findMonsterAtWorld(560, 480)
|
|
expect(hit).toBe(engine.world.monsters[1]) // Foreground monster (higher y) wins
|
|
|
|
// Left-click hold on Monster 1 at screen (460, 296) -> locks onto Monster 1
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointerdown', { clientX: 460, clientY: 296, button: 0, buttons: 1 }),
|
|
)
|
|
expect(controller.lockedMonster).toBe(engine.world.monsters[1])
|
|
|
|
// Drag cursor away to empty ground while holding left button -> stays locked on Monster 1
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 300, clientY: 200, button: 0, buttons: 1 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(controller.getActiveTargetMonster()).toBe(engine.world.monsters[1])
|
|
expect(hudManager.targetMonster?.nameKey).toBe('Zombie')
|
|
|
|
// Release left mouse button over empty ground -> clears lock and clears targetMonster
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointerup', { clientX: 300, clientY: 200, button: 0, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(controller.lockedMonster).toBeNull()
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
})
|
|
|
|
it('clears targetMonster immediately when hovered monster dies or becomes unkillable', () => {
|
|
const { canvas, engine, hudManager, controller } = createHoverTestScene()
|
|
const camX = engine.world.player.x
|
|
const camY = engine.world.player.y - 16
|
|
|
|
// Hover Monster 0
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
|
|
)
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
|
|
|
|
// Kill Monster 0
|
|
engine.world.monsters[0]!.hp = 0
|
|
engine.world.monsters[0]!.state = 'dead'
|
|
controller.syncMonsterTargetHud(camX, camY)
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
|
|
// Verify monsterToTargetHudInfo maps all fields cleanly
|
|
const hudInfo = monsterToTargetHudInfo(engine.world.monsters[1]!)
|
|
expect(hudInfo).toMatchObject({
|
|
name: 'Zombie',
|
|
nameKey: 'Zombie',
|
|
isUndead: true,
|
|
isDemon: false,
|
|
rank: 'unique',
|
|
modifiers: ['strong', 'cursed'],
|
|
})
|
|
})
|
|
|
|
it('filters "dummy" DescStr (0x6fb70c0b) and resolves wMonUModStrTable[24] ("thief") plus short/numeric MonUMod keys', () => {
|
|
const dummyMonster: MonsterTargetHudInfo = {
|
|
name: 'Skeleton',
|
|
nameKey: 'Skeleton',
|
|
descKey: 'dummy',
|
|
subType: 'Dummy',
|
|
hp: 30,
|
|
maxHp: 30,
|
|
rank: 'normal',
|
|
isUndead: true,
|
|
isDemon: false,
|
|
}
|
|
const dummySub = buildMonsterHoverSubtext(dummyMonster, 'en')
|
|
expect(dummySub.line1).toBe('Undead')
|
|
expect(dummySub.line1Colored).toBe('Undead')
|
|
|
|
const thiefUnique: MonsterTargetHudInfo = {
|
|
name: 'Bone Ash',
|
|
nameKey: 'Bone Ash',
|
|
descKey: 'dummy',
|
|
hp: 150,
|
|
maxHp: 150,
|
|
rank: 'unique',
|
|
isUndead: true,
|
|
modifiers: ['thief', 'curse', 'resist', '18'],
|
|
}
|
|
const thiefEn = buildMonsterHoverSubtext(thiefUnique, 'en')
|
|
expect(thiefEn.line1).toBe('Undead Thief Cursed Magic Resistant Cold Enchanted')
|
|
const thiefZh = buildMonsterHoverSubtext(thiefUnique, 'zh')
|
|
expect(thiefZh.line1).toBe('不死的 小偷 特別詛咒 魔法抵抗 冰冷強化')
|
|
})
|
|
|
|
it('wraps multi-immunity Line 2 and clamps long modifier Line 1 within the open half-viewport when a side panel is open', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const font = createMockFont(8)
|
|
const ctx = {
|
|
fillStyle: '',
|
|
fillRect: vi.fn(),
|
|
} as unknown as CanvasRenderingContext2D
|
|
|
|
const sixImmuneBoss: MonsterTargetHudInfo = {
|
|
name: 'Uber Boss',
|
|
nameKey: 'Diablo',
|
|
hp: 500,
|
|
maxHp: 500,
|
|
rank: 'unique',
|
|
isDemon: true,
|
|
modifiers: ['strong', 'fast', 'cursed', 'magicresistant', 'fireenchant', 'lightenchant', 'coldenchant', 'stoneskin'],
|
|
resistances: {
|
|
physical: 100,
|
|
magic: 100,
|
|
fire: 100,
|
|
lightning: 100,
|
|
cold: 100,
|
|
poison: 100,
|
|
},
|
|
}
|
|
|
|
hud.drawMonsterTargetBar(ctx, sixImmuneBoss, font, {
|
|
leftPanelOpen: true,
|
|
rightPanelOpen: false,
|
|
canvasWidth: 800,
|
|
locale: 'en',
|
|
})
|
|
|
|
// Active viewport is [400, 800] (width 400, maxSubtextWidth = 392)
|
|
// Title at y=32, clamped Line 1 at y=55, wrapped Line 2 across y=69, 83, 97
|
|
const line1Call = font.calls.find(c => c.y === 55)
|
|
expect(line1Call).toBeDefined()
|
|
const plainLine1 = line1Call!.text.replace(/ÿc[0-9a-z]/g, '')
|
|
expect(font.measureText(plainLine1, 'font6')).toBeLessThanOrEqual(392)
|
|
|
|
const immunityCalls = font.calls.filter(c => c.y >= 69)
|
|
expect(immunityCalls.length).toBeGreaterThanOrEqual(2)
|
|
expect(immunityCalls[0]!.y).toBe(69)
|
|
expect(immunityCalls[1]!.y).toBe(83)
|
|
for (const call of immunityCalls) {
|
|
const plain = call.text.replace(/ÿc[0-9a-z]/g, '')
|
|
expect(font.measureText(plain, 'font6')).toBeLessThanOrEqual(392)
|
|
}
|
|
})
|
|
|
|
it('preserves boss and superunique ranks in monsterToTargetHudInfo and uses getMonsterArtMap or boss fallback bounds before first render', () => {
|
|
const { engine, canvas, camera, hudManager, controller } = createHoverTestScene()
|
|
|
|
// Boss monster without drawBounds (before first render)
|
|
const bossMonster = engine.world.monsters[0]!
|
|
bossMonster.drawBounds = undefined
|
|
;(bossMonster as any).stats = {
|
|
...bossMonster.stats,
|
|
id: 'andariel',
|
|
name: 'Andariel',
|
|
nameKey: 'Andariel',
|
|
rank: 'boss',
|
|
isBoss: true,
|
|
isDemon: true,
|
|
}
|
|
|
|
const bossHud = monsterToTargetHudInfo(bossMonster)
|
|
expect(bossHud.rank).toBe('boss')
|
|
expect(bossHud.isBoss).toBe(true)
|
|
|
|
// 1. Without monsterArtMap, boss fallback bounds extend up to y - 96 (normal only extends to y - 64)
|
|
const controllerNoArt = new SceneMouseController({
|
|
canvas,
|
|
engine,
|
|
camera,
|
|
input: controller.input,
|
|
getRuntime: controller.getRuntime,
|
|
hudManager,
|
|
waypointNetwork: { activate: () => false } as any,
|
|
status: { textContent: '', hidden: false } as unknown as HTMLElement,
|
|
})
|
|
expect(controllerNoArt.findMonsterAtWorld(bossMonster.x, bossMonster.y - 85)).toBe(bossMonster)
|
|
|
|
// 2. With getMonsterArtMap, pre-render hit-testing uses frame 0 dimensions from monsterArtMap
|
|
const fakeArtMap = new Map([
|
|
[
|
|
'andariel',
|
|
{
|
|
token: 'AN',
|
|
handle: {} as any,
|
|
groups: [[{ x: 0, y: 0, width: 120, height: 150, anchorX: -60, anchorY: -140 }]],
|
|
directions: 8,
|
|
walkOffset: 0,
|
|
standOffset: 0,
|
|
walkFrames: 1,
|
|
standFrames: 1,
|
|
layerFailures: [],
|
|
},
|
|
],
|
|
])
|
|
const controllerWithArt = new SceneMouseController({
|
|
canvas,
|
|
engine,
|
|
camera,
|
|
input: controller.input,
|
|
getRuntime: controller.getRuntime,
|
|
hudManager,
|
|
waypointNetwork: { activate: () => false } as any,
|
|
status: { textContent: '', hidden: false } as unknown as HTMLElement,
|
|
getMonsterArtMap: () => fakeArtMap as any,
|
|
})
|
|
expect(controllerWithArt.findMonsterAtWorld(bossMonster.x - 55, bossMonster.y - 130)).toBe(bossMonster)
|
|
})
|
|
|
|
it('keeps attacking lockedMonster in tick() when cursor moves away during left-click hold and updates hudManager.targetMonster HP immediately after castSkill', () => {
|
|
const { canvas, engine, hudManager, controller } = createHoverTestScene()
|
|
const monster0 = engine.world.monsters[0]!
|
|
// Place Monster 0 within melee reach (distance 30 <= reach)
|
|
monster0.x = 530
|
|
monster0.y = 500
|
|
monster0.drawBounds = { x: 510, y: 450, w: 40, h: 60 }
|
|
|
|
const castSpy = vi.spyOn(controller, 'castSkill').mockImplementation((_skillId, tx, ty) => {
|
|
if (tx === monster0.x && ty === monster0.y) {
|
|
monster0.hp = Math.max(0, monster0.hp - 7)
|
|
if (monster0.hp === 0) monster0.state = 'dead'
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Left-click Monster 0 at screen (430, 296) -> world (530, 480)
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointerdown', { clientX: 430, clientY: 296, button: 0, buttons: 1 }),
|
|
)
|
|
expect(controller.lockedMonster).toBe(monster0)
|
|
// HP on HUD must immediately reflect post-hit HP (20 - 7 = 13), not stale 20!
|
|
expect(hudManager.targetMonster?.hp).toBe(13)
|
|
|
|
// Drag cursor far away to screen (100, 100) while holding left button
|
|
canvas.dispatchEvent(
|
|
new SyntheticPointerEvent('pointermove', { clientX: 100, clientY: 100, button: 0, buttons: 1 }),
|
|
)
|
|
engine.world.player.cooldown = 0
|
|
controller.tick()
|
|
|
|
// tick() must attack lockedMonster at (530, 500), NOT cursor at (200, 184)!
|
|
expect(castSpy).toHaveBeenLastCalledWith(expect.any(Number), 530, 500)
|
|
expect(hudManager.targetMonster?.hp).toBe(6)
|
|
|
|
// Third swing kills Monster 0 -> lockedMonster and hudManager.targetMonster clear immediately
|
|
engine.world.player.cooldown = 0
|
|
controller.tick()
|
|
expect(monster0.hp).toBe(0)
|
|
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)
|
|
})
|
|
|
|
it('applies 1.13c MonUMod elemental/physical/magic resistance bonuses in applyEliteModifiers and enrichMonsterPacks so Bishibosh and Frozenstein display immunities on Line 2 (D2Game.dll 0x6fc41be0)', () => {
|
|
const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'normal')
|
|
const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'normal')
|
|
const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s]))
|
|
|
|
// 1. Pre-baked Cold Plains Bishibosh (fallenshaman1: base fire=25 + magicresistant(+40) + fireenchant(+75) = 140 >= 100)
|
|
const bishiboshPack = enrichMonsterPacks(
|
|
[
|
|
{
|
|
superUniqueId: 'Bishibosh',
|
|
members: [
|
|
{
|
|
id: 'fallenshaman1',
|
|
name: 'Bishibosh',
|
|
hp: 43,
|
|
damage: 2,
|
|
cooldownTicks: 25,
|
|
reach: 40,
|
|
aggroRadius: 560,
|
|
speed: 113,
|
|
xp: 32,
|
|
level: 5,
|
|
rank: 'unique',
|
|
resistances: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 },
|
|
resists: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 },
|
|
modifiers: ['magicresistant', 'fireenchant'],
|
|
superUniqueId: 'Bishibosh',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
kinds,
|
|
superUniques,
|
|
)
|
|
const bishiboshStats = bishiboshPack[0]!.members[0]!
|
|
expect(bishiboshStats.resistances?.fire).toBe(140)
|
|
expect(bishiboshStats.resistances?.cold).toBe(40)
|
|
expect(bishiboshStats.resistances?.lightning).toBe(20)
|
|
|
|
const bishiboshHud = monsterToTargetHudInfo({
|
|
kind: 'fallenshaman1',
|
|
x: 100,
|
|
y: 100,
|
|
hp: 43,
|
|
maxHp: 43,
|
|
state: 'idle',
|
|
stats: bishiboshStats,
|
|
} as any)
|
|
const bishiboshSubEn = buildMonsterHoverSubtext(bishiboshHud, 'en')
|
|
expect(bishiboshSubEn.line1).toBe('Demon Magic Resistant Fire Enchanted Raises Fallen')
|
|
expect(bishiboshSubEn.line2).toBe('Immune to Fire')
|
|
expect(bishiboshSubEn.line2Colored).toBe('ÿc1Immune to Fire')
|
|
|
|
const bishiboshSubZh = buildMonsterHoverSubtext(bishiboshHud, 'zh')
|
|
expect(bishiboshSubZh.line2).toBe(lookupMonsterTblString('strScaryMonDesc1', 'zh'))
|
|
expect(bishiboshSubZh.line2Colored).toBe(`ÿc1${lookupMonsterTblString('strScaryMonDesc1', 'zh')}`)
|
|
|
|
// 2. Pre-baked Frozenstein (snowyeti4: base cold=75 + coldenchant(+75) = 150 >= 100, manahit(+20 magic))
|
|
const frozensteinPack = enrichMonsterPacks(
|
|
[
|
|
{
|
|
superUniqueId: 'Frozenstein',
|
|
members: [
|
|
{
|
|
id: 'snowyeti4',
|
|
name: 'Frozenstein',
|
|
hp: 300,
|
|
damage: 25,
|
|
cooldownTicks: 25,
|
|
reach: 40,
|
|
aggroRadius: 560,
|
|
speed: 120,
|
|
xp: 200,
|
|
level: 40,
|
|
rank: 'unique',
|
|
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 75, poison: 0 },
|
|
modifiers: ['coldenchant', 'manahit'],
|
|
superUniqueId: 'Frozenstein',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
kinds,
|
|
superUniques,
|
|
)
|
|
const frozensteinStats = frozensteinPack[0]!.members[0]!
|
|
expect(frozensteinStats.resistances?.cold).toBe(150)
|
|
expect(frozensteinStats.resistances?.magic).toBe(20)
|
|
|
|
// 3. applyEliteModifiers directly for stoneskin (+50 physical), spectralhit (+20 fire/cold/ltng), poisonenchant (+75 poison)
|
|
const baseStats = monsterStatsOf(kinds.get('fallen1')!, new Rng(1), 4)
|
|
const modded = applyEliteModifiers(baseStats, 'unique', ['stoneskin', 'spectralhit', 'poisonenchant'])
|
|
expect(modded.resistances?.physical).toBe(50)
|
|
expect(modded.resistances?.fire).toBe(20)
|
|
expect(modded.resistances?.cold).toBe(20)
|
|
expect(modded.resistances?.lightning).toBe(20)
|
|
expect(modded.resistances?.poison).toBe(75)
|
|
})
|
|
|
|
it('isolates hoveredMonster from 36px click-assist fallback in handleRightClickWorld and handleLeftClickWorld', () => {
|
|
const { canvas, engine, hudManager, controller } = createHoverTestScene()
|
|
const monster0 = engine.world.monsters[0]!
|
|
// Monster 0 is at (560, 500) with drawBounds [544..576, 450..504]
|
|
// World point (535, 500) is OUTSIDE drawBounds (x=535 < 544) but WITHIN 36px fallback (dist=25 <= 36)
|
|
expect(controller.findMonsterAtWorld(535, 500)).toBeNull()
|
|
|
|
// 1. Right-click at (535, 500) (outside drawBounds, inside 36px fallback) must NOT stick hoveredMonster or targetMonster
|
|
controller.handleRightClickWorld(535, 500)
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 435, clientY: 316, button: 2, buttons: 0 }))
|
|
expect(controller.activeClickType).toBe('none')
|
|
|
|
// 2. Left-click at (535, 500) (outside drawBounds, inside 36px fallback) locks monster while held, then clears on release
|
|
controller.isLeftMouseDown = true
|
|
controller.handleLeftClickWorld(535, 500)
|
|
expect(controller.lockedMonster).toBe(monster0)
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
|
|
|
|
canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 435, clientY: 316, button: 0, buttons: 0 }))
|
|
expect(controller.lockedMonster).toBeNull()
|
|
expect(controller.hoveredMonster).toBeNull()
|
|
expect(hudManager.targetMonster).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|