/** * 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) }) }) })