fix(ground): resolve monster-drop ground sprites from padded .d2s item codes
Pipeline-built Items carry code as the 4-byte space-padded .d2s field
('dr3 '), so the codeToFlippyFile lookup missed and every drop fell through
to the inventory-size guess (2x2 -> invbox, the Horadric Cube). Picking the
item up rebuilt it via itemToUiInventoryItem, which is why re-dropping
looked correct. Normalize the code (trim, fall back to base.id) in both
resolveItemSpriteRect and resolveGroundItemSpriteRect.
Adds tests/ground-drop-sprite.test.ts: raw-drop sprite == post-pickup sprite
for 400 Act 1 Champ A rolls, and every droppable base with a baked flippy
resolves to it directly.
This commit is contained in:
parent
71466df5e7
commit
af2671c14a
|
|
@ -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<string, SpriteRect> = 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()
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue