283 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|