diablo2-web/tests/client/hud-portrait-roster-pets.te...

322 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { decode0x7ASummonAction } from '../../src/netproto/d2gs/s2c/merc.ts'
import { decode0x7FPartyMemberState } from '../../src/netproto/d2gs/s2c/party.ts'
import {
CANONICAL_PORTRAIT_DC6_NAMES,
resolveMercPortraitIcon,
resolvePlayerClassPortraitIcon,
resolvePetPortraitInfo,
} from '../../src/common/units/pet-type-table.ts'
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
function createMockContext() {
const drawnTexts: Array<{ text: string; x: number; y: number }> = []
const drawnRects: Array<{ x: number; y: number; w: number; h: number }> = []
const drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }> = []
const ctx = {
canvas: { width: 800, height: 600 },
save: () => {},
restore: () => {},
scale: () => {},
translate: () => {},
clearRect: () => {},
beginPath: () => {},
closePath: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},
fill: () => {},
fillRect: (x: number, y: number, w: number, h: number) => {
drawnRects.push({ x, y, w, h })
},
strokeRect: (x: number, y: number, w: number, h: number) => {
drawnRects.push({ x, y, w, h })
},
fillText: (text: string, x: number, y: number) => {
drawnTexts.push({ text: String(text), x, y })
},
measureText: (text: string) => ({ width: String(text).length * 6 }),
drawImage: (_img: any, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number) => {
drawnImages.push({ sx, sy, sw, sh, dx, dy, dw, dh })
},
imageSmoothingEnabled: false,
fillStyle: '#000000',
strokeStyle: '#000000',
font: '10px sans-serif',
textBaseline: 'alphabetic',
drawnTexts,
drawnRects,
drawnImages,
} as unknown as CanvasRenderingContext2D & {
drawnTexts: Array<{ text: string; x: number; y: number }>
drawnRects: Array<{ x: number; y: number; w: number; h: number }>
drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }>
}
return ctx
}
describe('Top-Left HUD Portraits & 1.13c RosterPets Parity (Hireables.cpp)', () => {
it('P1: BAKED_UI_MANIFEST bundles all 29 authentic 1.13c DC6 portrait frames with valid non-zero dimensions', () => {
expect(BAKED_UI_MANIFEST.portraitRects).toBeDefined()
const rects = BAKED_UI_MANIFEST.portraitRects!
for (const name of CANONICAL_PORTRAIT_DC6_NAMES) {
const rect = rects[name] ?? rects[name.toLowerCase()]
expect(rect, `Expected portrait rect for ${name}`).toBeDefined()
expect(rect.w).toBeGreaterThanOrEqual(44)
expect(rect.h).toBeGreaterThanOrEqual(40)
}
})
it('P2: solo unpartied player never renders a phantom "Player #1" portrait or box in top-left HUD', () => {
const world = new ClientWorld()
world.selfId = 1
const mockCtx = createMockContext()
const hud = new HudManager()
hud.ctx = mockCtx
const model = new HudModel({ world, hudManager: hud })
// Player joins solo: 0x5B PlayerInGame sends selfId with partyId 0xffff
world.apply({
type: 'Party',
kind: 'roster',
unitId: 1,
playerId: 1,
charClass: 0,
charLevel: 20,
partyId: 0xffff,
charName: 'AmazonHero',
})
// 0x8B PlayerPartyRelation sends state 0 (unpartied)
world.apply({
type: 'Party',
kind: 'relation',
unitId: 1,
relation: 0,
})
model.syncFromWorld()
expect(hud.partyMembers).toHaveLength(0)
hud.render()
expect(mockCtx.drawnTexts.some((t) => t.text.includes('Player #1'))).toBe(false)
expect(mockCtx.drawnTexts.some((t) => t.text.includes('AmazonHero'))).toBe(false)
expect(mockCtx.drawnRects.filter((r) => r.y <= 64)).toHaveLength(0)
})
it('P3: 0x7F PartyMemberState with isPlayer === 0 updates pet/hireling life without creating phantom party member', () => {
const world = new ClientWorld()
world.selfId = 1
const mockCtx = createMockContext()
const hud = new HudManager()
hud.ctx = mockCtx
const model = new HudModel({ world, hudManager: hud })
// Assign hireling
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 338, // Act 2 Desert Mercenary
})
expect(world.merc).not.toBeNull()
// 0x7F packet: [0x7f, isPlayer=0, lifePct=96 (u16), unitId=88 (u32), areaId=1 (u16)]
const pkt7f = new Uint8Array([0x7f, 0, 96, 0, 88, 0, 0, 0, 1, 0])
const ev7f = decode0x7FPartyMemberState(pkt7f)
expect(ev7f.type).toBe('Party')
expect((ev7f as any).isPlayer).toBe(false)
world.apply(ev7f)
expect(world.merc!.lifePct).toBe(96)
expect(world.party.has(88)).toBe(false)
model.syncFromWorld()
expect(hud.partyMembers).toHaveLength(0)
expect((hud as any).merc.lifePct).toBe(96)
})
it('P4: renders authentic mercenary portrait (act2hireableicon) without duplicate overlapping fillText', () => {
const world = new ClientWorld()
world.selfId = 1
const mockCtx = createMockContext()
const hud = new HudManager()
hud.ctx = mockCtx
const model = new HudModel({ world, hudManager: hud })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 99,
classId: 338, // Desert Mercenary
nameId: 14,
})
model.syncFromWorld()
expect((hud as any).merc.iconKey).toBe('act2hireableicon')
hud.render()
// The portrait frame and health bar are drawn
expect(mockCtx.drawnRects.some((r) => r.x <= 24 && r.y <= 24 && r.w >= 32)).toBe(true)
// In headless unit tests where DC6 font atlases are unloaded, D2FontRenderer.drawText
// falls back to ctx.fillText with 3 drop shadow passes + 1 foreground pass (4 passes).
// The previous buggy code called an extra manual ctx.fillText beforehand (5 passes with stacked browser text).
const mercTexts = mockCtx.drawnTexts.filter((t) => t.text.includes('Merc'))
expect(mercTexts).toHaveLength(4)
})
it('P5: 0x7A SummonAction maintains active pets in world.pets without clobbering world.merc', () => {
const world = new ClientWorld()
world.selfId = 1
const mockCtx = createMockContext()
const hud = new HudManager()
hud.ctx = mockCtx
const model = new HudModel({ world, hudManager: hud })
// Player has an active mercenary
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 55,
classId: 271, // Rogue
nameId: 3,
})
expect(world.merc).not.toBeNull()
// 0x7A SummonAction: [0x7a, action=1, petType=4 (skeleton), classId=363 (necroskeleton), ownerId=1, mercId=101]
const pkt7a = new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 101, 0, 0, 0])
const ev7a = decode0x7ASummonAction(pkt7a)
expect((ev7a as any).isSummonAction).toBe(true)
expect((ev7a as any).petType).toBe(4)
world.apply(ev7a)
// Mercenary remains completely intact!
expect(world.merc).not.toBeNull()
expect(world.merc!.mercId).toBe(55)
// Pet is tracked in world.pets
expect(world.pets.has(101)).toBe(true)
const pet = world.pets.get(101)!
expect(pet.petType).toBe(4)
expect(pet.classId).toBe(363)
expect(pet.ownerId).toBe(1)
// Add 2 more skeletons
world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0])))
world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 103, 0, 0, 0])))
expect(world.pets.size).toBe(4) // 3 skeletons + 1 merc mirrored
model.syncFromWorld()
const summons = (hud as any).summonPortraits
expect(summons).toHaveLength(1)
expect(summons[0].iconKey).toBe('skeletonicon')
expect(summons[0].count).toBe(3)
expect(summons[0].iconType).toBe(2)
// Render frame and verify count badge "3" is drawn in top-left
hud.render()
expect(mockCtx.drawnTexts.some((t) => t.text === '3')).toBe(true)
// Unsummon skeleton 102 (action=0)
world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 0, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0])))
expect(world.pets.has(102)).toBe(false)
model.syncFromWorld()
expect((hud as any).summonPortraits[0].count).toBe(2)
})
it('P6: icontype=3 sub-type summons (Golems, Spirits, Vines) resolve exact sub-icon via classId', () => {
const clay = resolvePetPortraitInfo(3, 289)
expect(clay?.iconKey).toBe('earthgolumicon')
const blood = resolvePetPortraitInfo(3, 290)
expect(blood?.iconKey).toBe('bloodgolumicon')
const iron = resolvePetPortraitInfo(3, 291)
expect(iron?.iconKey).toBe('metalgolumicon')
const fire = resolvePetPortraitInfo(3, 292)
expect(fire?.iconKey).toBe('firegolumicon')
const oak = resolvePetPortraitInfo(13, 424)
expect(oak?.iconKey).toBe('oaksage')
const wolverine = resolvePetPortraitInfo(13, 423)
expect(wolverine?.iconKey).toBe('heartofwolverine')
const poison = resolvePetPortraitInfo(14, 425)
expect(poison?.iconKey).toBe('plaguepoppy')
})
it('P7: allied party members render their authentic class portraits on the top-left HUD', () => {
const world = new ClientWorld()
world.selfId = 1
const mockCtx = createMockContext()
const hud = new HudManager()
hud.ctx = mockCtx
const model = new HudModel({ world, hudManager: hud })
// Local player
world.apply({
type: 'Party',
kind: 'roster',
unitId: 1,
charClass: 0, // Amazon
charName: 'SelfAmazon',
partyId: 1,
})
// Allied Paladin in same party
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charClass: 3, // Paladin
charName: 'PaladinBro',
partyId: 1,
lifePct: 90,
})
world.apply({
type: 'Party',
kind: 'relation',
unitId: 2,
relation: 1, // in party
})
// Unpartied Sorceress in different party / unpartied
world.apply({
type: 'Party',
kind: 'roster',
unitId: 3,
charClass: 1, // Sorceress
charName: 'SoloSorc',
partyId: 0xffff,
})
world.apply({
type: 'Party',
kind: 'relation',
unitId: 3,
relation: 0, // none
})
model.syncFromWorld()
// Party list in hud model excludes selfId
expect(hud.partyMembers).toHaveLength(2)
const paladinEntry = hud.partyMembers.find((m) => m.unitId === 2)
expect(paladinEntry?.portraitIcon).toBe('PaladinIcon')
hud.render()
// Allied Paladin is drawn
expect(mockCtx.drawnTexts.some((t) => t.text.includes('PaladinBro'))).toBe(true)
// Unpartied Sorceress is NOT drawn on the top-left HUD
expect(mockCtx.drawnTexts.some((t) => t.text.includes('SoloSorc'))).toBe(false)
// Self is NOT drawn on the top-left HUD
expect(mockCtx.drawnTexts.some((t) => t.text.includes('SelfAmazon'))).toBe(false)
})
})