155 lines
6.6 KiB
TypeScript
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)
|
|
})
|
|
})
|