317 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|
|
|