diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 4d646c3..3613148 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -142,10 +142,26 @@ export interface ResolvableItem { readonly height?: number | undefined } +/** + * Returns the canonical, unpadded item code for sprite lookups. + * + * Simulation items carry `code` as the fixed 4-byte .d2s field, space padded + * (`'hp1 '`, `'dr3 '`); UI items carry it unpadded. Both are normalized here, + * falling back to the ItemBase code (`base.id`) when `code` is absent. + */ +export function normalizeItemCode(item: ResolvableItem): string | undefined { + const raw = item.code ?? (item as { base?: { id?: unknown } }).base?.id + if (typeof raw !== 'string') return undefined + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : undefined +} + export function resolveItemSpriteRect( - item: ResolvableItem, + rawItem: ResolvableItem, itemRects: Record = BAKED_UI_MANIFEST.itemRects, ): SpriteRect | null { + const normalizedCode = normalizeItemCode(rawItem) + const item: ResolvableItem = normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode } // Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}` const targetInv = item.invFile || @@ -403,9 +419,13 @@ export function resolveGroundItemSpriteRect( } } - // 4. Base item code lookup in codeToFlippyFile - if (item.code) { - const fromCode = codeToFlippyFile[item.code] ?? codeToFlippyFile[item.code.toLowerCase()] + // 4. Base item code lookup in codeToFlippyFile. + // Simulation `Item.code` is the 4-char space-padded .d2s code (e.g. 'dr3 ', + // D2Common!6FD77180), so it must be trimmed; `base.id` is the unpadded + // ItemBase code and is authoritative when `code` is absent. + const itemCode = normalizeItemCode(item) + if (itemCode) { + const fromCode = codeToFlippyFile[itemCode] ?? codeToFlippyFile[itemCode.toLowerCase()] if (fromCode) { if (item.invtransform) { const dyed = `${fromCode}_${item.invtransform}`.toLowerCase() diff --git a/tests/ground-drop-sprite.test.ts b/tests/ground-drop-sprite.test.ts new file mode 100644 index 0000000..dc97774 --- /dev/null +++ b/tests/ground-drop-sprite.test.ts @@ -0,0 +1,79 @@ +/** + * Regression: monster drops rendered with the wrong ground sprite. + * + * Simulation items produced by executeDropPipeline carry `code` as the 4-byte + * space-padded .d2s field ('dr3 '). The ground resolver looked that up verbatim + * in codeToFlippyFile, missed, and fell through to the inventory-size guess, so + * every 2x2 drop (Antlers 'dr3', caps, ...) rendered as the Horadric Cube + * (invbox). Picking the item up rebuilt it through itemToUiInventoryItem, which + * is why re-dropping it looked correct. + */ +import { describe, it, expect } from 'vitest' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { createDroppedItem, executeDropPipeline } from '../src/game/drop-pipeline.ts' +import { D2Rng } from '../src/game/d2-rng.ts' +import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' +import { normalizeItemCode, resolveGroundItemSpriteRect, resolveItemSpriteRect } from '../src/ui/inventory.ts' +import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts' + +const VARIANT_INV_CODES = new Set(['amu', 'rin', 'jew']) + +describe('monster drop ground sprite resolution', () => { + const tables = getEmbeddedDropTables() + const flippyRects = BAKED_UI_MANIFEST.flippyRects + const codeToFlippy = BAKED_UI_MANIFEST.codeToFlippyFile + + it('normalizes padded .d2s codes and falls back to base.id', () => { + expect(normalizeItemCode({ code: 'dr3 ' })).toBe('dr3') + expect(normalizeItemCode({ code: 'hp1 ' })).toBe('hp1') + expect(normalizeItemCode({ base: { id: 'cap' } } as any)).toBe('cap') + expect(normalizeItemCode({})).toBeUndefined() + }) + + it('renders a pipeline-built Antlers (dr3) with flpdr3, not the Horadric Cube', () => { + const base = tables.getBase('dr3')! + expect(base).toBeDefined() + const item = createDroppedItem(base, 'normal', { ilvl: 5, dwInitSeed: 1 } as any) + expect(item.code).toBe('dr3 ') + const rect = resolveGroundItemSpriteRect(item as any) + expect(rect).toBe(flippyRects[codeToFlippy['dr3']!]) + expect(rect).not.toBe(flippyRects['flpbox']) + expect(rect).not.toBe(BAKED_UI_MANIFEST.itemRects['invbox']) + }) + + it('ground sprite of a raw drop equals the sprite after the pickup→UI round trip', () => { + const rng = new D2Rng(7) + let checked = 0 + for (let i = 0; i < 400; i++) { + for (const drop of executeDropPipeline(tables, { tcName: 'Act 1 Champ A', nLevel: 5, monsterType: 2, monsterRng: rng } as any)) { + if (drop.base.id === 'gold') continue + const raw = resolveGroundItemSpriteRect(drop as any) + const ui = resolveGroundItemSpriteRect(itemToUiInventoryItem(drop, tables) as any) + expect(raw, `${drop.base.id} ${drop.name}`).toBe(ui) + checked++ + } + } + expect(checked).toBeGreaterThan(100) + }) + + it('every droppable weapon/armor/misc base with a baked flippy resolves to it directly', () => { + const missing: string[] = [] + let checked = 0 + for (const [code, flp] of Object.entries(codeToFlippy)) { + const base = tables.getBase(code) + if (!base || !flippyRects[flp]) continue + { + checked++ + const item = createDroppedItem(base, 'normal', { ilvl: 1, dwInitSeed: 1 } as any) + if (resolveGroundItemSpriteRect(item as any) !== flippyRects[flp]) missing.push(code) + const inv = BAKED_UI_MANIFEST.codeToInvFile[code] + // amu/rin/jew pick one of several invfile variants (invamu1..3 etc.) per item seed. + if (!VARIANT_INV_CODES.has(code) && inv && BAKED_UI_MANIFEST.itemRects[inv] && resolveItemSpriteRect(item as any) !== BAKED_UI_MANIFEST.itemRects[inv]) { + missing.push(`inv:${code}`) + } + } + } + expect(checked).toBeGreaterThan(300) + expect(missing).toEqual([]) + }) +})