diablo2-web/tests/m4-visual-belt-net-parity.t...

283 lines
11 KiB
TypeScript

/**
* Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplayer Drop Sync Parity Suite
* Covers:
* - Issue #423: Ground Drop Rendering — Remove Color Box Fallbacks & Use Only Authentic Flippy DC6 Sprites + Frozen Death Anim Fix
* - Issue #424: Ground Label Colors — Authentic D2 Quality Colors & Ethereal/Socketed Gray (#808080) Override
* - Issue #425: Pickup Radius & Belt Quick-Slotting — 2-Subtile Edge Bounds & Auto-Belt Potions
* - Issue #427: Multiplayer & Persistence — Lockstep Drop Sync via Shared Monster dwInitSeed
*/
import { describe, it, expect, vi } from 'vitest'
import { createNetSimulation } from '../src/server/engine/state-hash.ts'
import { computeLockstepHash, LockstepSession } from '../src/server/engine/state-hash.ts'
import {
BeltHud,
BELT_COLS,
BELT_ROWS,
BELT_POTION_CATALOG,
areBeltPotionsCompatible,
isAutoBeltablePotion,
itemToBeltPotion,
} from '../src/client/ui/belt.ts'
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import {
GroundItemManager,
PLAYER_COLLISION_EXTENT_PX,
ITEM_COLLISION_EXTENT_PX,
D2_PICKUP_SUBTILE_REACH_PX,
getPickupEdgeDistance,
isWithinPickupBounds,
type GroundItemEntity,
} from '../src/common/items/ground-items.ts'
import { GameEngine } from '../src/server/engine/engine.ts'
import { createWorld, addPlayer, spawnMonsters, spawnMonsterPacks, type MonsterStats } from '../src/server/engine/combat.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
function createTestEngine(pickupRadius = 64, belt?: BeltHud) {
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
return new GameEngine(terrain, {
spawn: { x: 500, y: 500 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 50,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 48,
pickupRadius,
inventoryCols: 10,
inventoryRows: 4,
lootSeed: 0x12345678,
npcDefs: [],
dropTables: getEmbeddedDropTables(),
belt,
})
}
const TEST_ZOMBIE_STATS: MonsterStats = {
id: 'bloodMoorBoss',
name: 'Corpsefire',
hp: 10,
damage: 2,
speed: 0,
reach: 10,
aggroRadius: 100,
cooldownTicks: 20,
xp: 50,
level: 4,
}
describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplayer Parity', () => {
describe('Issue #423 — Remove Color Box Fallbacks & Authentic Flippy DC6 Sprites', () => {
it('resolves valid flippyRects for 100% of weapons, armor, and misc bases in embedded drop tables', () => {
const tables = getEmbeddedDropTables()
const allBases = [
...tables.weapons.all,
...tables.armor.all,
...tables.misc.all,
]
expect(allBases.length).toBeGreaterThanOrEqual(500)
for (const base of allBases) {
const rect = resolveGroundItemSpriteRect(
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
BAKED_UI_MANIFEST.flippyRects,
BAKED_UI_MANIFEST.codeToFlippyFile,
)
expect(rect, `Missing flippy sprite rect for ${base.code} (${base.name})`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0)
expect(rect!.h).toBeGreaterThan(0)
}
})
})
describe('Issue #425 — Pickup Radius (2-Subtile Collision-Box Edge Distance) & Belt Quick-Slotting', () => {
it('implements 2-pass INVENTORY_GetFreeBeltSlot auto-belt placement and falls back to bag when belt is full', () => {
const belt = new BeltHud()
belt.clear()
// Verify catalog & compatibility rules
expect(Object.keys(BELT_POTION_CATALOG).length).toBe(15)
expect(isAutoBeltablePotion({ code: 'hp3' })).toBe(true)
expect(isAutoBeltablePotion({ code: 'rvs' })).toBe(true)
expect(isAutoBeltablePotion({ code: 'vps' })).toBe(true)
expect(isAutoBeltablePotion({ code: 'yps' })).toBe(true)
expect(isAutoBeltablePotion({ code: 'wms' })).toBe(true)
expect(isAutoBeltablePotion({ code: 'gpm' })).toBe(false)
const hp1 = itemToBeltPotion({ code: 'hp1' })!
const hp5 = itemToBeltPotion({ code: 'hp5' })!
const rvs = itemToBeltPotion({ code: 'rvs' })!
const rvl = itemToBeltPotion({ code: 'rvl' })!
const vps = itemToBeltPotion({ code: 'vps' })!
const yps = itemToBeltPotion({ code: 'yps' })!
expect(areBeltPotionsCompatible(hp1, hp5)).toBe(true)
expect(areBeltPotionsCompatible(rvs, rvl)).toBe(true)
expect(areBeltPotionsCompatible(hp1, rvs)).toBe(false)
expect(areBeltPotionsCompatible(vps, yps)).toBe(false)
const engine = createTestEngine(64, belt)
// Drop 17 healing potions and pick them up via engine.pickupItem
for (let i = 0; i < BELT_COLS * BELT_ROWS; i++) {
const g = engine.dropItem(
{ id: `pot_${i}`, code: i % 2 === 0 ? 'hp1' : 'hp5', name: 'Healing Potion', invWidth: 1, invHeight: 1 },
500,
500,
)
const res = engine.pickupItem(g.id)
expect(res.success).toBe(true)
}
// First 16 potions fill the 4x4 belt; bag remains empty
expect(belt.countTotalPotions()).toBe(16)
expect(engine.bag.contents.length).toBe(0)
// 17th potion overflows belt and lands in engine.bag
const g17 = engine.dropItem(
{ id: 'pot_17', code: 'hp4', name: 'Greater Healing Potion', invWidth: 1, invHeight: 1 },
500,
500,
)
const res17 = engine.pickupItem(g17.id)
expect(res17.success).toBe(true)
expect(engine.bag.contents.length).toBe(1)
})
})
describe('Issue #427 — Multiplayer Lockstep Drop Sync & Monster dwInitSeed', () => {
it('initializes deterministic dwInitSeed on monsters in spawnMonsters and spawnMonsterPacks', () => {
const terrain = { overlap: () => 0 }
const w1 = createWorld(0, 0)
const w2 = createWorld(0, 0)
spawnMonsters(w1, [TEST_ZOMBIE_STATS], 3, { x: 100, y: 100 }, 50, terrain, 0xabcdef01)
spawnMonsters(w2, [TEST_ZOMBIE_STATS], 3, { x: 100, y: 100 }, 50, terrain, 0xabcdef01)
expect(w1.monsters.length).toBe(3)
for (let i = 0; i < w1.monsters.length; i++) {
expect(w1.monsters[i]!.dwInitSeed).toBeDefined()
expect(w1.monsters[i]!.dwInitSeed).toBeGreaterThan(0)
expect(w1.monsters[i]!.dwInitSeed).toBe(w2.monsters[i]!.dwInitSeed)
}
const wp1 = createWorld(0, 0)
const wp2 = createWorld(0, 0)
spawnMonsterPacks(
wp1,
[{ members: [TEST_ZOMBIE_STATS, TEST_ZOMBIE_STATS] }],
{ x: 200, y: 200 },
80,
terrain,
undefined,
undefined,
0x55aa55aa,
)
spawnMonsterPacks(
wp2,
[{ members: [TEST_ZOMBIE_STATS, TEST_ZOMBIE_STATS] }],
{ x: 200, y: 200 },
80,
terrain,
undefined,
undefined,
0x55aa55aa,
)
expect(wp1.monsters.length).toBe(2)
for (let i = 0; i < wp1.monsters.length; i++) {
expect(wp1.monsters[i]!.dwInitSeed).toBe(wp2.monsters[i]!.dwInitSeed)
}
})
it('mixes ground item quality, stack, value, and uniqueId into computeLockstepHash', () => {
const world = createWorld(0, 0)
const baseItem = {
name: 'Short Sword',
stack: 1,
value: 50,
quality: 'magic' as const,
uniqueId: 1001,
base: {} as any,
prefix: null,
suffix: null,
level: 1,
invWidth: 1,
invHeight: 3,
stats: {},
}
const hMagic = computeLockstepHash(world, [], [{ x: 10, y: 20, item: baseItem as any }], [])
const hRare = computeLockstepHash(world, [], [{ x: 10, y: 20, item: { ...baseItem, quality: 'rare' } as any }], [])
const hSeed = computeLockstepHash(world, [], [{ x: 10, y: 20, item: { ...baseItem, uniqueId: 1002 } as any }], [])
expect(hMagic).not.toBe(hRare)
expect(hMagic).not.toBe(hSeed)
})
it('synchronizes monster drop pipeline and pickup across 2 lockstep peers', () => {
const terrain = { overlap: () => 0 }
const worldA = createWorld(0, 0)
addPlayer(worldA, 10, 0)
const worldB = createWorld(0, 0)
addPlayer(worldB, 10, 0)
spawnMonsters(worldA, [TEST_ZOMBIE_STATS], 1, { x: 4, y: 0 }, 0, terrain, 0x42424242)
spawnMonsters(worldB, [TEST_ZOMBIE_STATS], 1, { x: 4, y: 0 }, 0, terrain, 0x42424242)
const options = {
playerSpeed: 100,
playerReach: 30,
playerCooldownTicks: 2,
playerDamage: 50,
playerManaPerAttack: 0,
respawnTicks: 100,
}
const simA = createNetSimulation(worldA, options, terrain, [0, 100], 2, 0x99887766)
const simB = createNetSimulation(worldB, options, terrain, [0, 100], 2, 0x99887766)
const sessionA = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, simA.simulation)
const sessionB = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, simB.simulation)
// Tick 0: Peer 0 slays monster -> executeDropPipeline spawns identical drops on both peers
sessionA.submit(0, { tick: 0, movement: { x: 0, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 })
sessionA.submit(1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
sessionB.submit(0, { tick: 0, movement: { x: 0, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 })
sessionB.submit(1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
const step0A = sessionA.step()
const step0B = sessionB.step()
expect(step0A.kind).toBe('stepped')
expect(step0B.kind).toBe('stepped')
if (step0A.kind === 'stepped' && step0B.kind === 'stepped') {
expect(step0A.hash).toBe(step0B.hash)
}
expect(simA.ground.length).toBeGreaterThan(0)
expect(simA.ground.length).toBe(simB.ground.length)
// Tick 1: Peer 0 picks up item -> inventories and ground items stay in lockstep sync
sessionA.submit(0, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: true, talk: false, skill: 0 })
sessionA.submit(1, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
sessionB.submit(0, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: true, talk: false, skill: 0 })
sessionB.submit(1, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
const step1A = sessionA.step()
const step1B = sessionB.step()
expect(step1A.kind).toBe('stepped')
expect(step1B.kind).toBe('stepped')
if (step1A.kind === 'stepped' && step1B.kind === 'stepped') {
expect(step1A.hash).toBe(step1B.hash)
}
expect(simA.ground.length).toBe(simB.ground.length)
expect(simA.inventories[0]!.contents.length).toBe(simB.inventories[0]!.contents.length)
})
})
})