diablo2-web/tests/client/hud-server-authority.test.ts

155 lines
6.6 KiB
TypeScript

/**
* Regression tests for the client hard-coded/demo value audit (server-authoritative HUD).
*
* Covers: neutral level-1 self, class id -> tree/quickbar mapping for all 7 classes, HUD clicks that
* only emit `ClientCommand`s, empty equipment/belt and the plain-integer stat 6-11 wire format.
*/
import { describe, expect, it } from 'vitest'
import { HudModel, CLASS_ID_TO_CODE, nextLevelExperience } from '../../src/client/ui-model/hud-model.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../src/common/skills/formulas.ts'
import type { ClientCommand } from '../../src/netproto/index.ts'
function setup() {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
return { world, hud, model, commands }
}
function assignSelf(world: ClientWorld, classId: number): void {
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId, name: 'WebBotOne', x: 5000, y: 5000 },
isSelf: true,
})
}
describe('client hardcode audit — server-authoritative HUD', () => {
it('level-1 self with only non-zero stats shows 0 stat/skill points, 0 gold and table XP thresholds', () => {
const { world, hud, model } = setup()
assignSelf(world, 6)
// Values exactly as sent by the 1.13c server for a fresh Assassin (tests/fixtures/netproto/02*.d2cap):
// stats 6..11 arrive as 8.8 fixed point (ItemStatCost ValShift 8).
world.apply({ type: 'StatSet', stat: D2StatId.Strength, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.Energy, value: 25 })
world.apply({ type: 'StatSet', stat: D2StatId.Dexterity, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 12800 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 6400 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 24320 })
world.apply({ type: 'StatSet', stat: D2StatId.Level, value: 1 })
model.syncFromWorld()
expect(hud.charSheet.attrs.statPoints).toBe(0)
expect(hud.skillTree.unspentSkillPoints).toBe(0)
expect(hud.inventory.gold).toBe(0)
expect(hud.worldPanels.stashGold).toBe(0)
expect(hud.charSheet.attrs.level).toBe(1)
expect(hud.charSheet.attrs.xp).toBe(0)
expect(hud.charSheet.attrs.nextLevelXp).toBe(D2_EXPERIENCE_TABLE_99[2])
expect(nextLevelExperience(1)).toBe(500)
expect(nextLevelExperience(99)).toBe(0)
expect(hud.maxHp).toBe(50)
expect(hud.maxMana).toBe(25)
expect(hud.maxStamina).toBe(95)
})
it('stat 6-11 values are 8.8 fixed point on the wire (ValShift 8, unconditional)', () => {
const { world } = setup()
assignSelf(world, 6)
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 12800 }) // raw 1e 07 00 32
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: (420 << 8) + 128 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 24320 }) // raw 1e 0b 00 5f
world.apply({ type: 'StatSet', stat: D2StatId.HitPoints, value: 128 }) // 0.5 hp: no magnitude heuristic
expect(world.self.maxLife).toBe(50)
expect(world.self.maxMana).toBe(420)
expect(world.self.maxStamina).toBe(95)
expect(world.self.life).toBe(0)
expect(world.self.stats.get(D2StatId.MaxHp)).toBe(12800)
})
it('maps every 1.13c class id to its own skill tree and an empty server-driven quickbar', () => {
for (const [idStr, code] of Object.entries(CLASS_ID_TO_CODE)) {
const { world, hud, model } = setup()
assignSelf(world, Number(idStr))
model.syncFromWorld()
expect(hud.currentClass).toBe(code)
expect(hud.skillTree.characterClass).toBe(code)
expect(hud.skillTree.getSkillTree().length).toBeGreaterThan(0)
expect(hud.hotkeys.availableSkills).toEqual([])
}
const { world, hud, model } = setup()
assignSelf(world, 6)
model.syncFromWorld()
expect(hud.currentClass).toBe('ass')
expect(hud.charSheet.attrs.classNameEn).toBe('Assassin')
})
it('is not ready (no class, no fabricated name/class) before the server assigns the class', () => {
const { hud, model } = setup()
model.syncFromWorld()
expect(hud.currentClass).toBeNull()
expect(hud.skillTree.characterClass).toBeNull()
const snap = model.getSnapshot()
expect(snap.charClass).toBeNull()
expect(snap.classCode).toBeNull()
expect(snap.charName).toBe('')
expect(snap.hp).toBe(0)
})
it('HUD interactions only emit ClientCommands and never mutate local state', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({ type: 'StatSet', stat: D2StatId.StatPts, value: 5 })
world.apply({ type: 'StatSet', stat: D2StatId.NewSkills, value: 2 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 10 })
model.syncFromWorld()
expect(hud.commandSink).toBe(model)
expect(hud.allocateStat('vit')).toBe(true)
expect(hud.charSheet.attrs.vit).toBe(10)
expect(hud.charSheet.attrs.statPoints).toBe(5)
;(hud as any).handleSkillAllocated(36)
expect(hud.skillTree.unspentSkillPoints).toBe(2)
expect(hud.skillTree.hardPoints.get(36)).toBeUndefined()
hud.hotkeys.onSelectSkill?.('right', 36)
expect(hud.hotkeys.rightSkillId).toBe(0)
expect(world.self.rightSkill).toBe(0)
// Empty belt: no BeltUse with a made-up item id.
expect(hud.useBeltSlot(0)).toBe(false)
expect(commands).toEqual([
{ type: 'AddStatPoint', statId: D2StatId.Vitality },
{ type: 'AddSkillPoint', skillId: 36 },
{ type: 'SelectSkill', hand: 'right', skill: 36, itemId: 0xffffffff },
])
})
it('no equipment / empty belt / empty containers show no items', () => {
const { world, hud, model } = setup()
assignSelf(world, 1)
model.syncFromWorld()
expect(Object.keys(hud.inventory.equipped)).toEqual([])
expect(hud.inventory.gridItems).toEqual([])
expect(hud.belt.grid.flat().every((p) => p === null)).toBe(true)
expect(hud.worldPanels.stashItems).toEqual([])
expect(hud.worldPanels.cubeItems).toEqual([])
expect(hud.inventory.cursorItem).toBeNull()
})
it('splits 0x79 GoldInTrade into self and partner gold', () => {
const world = new ClientWorld()
world.apply({ type: 'Trade', action: 0x7901, gold: 300 })
world.apply({ type: 'Trade', action: 0x7900, gold: 70 })
expect(world.trade.selfGold).toBe(300)
expect(world.trade.partnerGold).toBe(70)
})
})