322 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|