diablo2-web/tests/ground-items-flippy-parity....

317 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import { drawGroundItem } from '../src/scene/act-scene.ts'
import type { GroundItemEntity } from '../src/game/ground-items.ts'
import type { SpriteRenderer } from '../src/render/renderer.ts'
import { GameEngine } from '../src/game/engine.ts'
describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => {
describe('1. Flippy File Resolution Parity', () => {
it('resolves authentic flippy ground sprites for weapons and armor', () => {
const sword = resolveGroundItemSpriteRect({ code: 'ssd', name: 'Short Sword' })
expect(sword).toBeDefined()
expect(sword).not.toBeNull()
expect(sword).toEqual(BAKED_UI_MANIFEST.flippyRects['flpssd'])
expect(sword!.w).toBe(27)
expect(sword!.h).toBe(9)
const axe = resolveGroundItemSpriteRect({ code: 'hax', name: 'Hand Axe' })
expect(axe).toEqual(BAKED_UI_MANIFEST.flippyRects['flphax'])
expect(axe!.w).toBe(9)
expect(axe!.h).toBe(13)
const cap = resolveGroundItemSpriteRect({ code: 'cap', name: 'Cap' })
expect(cap).toEqual(BAKED_UI_MANIFEST.flippyRects['flpcap'])
expect(cap!.w).toBe(11)
expect(cap!.h).toBe(8)
})
it('resolves gold piles into 3 authentic DC6 graphic tiers based on gold amount', () => {
// Tier 0: small pile (1 - 49 gold)
const small = resolveGroundItemSpriteRect({ isGold: true, amount: 45, code: 'gld' })
expect(small).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_0'])
expect(small!.w).toBe(24)
expect(small!.h).toBe(7)
// Tier 1: medium pile (50 - 499 gold)
const medium = resolveGroundItemSpriteRect({ isGold: true, amount: 250, code: 'gld' })
expect(medium).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_1'])
expect(medium!.w).toBe(26)
expect(medium!.h).toBe(13)
// Tier 2: large pile (500+ gold)
const large = resolveGroundItemSpriteRect({ isGold: true, amount: 1500, code: 'gld' })
expect(large).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_2'])
expect(large!.w).toBe(32)
expect(large!.h).toBe(20)
const huge = resolveGroundItemSpriteRect({ isGold: true, amount: 10000, code: 'gld' })
expect(huge).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_2'])
expect(huge!.w).toBe(32)
expect(huge!.h).toBe(20)
})
it('resolves potions, scrolls, and runes to authentic flippy files', () => {
const redPotion = resolveGroundItemSpriteRect({ code: 'hp1', name: 'Lesser Healing Potion' })
expect(redPotion).toEqual(BAKED_UI_MANIFEST.flippyRects['flprps'])
const bluePotion = resolveGroundItemSpriteRect({ code: 'mp1', name: 'Lesser Mana Potion' })
expect(bluePotion).toEqual(BAKED_UI_MANIFEST.flippyRects['flpbps'])
const rune = resolveGroundItemSpriteRect({ code: 'r01', name: 'El Rune' })
expect(rune).toEqual(BAKED_UI_MANIFEST.flippyRects['flprun'])
expect(rune!.w).toBe(4)
expect(rune!.h).toBe(4)
const tpScroll = resolveGroundItemSpriteRect({ code: 'tsc', name: 'Town Portal Scroll' })
expect(tpScroll).toEqual(BAKED_UI_MANIFEST.flippyRects['flpbsc'])
})
it('resolves unique items and charms to authentic ground art', () => {
const anni = resolveGroundItemSpriteRect({ code: 'cm1', name: 'Annihilus' })
expect(anni).toEqual(BAKED_UI_MANIFEST.flippyRects['flpmss'])
const torch = resolveGroundItemSpriteRect({ code: 'cm2', name: 'Hellfire Torch' })
expect(torch).toEqual(BAKED_UI_MANIFEST.flippyRects['flptrch'])
const cube = resolveGroundItemSpriteRect({ code: 'box', name: 'Horadric Cube' })
expect(cube).toEqual(BAKED_UI_MANIFEST.flippyRects['flpbox'])
})
it('resolves color-transformed items to dyed ground flippy sprites', () => {
const shako = resolveGroundItemSpriteRect({
code: 'uap',
invFile: 'invcap',
invtransform: 'cgrn',
name: 'Harlequin Crest',
})
expect(shako).toEqual(BAKED_UI_MANIFEST.flippyRects['flpcap_cgrn'])
})
})
describe('2. Ground Item Rendering with Atlas Parity', () => {
it('renders authentic Blizzard flippy sprite with 1:1 isometric dimensions and ground shadow', () => {
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const drawnSprites: { 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]) {
drawnQuads.push({ x, y, w, h, color })
},
draw(frame: any, x: number, y: number, options: any) {
drawnSprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any
const groundSword: GroundItemEntity = {
id: 'ground_sword_test',
item: { name: 'Short Sword', code: 'ssd' },
name: 'Short Sword',
nameZh: '短剑',
quality: 'magic',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 3,
dropTime: 0,
x: 300,
y: 250,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, groundSword, 1000, mockAtlas)
// Verified: Exactly 1 sprite drawn via renderer.draw using flpssd rect
expect(drawnSprites.length).toBe(1)
const sprite = drawnSprites[0]!
expect(sprite.options.atlas).toBe(mockAtlas)
expect(sprite.options.width).toBe(27)
expect(sprite.options.height).toBe(9)
expect(sprite.x).toBe(Math.round(300 - 27 / 2))
expect(sprite.y).toBe(250 - 9)
// Ground contact shadow is drawn at item.y - 2
expect(drawnQuads.length).toBe(1)
const shadow = drawnQuads[0]!
expect(shadow.y).toBe(250 - 2)
expect(shadow.color[3]).toBeLessThanOrEqual(0.4) // Subtle shadow alpha
// Zero blue bounding boxes
const blueBox = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
expect(blueBox).toBeUndefined()
// Zero white sparkle star quads
const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0)
expect(whiteGlint).toBeUndefined()
})
it('renders authentic flpgld sprite for dropped gold when atlas is present', () => {
const drawnQuads: any[] = []
const drawnSprites: any[] = []
const renderer = {
drawSolid(x: number, y: number, w: number, h: number, color: any) {
drawnQuads.push({ x, y, w, h, color })
},
draw(frame: any, x: number, y: number, options: any) {
drawnSprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any
const groundGold: GroundItemEntity = {
id: 'gold_huge_test',
item: { isGold: true },
name: '5000 Gold',
nameZh: '5000 金币',
quality: 'normal',
isGold: true,
amount: 5000,
invWidth: 1,
invHeight: 1,
dropTime: 0,
x: 400,
y: 350,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, groundGold, 1000, mockAtlas)
expect(drawnSprites.length).toBe(1)
const goldSprite = drawnSprites[0]!
// Tier 2 large pile (500+): 32x20
expect(goldSprite.options.width).toBe(32)
expect(goldSprite.options.height).toBe(20)
expect(goldSprite.x).toBe(Math.round(400 - 32 / 2))
expect(goldSprite.y).toBe(350 - 20)
})
})
describe('3. Clean Pickup Lifecycle (Zero Remaining Dots / Artifacts)', () => {
it('completely cleans groundItems on engine pickup tick without leaving orphaned entities', () => {
const engine = new GameEngine(
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
{
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
},
)
engine.world.player.x = 100
engine.world.player.y = 100
const droppedItem = {
id: 'test_drop_item',
code: 'hax',
name: 'Hand Axe',
invWidth: 1,
invHeight: 3,
}
// Drop item near player
engine.dropItem(droppedItem, 105, 105, 5, 5)
expect(engine.groundItems.count).toBe(1)
expect(engine.ground.length).toBe(1)
// Tick with pickup intent
engine.tick({
pickingUp: true,
movement: { x: 0, y: 0 },
attacking: false,
talking: false,
digits: [],
saving: false,
loading: false,
})
// Verified: Cleanly removed from both collections
expect(engine.groundItems.count).toBe(0)
expect(engine.ground.length).toBe(0)
expect(engine.bag.contents.some(p => (p.item as any).id === 'test_drop_item')).toBe(true)
})
})
describe('4. Full-Spectrum 100% Drop Table Flippy DC6 Coverage & Anti-Color-Box Parity (Issue #423)', () => {
it('resolves valid non-empty flippyRects for 100% of weapons, armor, and misc bases in embedded drop tables', async () => {
const { getEmbeddedDropTables } = await import('../src/game/embedded-drop-tables.ts')
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 sr = 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(sr, `Expected flippy sprite rect for base ${base.code} (${base.name})`).not.toBeNull()
expect(sr!.w).toBeGreaterThan(0)
expect(sr!.h).toBeGreaterThan(0)
}
})
it('never draws procedural colored boxes even when itemsAtlas is undefined', () => {
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const drawnSprites: { 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]) {
drawnQuads.push({ x, y, w, h, color })
},
draw(frame: any, x: number, y: number, options: any) {
drawnSprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const groundSword: GroundItemEntity = {
id: 'no_atlas_sword',
item: { name: 'Short Sword', code: 'ssd' },
name: 'Short Sword',
nameZh: '短剑',
quality: 'magic',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 3,
dropTime: 0,
x: 300,
y: 250,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, groundSword, 1000, undefined)
expect(drawnSprites.length).toBe(1)
expect(drawnQuads.length).toBe(1) // Only the drop shadow
expect(drawnQuads[0]!.color[0]).toBe(0)
expect(drawnQuads[0]!.color[1]).toBe(0)
expect(drawnQuads[0]!.color[2]).toBe(0)
})
})
})