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

479 lines
20 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 { drawGroundItem, GROUND_ITEM_QUALITY_COLORS, SceneMouseController } from '../src/scene/act-scene.ts'
import { createNetSimulation } from '../src/scene/net-scene.ts'
import { computeLockstepHash, LockstepSession } from '../src/net/lockstep.ts'
import {
GROUND_LABEL_QUALITY_COLORS,
getGroundItemQualityColor,
isGroundItemEthereal,
isGroundItemSocketed,
isGroundItemEtherealOrSocketed,
computeInitialGroundLabelLayouts,
} from '../src/ui/ground-labels.ts'
import {
BeltHud,
BELT_COLS,
BELT_ROWS,
BELT_POTION_CATALOG,
areBeltPotionsCompatible,
isAutoBeltablePotion,
itemToBeltPotion,
} from '../src/ui/belt.ts'
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/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/game/ground-items.ts'
import { GameEngine } from '../src/game/engine.ts'
import { createWorld, addPlayer, spawnMonsters, spawnMonsterPacks, type MonsterStats } from '../src/game/combat.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import type { SpriteRenderer } from '../src/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)
}
})
it('renders flippy sprite via renderer.draw and never falls back to procedural color rectangles even when itemsAtlas is undefined', () => {
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const sprites: { frame: any; x: number; y: number; options: any }[] = []
const renderer = {
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
quads.push({ x, y, w, h, color })
},
draw(frame: any, x: number, y: number, options: any) {
sprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const entity: GroundItemEntity = {
id: 'm4_test_cap',
item: { code: 'cap', name: 'Cap' },
name: 'Cap',
nameZh: '帽子',
quality: 'magic',
isGold: false,
amount: 1,
invWidth: 2,
invHeight: 2,
dropTime: 0,
x: 320,
y: 240,
cellX: 10,
cellY: 10,
sparklePhase: 0,
}
// Timestamp 1000ms is outside the sparkle window
drawGroundItem(renderer, entity, 1000, undefined)
expect(sprites.length).toBe(1)
expect(sprites[0]!.frame.width).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.w)
expect(sprites[0]!.frame.height).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.h)
// Only 1 solid quad (ground shadow), zero colored border/body rectangles
expect(quads.length).toBe(1)
expect(quads[0]!.color[0]).toBe(0)
expect(quads[0]!.color[1]).toBe(0)
expect(quads[0]!.color[2]).toBe(0)
})
})
describe('Issue #424 — Ground Label Colors & Ethereal/Socketed Gray (#808080) Override', () => {
it('detects ethereal and socketed ground items from entity or underlying item metadata', () => {
const ethEntity = {
id: 'e1',
item: { ethereal: true, sockets: 0 },
name: 'Monarch',
nameZh: '统治者大盾',
quality: 'normal' as const,
isGold: false,
amount: 1,
invWidth: 2,
invHeight: 3,
dropTime: 0,
x: 100,
y: 100,
cellX: 3,
cellY: 3,
sparklePhase: 0,
}
const sockEntity = {
...ethEntity,
id: 's1',
item: { ethereal: false, sockets: 4 },
}
const plainEntity = {
...ethEntity,
id: 'p1',
item: { ethereal: false, sockets: 0 },
}
expect(isGroundItemEthereal(ethEntity)).toBe(true)
expect(isGroundItemSocketed(ethEntity)).toBe(false)
expect(isGroundItemEtherealOrSocketed(ethEntity)).toBe(true)
expect(isGroundItemEthereal(sockEntity)).toBe(false)
expect(isGroundItemSocketed(sockEntity)).toBe(true)
expect(isGroundItemEtherealOrSocketed(sockEntity)).toBe(true)
expect(isGroundItemEtherealOrSocketed(plainEntity)).toBe(false)
})
it('overrides normal and superior items to #808080 when ethereal or socketed, while preserving magic/rare/set/unique/runeword colors', () => {
const mgr = new GroundItemManager()
const ethSword = mgr.add({ code: 'bsd', name: 'Broad Sword', quality: 'normal', ethereal: true }, 100, 100, 3, 3)
const sockArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 3 }, 120, 100, 4, 3)
const plainArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 0, ethereal: false }, 140, 100, 5, 3)
const ethUnique = mgr.add({ code: 'ame', name: "Titan's Revenge", quality: 'unique', ethereal: true }, 160, 100, 6, 3)
const sockMagic = mgr.add({ code: 'bsd', name: 'Mechanic Broad Sword', quality: 'magic', sockets: 2 }, 180, 100, 7, 3)
const runeword = mgr.add({ code: 'xtp', name: 'Enigma', quality: 'normal', sockets: 3, isRuneword: true }, 200, 100, 8, 3)
expect(ethSword.ethereal).toBe(true)
expect(sockArmor.sockets).toBe(3)
expect(getGroundItemQualityColor(ethSword)).toBe('#808080')
expect(getGroundItemQualityColor(sockArmor)).toBe('#808080')
expect(getGroundItemQualityColor(plainArmor)).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
expect(getGroundItemQualityColor(ethUnique)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
expect(getGroundItemQualityColor(sockMagic)).toBe(GROUND_LABEL_QUALITY_COLORS.magic)
expect(getGroundItemQualityColor(runeword)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
// Verify GroundLabelLayout uses the gray color and borderColor
const layouts = computeInitialGroundLabelLayouts(
[ethSword, sockArmor, plainArmor, ethUnique],
(wx, wy) => ({ x: wx, y: wy }),
)
expect(layouts[0]!.color).toBe('#808080')
expect(layouts[1]!.color).toBe('#808080')
expect(layouts[2]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
expect(layouts[3]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
// Verify itemToUiInventoryItem preserves ethereal flag
const uiEth = itemToUiInventoryItem(ethSword.item, getEmbeddedDropTables())
expect(uiEth.ethereal).toBe(true)
})
it('uses gray (#808080) sparkle aura in drawGroundItem for ethereal/socketed normal items', () => {
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const renderer = {
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
quads.push({ x, y, w, h, color })
},
draw() {},
} as unknown as SpriteRenderer
const mgr = new GroundItemManager()
const ethNormal = mgr.add({ code: 'ssd', name: 'Short Sword', quality: 'normal', ethereal: true }, 0, 0, 0, 0)
// Render at t = 160ms (peak of sparkle flash window)
drawGroundItem(renderer, ethNormal, 160)
expect(quads.length).toBe(5)
const aura = quads[4]!
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![0], 2)
expect(aura.color[1]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![1], 2)
expect(aura.color[2]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![2], 2)
})
})
describe('Issue #425 — Pickup Radius (2-Subtile Collision-Box Edge Distance) & Belt Quick-Slotting', () => {
it('computes collision-box edge distance and allows pickup up to 2 subtiles (64px edge / 96px center)', () => {
expect(PLAYER_COLLISION_EXTENT_PX).toBe(16)
expect(ITEM_COLLISION_EXTENT_PX).toBe(16)
expect(D2_PICKUP_SUBTILE_REACH_PX).toBe(64)
// Center distance 96px -> edge distance 96 - 32 = 64px (within reach)
expect(getPickupEdgeDistance(500, 500, 596, 500)).toBeCloseTo(64, 5)
expect(isWithinPickupBounds(500, 500, 596, 500, 64)).toBe(true)
// Center distance 97px -> edge distance 65px (outside 64px reach)
expect(isWithinPickupBounds(500, 500, 597, 500, 64)).toBe(false)
// SceneMouseController.tick triggers pickup at 80px center distance (which previously stalled at dist <= 48)
const belt = new BeltHud()
belt.clear()
const engine = createTestEngine(64, belt)
const dropped = engine.dropItem({ code: 'hp1', name: 'Minor Healing Potion', invWidth: 1, invHeight: 1 }, 580, 500)
// Force exact coordinates 80px from player (500, 500)
dropped.x = 580
dropped.y = 500
const mockStatus = { textContent: '' }
const controller = new SceneMouseController({
canvas: {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as any,
engine,
camera: { zoom: 1 } as any,
input: {
takeLeftClick: () => null,
takeRightClick: () => null,
shiftHeld: false,
movement: () => ({ x: 0, y: 0 }),
} as any,
getRuntime: () => ({ grid: { cellsX: 100, cellsY: 100 }, waypoints: [], stashes: [] }) as any,
hudManager: null,
waypointNetwork: null as any,
status: mockStatus as any,
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
getCharacter: () => null,
})
controller.pickupGroundItem(dropped)
expect(engine.groundItems.count).toBe(0)
expect(belt.grid[0]![0]?.code).toBe('hp1')
})
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)
})
})
})