576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { CommandMapper, CORPSE_TARGET_SKILL_IDS } from '../../src/client/input/command-mapper.ts'
|
||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||
import { ClientUnit } from '../../src/client/world/client-unit.ts'
|
||
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
||
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
|
||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||
import { decode0x22UpdateSkillStatus, decode0x7BAssignSkillHotkey } from '../../src/netproto/d2gs/s2c/skills.ts'
|
||
import type { ClientCommand } from '../../src/netproto/index.ts'
|
||
|
||
describe('Combat Targeting, Input & Net-World Parity (Milestone M5 Phase 3)', () => {
|
||
function setupTestEnvironment() {
|
||
const world = new ClientWorld()
|
||
const hudManager = new HudManager()
|
||
const dispatchedCommands: ClientCommand[] = []
|
||
const sentCommands: ClientCommand[] = []
|
||
|
||
const hudModel = new HudModel({
|
||
world,
|
||
hudManager,
|
||
onCommand: (cmd: ClientCommand) => {
|
||
sentCommands.push(cmd)
|
||
},
|
||
})
|
||
hudManager.commandSink = hudModel
|
||
|
||
const mockCamera = {
|
||
subtileToScreen(x: number, y: number) {
|
||
return { x: (x - 5000) * 16 + 400, y: (y - 5000) * 8 + 300 }
|
||
},
|
||
screenToSubtile(x: number, y: number) {
|
||
return { x: (x - 400) / 16 + 5000, y: (y - 300) / 8 + 5000 }
|
||
},
|
||
update(_x: number, _y: number) {},
|
||
}
|
||
|
||
const commandMapper = new CommandMapper({
|
||
world,
|
||
hudManager,
|
||
hudModel,
|
||
camera: mockCamera as any,
|
||
sendCommand: (cmd: ClientCommand) => {
|
||
sentCommands.push(cmd)
|
||
},
|
||
})
|
||
|
||
// Initialize player at (5000, 5000)
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 0,
|
||
id: 1,
|
||
classId: 1,
|
||
name: 'Hero',
|
||
x: 5000,
|
||
y: 5000,
|
||
},
|
||
isSelf: true,
|
||
})
|
||
|
||
return {
|
||
world,
|
||
hudManager,
|
||
hudModel,
|
||
mockCamera,
|
||
commandMapper,
|
||
dispatchedCommands,
|
||
sentCommands,
|
||
}
|
||
}
|
||
|
||
// ─── 1. Issue #581: Default Attack (skill 0) sends C2S 0x06 LeftSkillOnUnit ───
|
||
it('Issue #581: left-clicking a hostile monster with skill 0 (Attack) sends SkillOn left', () => {
|
||
const { world, commandMapper, sentCommands } = setupTestEnvironment()
|
||
world.self.leftSkill = 0 // Attack
|
||
|
||
// Spawn hostile monster at (5003, 5000)
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 1,
|
||
id: 101,
|
||
classId: 2, // Blood Raven / monster
|
||
name: 'Zombie',
|
||
x: 5003,
|
||
y: 5000,
|
||
lifePct: 128,
|
||
mode: 1, // Neutral alive
|
||
},
|
||
})
|
||
|
||
const monster = world.getUnit('monster', 101)!
|
||
expect(monster.dead).toBe(false)
|
||
|
||
// Left click at monster position
|
||
const record = commandMapper.triggerPrimaryWorldAction(5003, 5000, monster, 1000)
|
||
expect(record).not.toBeNull()
|
||
expect(record?.command).toEqual({
|
||
type: 'SkillOn',
|
||
hand: 'left',
|
||
target: { unitType: 1, id: 101 },
|
||
shift: false,
|
||
})
|
||
})
|
||
|
||
// ─── 2. Issue #582: Mouse hover hitbox against DCC screen bounds & item priority ───
|
||
it('Issue #582: findUnitAtScreenPoint tests DCC bounding box; living monster takes priority over ground item unless Alt is held', () => {
|
||
const { world, commandMapper, mockCamera } = setupTestEnvironment()
|
||
|
||
// Spawn monster at (5000, 5000)
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 1,
|
||
id: 201,
|
||
classId: 5,
|
||
name: 'Gargoyle',
|
||
x: 5000,
|
||
y: 5000,
|
||
lifePct: 128,
|
||
mode: 1,
|
||
},
|
||
})
|
||
const monster = world.getUnit('monster', 201)!
|
||
expect(monster.getHitBox()).toEqual({
|
||
left: -24,
|
||
top: -64,
|
||
width: 48,
|
||
height: 72,
|
||
})
|
||
|
||
// Spawn ground item at exact same subtile (5000, 5000)
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
action: 0,
|
||
container: 'ground',
|
||
item: {
|
||
id: 999,
|
||
itemId: 999,
|
||
code: 'tsc',
|
||
rawCode: 'tsc ',
|
||
x: 5000,
|
||
y: 5000,
|
||
worldX: 5000,
|
||
worldY: 5000,
|
||
action: 0,
|
||
category: 0,
|
||
flags: 0,
|
||
rawFlags: 0,
|
||
version: 101,
|
||
location: 0,
|
||
container: 'ground',
|
||
bodyLoc: 0,
|
||
equippedSlot: 0,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
storePage: 1,
|
||
storagePage: 1,
|
||
compact: true,
|
||
simple: true,
|
||
identified: true,
|
||
socketed: false,
|
||
ethereal: false,
|
||
runeword: false,
|
||
personalized: false,
|
||
isEar: false,
|
||
newItem: false,
|
||
starterItem: false,
|
||
broken: false,
|
||
inSocket: false,
|
||
equipped: false,
|
||
socketedChildCount: 0,
|
||
usedSockets: 0,
|
||
} as any,
|
||
})
|
||
|
||
// Screen coordinate for (5000, 5000) is (400, 300)
|
||
// Point (400, 260) is inside monster hitbox (left -24..24 -> 376..424, top -64..8 -> 236..308)
|
||
const pickedScreen = commandMapper.findUnitAtScreenPoint(400, 260)
|
||
expect(pickedScreen?.id).toBe(201)
|
||
|
||
// Subtile picking without Alt: monster takes precedence over item
|
||
const pickedSubtileNormal = commandMapper.pickUnitAtSubtile(5000, 5000)
|
||
expect(pickedSubtileNormal?.id).toBe(201)
|
||
expect(pickedSubtileNormal?.unitType).toBe('monster')
|
||
|
||
// Subtile picking with Alt held: item takes precedence
|
||
commandMapper.handleKeyDown({ key: 'Alt', altKey: true })
|
||
const pickedSubtileAlt = commandMapper.pickUnitAtSubtile(5000, 5000)
|
||
expect(pickedSubtileAlt?.id).toBe(999)
|
||
expect(pickedSubtileAlt?.unitType).toBe('item')
|
||
commandMapper.handleKeyUp({ key: 'Alt' })
|
||
})
|
||
|
||
// ─── 3. Issue #583: Corpse unit selection for corpse skills ───
|
||
it('Issue #583: dead monster units can be targeted when active skill has TargetCorpse = 1', () => {
|
||
const { world, commandMapper } = setupTestEnvironment()
|
||
|
||
// Spawn dead monster (corpse) at (5002, 5000)
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 1,
|
||
id: 301,
|
||
classId: 1,
|
||
name: 'Skeleton',
|
||
x: 5002,
|
||
y: 5000,
|
||
lifePct: 0,
|
||
mode: 12, // MONMODE_DEATH
|
||
},
|
||
})
|
||
const corpseUnit = world.getUnit('monster', 301)!
|
||
expect(corpseUnit.dead).toBe(true)
|
||
|
||
// With regular Attack (skill 0), corpse is NOT picked
|
||
world.self.leftSkill = 0
|
||
world.self.rightSkill = 0
|
||
let picked = commandMapper.pickUnitAtSubtile(5002, 5000)
|
||
expect(picked).toBeNull()
|
||
|
||
// With Corpse Explosion (skill 70 in CORPSE_TARGET_SKILL_IDS), corpse CAN be picked
|
||
expect(CORPSE_TARGET_SKILL_IDS.has(70)).toBe(true)
|
||
world.self.rightSkill = 70
|
||
picked = commandMapper.pickUnitAtSubtile(5002, 5000)
|
||
expect(picked?.id).toBe(301)
|
||
expect(picked?.dead).toBe(true)
|
||
|
||
// Also verifies Barbarian Find Item (142) and Necromancer Revive (95) are in the set
|
||
expect(CORPSE_TARGET_SKILL_IDS.has(142)).toBe(true)
|
||
expect(CORPSE_TARGET_SKILL_IDS.has(95)).toBe(true)
|
||
})
|
||
|
||
// ─── 4. Issue #585: F1–F8 skill hotkey binding & S2C 0x7B decoding ───
|
||
it('Issue #585: S2C 0x7B assigns hotkey slot and pressing F1..F8 selects mapped skill', () => {
|
||
const { world, hudModel, commandMapper, sentCommands } = setupTestEnvironment()
|
||
|
||
// Decode 0x7B AssignSkillHotkey packet (slot 0 = F1, skill 47 = Fire Ball, hand right = 0x00)
|
||
// Wire: 0x7B (1B) | slot (1B) | skill_lo (1B) | skill_hi|hand (1B) | itemId (4B LE)
|
||
const wire7B = new Uint8Array([0x7b, 0x00, 0x2f, 0x00, 0xff, 0xff, 0xff, 0xff])
|
||
const decoded = decode0x7BAssignSkillHotkey(wire7B) as any
|
||
expect(decoded.type).toBe('ActiveSkill')
|
||
expect(decoded.isHotkey).toBe(true)
|
||
expect(decoded.slot).toBe(0)
|
||
expect(decoded.skill).toBe(47)
|
||
|
||
// Apply to ClientWorld
|
||
world.apply(decoded)
|
||
expect(world.skillHotkeys.get(0)).toMatchObject({
|
||
hand: 'right',
|
||
skill: 47,
|
||
})
|
||
|
||
// HudModel syncs world hotkeys to hud.hotkeys
|
||
hudModel.syncFromWorld()
|
||
|
||
// Press F1
|
||
sentCommands.length = 0
|
||
const handled = commandMapper.handleKeyDown({ key: 'F1' }, 1000)
|
||
expect(handled).toBe(true)
|
||
expect(sentCommands).toContainEqual({
|
||
type: 'SelectSkill',
|
||
hand: 'right',
|
||
skill: 47,
|
||
itemId: 0xffffffff,
|
||
})
|
||
})
|
||
|
||
// ─── 5. Issue #589: NPC distance check and monstats interact flag ───
|
||
it('Issue #589: clicking an interactive NPC only opens dialog when distance <= 6', () => {
|
||
const { world, commandMapper, sentCommands } = setupTestEnvironment()
|
||
|
||
// Player is at (5000, 5000)
|
||
// 1. Far NPC (Akara, classId 148) at (5015, 5000) -> distance = 15 > 6
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 1,
|
||
id: 401,
|
||
classId: 148, // Akara (interact = 1)
|
||
name: 'Akara',
|
||
x: 5015,
|
||
y: 5000,
|
||
lifePct: 128,
|
||
mode: 1,
|
||
},
|
||
})
|
||
const farNpc = world.getUnit('monster', 401)!
|
||
|
||
sentCommands.length = 0
|
||
commandMapper.triggerPrimaryWorldAction(5015, 5000, farNpc, 1000)
|
||
// Distance > 6: only MoveToUnit, NO NpcInit or Interact
|
||
expect(sentCommands.some((c) => c.type === 'NpcInit')).toBe(false)
|
||
expect(sentCommands.some((c) => c.type === 'Interact')).toBe(false)
|
||
|
||
// 2. Close NPC (Warriv, classId 155) at (5003, 5000) -> distance = 3 <= 6
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 1,
|
||
id: 402,
|
||
classId: 155, // Warriv (interact = 1)
|
||
name: 'Warriv',
|
||
x: 5003,
|
||
y: 5000,
|
||
lifePct: 128,
|
||
mode: 1,
|
||
},
|
||
})
|
||
const closeNpc = world.getUnit('monster', 402)!
|
||
|
||
sentCommands.length = 0
|
||
commandMapper.triggerPrimaryWorldAction(5003, 5000, closeNpc, 1000)
|
||
// Distance <= 6: sends MoveToUnit, NpcInit, and Interact
|
||
expect(sentCommands.some((c) => c.type === 'NpcInit')).toBe(true)
|
||
expect(sentCommands.some((c) => c.type === 'Interact')).toBe(true)
|
||
})
|
||
|
||
// ─── 6. Issue #590: Warp click sends C2S 0x13 Interact (unitType 5) ───
|
||
it('Issue #590: clicking a warp unit sends C2S 0x13 Interact with unitType 5', () => {
|
||
const { world, commandMapper, sentCommands } = setupTestEnvironment()
|
||
|
||
// Spawn a warp / door unit at (5004, 5000)
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 5, // tile / warp
|
||
id: 501,
|
||
classId: 1,
|
||
name: 'TownGate',
|
||
x: 5004,
|
||
y: 5000,
|
||
warpId: 1,
|
||
},
|
||
})
|
||
const warpUnit = world.getUnit(5, 501)!
|
||
|
||
sentCommands.length = 0
|
||
commandMapper.triggerPrimaryWorldAction(5004, 5000, warpUnit, 1000)
|
||
|
||
expect(sentCommands).toContainEqual({
|
||
type: 'Interact',
|
||
target: { unitType: 5, id: 501 },
|
||
})
|
||
expect(sentCommands).toContainEqual({
|
||
type: 'TakeWarp',
|
||
warpId: 501,
|
||
})
|
||
})
|
||
|
||
// ─── 7. Issue #591: Send raw clicked sub-tile coordinates in C2S Move ───
|
||
it('Issue #591: ground movement sends the exact clicked sub-tile coordinates', () => {
|
||
const { commandMapper } = setupTestEnvironment()
|
||
|
||
const record = commandMapper.triggerPrimaryWorldAction(5035, 5042, null, 1000)
|
||
expect(record).not.toBeNull()
|
||
expect(record?.command).toEqual({
|
||
type: 'Move',
|
||
run: true,
|
||
to: { x: 5035, y: 5042 },
|
||
})
|
||
})
|
||
|
||
// ─── 8. Issue #593: Pickup items toCursor: true when inventory is open ───
|
||
it('Issue #593: picking up ground item sets toCursor: true when inventory panel is open', () => {
|
||
const { world, hudManager, commandMapper, sentCommands } = setupTestEnvironment()
|
||
|
||
// Spawn ground item at (5001, 5000)
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
action: 0,
|
||
container: 'ground',
|
||
item: {
|
||
id: 601,
|
||
itemId: 601,
|
||
code: 'r01',
|
||
rawCode: 'r01 ',
|
||
x: 5001,
|
||
y: 5000,
|
||
worldX: 5001,
|
||
worldY: 5000,
|
||
action: 0,
|
||
category: 0,
|
||
flags: 0,
|
||
rawFlags: 0,
|
||
version: 101,
|
||
location: 0,
|
||
container: 'ground',
|
||
bodyLoc: 0,
|
||
equippedSlot: 0,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
storePage: 1,
|
||
storagePage: 1,
|
||
compact: true,
|
||
simple: true,
|
||
identified: true,
|
||
socketed: false,
|
||
ethereal: false,
|
||
runeword: false,
|
||
personalized: false,
|
||
isEar: false,
|
||
newItem: false,
|
||
starterItem: false,
|
||
broken: false,
|
||
inSocket: false,
|
||
equipped: false,
|
||
socketedChildCount: 0,
|
||
usedSockets: 0,
|
||
} as any,
|
||
})
|
||
const itemUnit = world.getUnit('item', 601)!
|
||
|
||
// 1. With inventory closed -> toCursor = false
|
||
hudManager.rightPanel = 'none'
|
||
const recClosed = commandMapper.triggerPrimaryWorldAction(5001, 5000, itemUnit, 1000)
|
||
expect(recClosed?.command).toMatchObject({
|
||
type: 'ItemPickup',
|
||
toCursor: false,
|
||
})
|
||
|
||
// 2. With inventory open -> toCursor = true
|
||
hudManager.rightPanel = 'inv'
|
||
const recOpen = commandMapper.triggerPrimaryWorldAction(5001, 5000, itemUnit, 1050)
|
||
expect(recOpen?.command).toMatchObject({
|
||
type: 'ItemPickup',
|
||
toCursor: true,
|
||
})
|
||
})
|
||
|
||
// ─── 9. Issue #594: 1.13c default hotkeys alignment (S, T, V, no M) ───
|
||
it('Issue #594: S toggles speedbar popup, T toggles skill tree, V toggles belt popup, M is ignored', () => {
|
||
const { hudManager, commandMapper } = setupTestEnvironment()
|
||
|
||
// 'S': toggles hotkeys speedbar popup
|
||
expect(hudManager.hotkeys.openPopup).toBeNull()
|
||
commandMapper.handleKeyDown({ key: 's' })
|
||
expect(hudManager.hotkeys.openPopup).toBe('right')
|
||
commandMapper.handleKeyDown({ key: 's' })
|
||
expect(hudManager.hotkeys.openPopup).toBeNull()
|
||
|
||
// 'T': toggles skill tree
|
||
expect(hudManager.rightPanel).toBe('none')
|
||
commandMapper.handleKeyDown({ key: 't' })
|
||
expect(hudManager.rightPanel).toBe('skill')
|
||
|
||
// 'V': toggles belt popup (requires multi-row belt equipped to expand)
|
||
hudManager.inventory.equipped.belt = { code: 'hbl' } as any
|
||
expect(hudManager.belt.isExpanded).toBe(false)
|
||
commandMapper.handleKeyDown({ key: 'v' })
|
||
expect(hudManager.belt.isExpanded).toBe(true)
|
||
commandMapper.handleKeyDown({ key: 'v' })
|
||
expect(hudManager.belt.isExpanded).toBe(false)
|
||
|
||
// 'M': ignored (does not open waypoint)
|
||
commandMapper.handleKeyDown({ key: 'm' })
|
||
expect(hudManager.leftPanel).not.toBe('waypoint')
|
||
})
|
||
|
||
// ─── 10. Issue #595: CommandMapper mouse-hold 40ms throttling ───
|
||
it('Issue #595: mouse-hold continuous action dispatch is throttled to 40ms (~25Hz)', () => {
|
||
const { world, hudManager, hudModel, mockCamera, sentCommands } = setupTestEnvironment()
|
||
const rateLimiter = new OutboundRateLimiter({ moveMinIntervalMs: 40 })
|
||
const commandMapper = new CommandMapper({
|
||
world,
|
||
hudManager,
|
||
hudModel,
|
||
rateLimiter,
|
||
camera: mockCamera as any,
|
||
sendCommand: (cmd) => sentCommands.push(cmd),
|
||
})
|
||
|
||
// Pointer down at t = 1000ms
|
||
const cmd1 = commandMapper.handlePointerDown(450, 350, 0, 1000)
|
||
expect(cmd1).not.toBeNull()
|
||
|
||
// Move pointer to steer at t = 1016ms (16ms elapsed < 40ms): throttled, returns null
|
||
commandMapper.handlePointerMove(480, 380, 1016)
|
||
const cmd2 = commandMapper.tick(1016)
|
||
expect(cmd2).toBeNull()
|
||
|
||
// Frame update at t = 1045ms (45ms elapsed >= 40ms): allowed
|
||
const cmd3 = commandMapper.tick(1045)
|
||
expect(cmd3).not.toBeNull()
|
||
|
||
// Pointer up at t = 1050ms
|
||
commandMapper.handlePointerUp(0)
|
||
})
|
||
|
||
// ─── 11. Net-World Integrity: Issues #577, #578, #579, #580 ───
|
||
it('Issue #577: fail fast when UnitAssign or ItemEvent payload is missing', () => {
|
||
const world = new ClientWorld()
|
||
|
||
expect(() => {
|
||
world.apply({ type: 'UnitAssign' })
|
||
}).toThrow(/missing unit payload/)
|
||
|
||
expect(() => {
|
||
world.apply({ type: 'ItemEvent' })
|
||
}).toThrow(/missing item payload/)
|
||
})
|
||
|
||
it('Issue #578: ClientUnit lifePercent scales 0..128 to 0..100%, and Resurrect does not optimistically revive', () => {
|
||
const world = new ClientWorld()
|
||
const unit = new ClientUnit({
|
||
unitType: 'monster',
|
||
id: 777,
|
||
classId: 1,
|
||
x: 100,
|
||
y: 100,
|
||
lifePct: 64, // exactly half (64 / 128 = 50%)
|
||
})
|
||
expect(unit.lifePercent).toBe(50)
|
||
|
||
// Player death
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
world.apply({ type: 'LifeMana', life: 0, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
|
||
expect(world.isDead).toBe(true)
|
||
|
||
// Sending Resurrect does not optimistically flip isDead
|
||
world.noteOutgoingCommand({ type: 'Resurrect' })
|
||
expect(world.isDead).toBe(true)
|
||
|
||
// Server sends LifeMana revival -> isDead becomes false
|
||
world.apply({ type: 'LifeMana', life: 100 << 8, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
|
||
expect(world.isDead).toBe(false)
|
||
})
|
||
|
||
it('Issue #579: fail fast when ActDataLoaded missing areaId or PlayerAssign missing unitId', () => {
|
||
const world = new ClientWorld()
|
||
|
||
expect(() => {
|
||
world.apply({ type: 'ActDataLoaded', act: 0 })
|
||
}).toThrow(/missing areaId/)
|
||
|
||
expect(() => {
|
||
world.apply({ type: 'PlayerAssign' })
|
||
}).toThrow(/missing unitId/)
|
||
})
|
||
|
||
it('Issue #580: S2C 0x22 skill charges separated from level, and 8.8 pool stats normalized', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
|
||
// S2C 0x22 UpdateSkillStatus (12B: opcode | unk | unitId | skill | quantity | unk)
|
||
// skill 42 (0x002a), quantity 15 (0x0f)
|
||
const wire22 = new Uint8Array([0x22, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x0f, 0x00, 0x00])
|
||
const decoded22 = decode0x22UpdateSkillStatus(wire22) as any
|
||
expect(decoded22.isSkillStatus).toBe(true)
|
||
expect(decoded22.skills[0]?.quantity).toBe(15)
|
||
|
||
world.apply(decoded22)
|
||
expect(world.self.skillCharges?.get(42)).toBe(15)
|
||
|
||
// LifeMana event normalizes stats with ValShift = 8
|
||
world.apply({
|
||
type: 'LifeMana',
|
||
life: 80,
|
||
mana: 50,
|
||
stamina: 100,
|
||
at: { x: 5000, y: 5000 },
|
||
})
|
||
expect(world.self.stats.get(6)).toBe(80 << 8) // STAT_HITPOINTS
|
||
expect(world.self.stats.get(8)).toBe(50 << 8) // STAT_MANA
|
||
expect(world.self.stats.get(10)).toBe(100 << 8) // STAT_STAMINA
|
||
})
|
||
})
|