From e9c3b4176803dd2d9d0ebd27e59bd7b54b2daa3d Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sun, 27 Sep 2026 05:38:58 +0000 Subject: [PATCH] feat(items): spawn magic/rare/set/unique drops unidentified and implement full 1.13c identification --- src/game/drop-pipeline.ts | 20 +- src/game/ground-items.ts | 75 +++- src/game/item-cost.ts | 3 +- src/game/vendor-generator.ts | 2 + src/ui/ground-labels.ts | 34 +- src/ui/inventory.ts | 35 +- src/ui/item-bridge.ts | 7 + src/ui/world-panels.ts | 86 +++-- tests/monster-drop-e2e.test.ts | 10 + tests/unidentified-drops-identify.test.ts | 449 ++++++++++++++++++++++ tests/vendor-panel-ui.test.ts | 12 +- 11 files changed, 679 insertions(+), 54 deletions(-) create mode 100644 tests/unidentified-drops-identify.test.ts diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 79b5fc4..90ef786 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -566,6 +566,21 @@ export function rollItemEtherealAndSockets( return { ethereal, sockets } } +/** + * Per 1.13c D2Game.dll (ITEMS_Create / 0x6FC2FC40): + * - Low Quality (1), Normal (2), Superior (3), and Quest items (quest > 0) spawn identified. + * - Magic (4), Set (5), Rare (6), Unique (7), and Crafted (8) non-quest items spawn unidentified on drop. + */ +export function shouldSpawnIdentifiedByDefault( + base: ItemBase, + qualityTier: ItemQualityTier, +): boolean { + if ((base.quest ?? 0) > 0) { + return true + } + return qualityTier === 'low' || qualityTier === 'normal' || qualityTier === 'superior' +} + /** * Creates a concrete Item instance for a dropped leaf item. */ @@ -587,10 +602,12 @@ export function createDroppedItem( readonly dwInitSeed: number readonly durabilityMultiplier?: number | undefined readonly itemRng?: D2Rng | undefined + readonly identified?: boolean | undefined }, ): Item { const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL - let rawFlags = ItemFlag.IDENTIFIED + const isIdentified = options.identified ?? shouldSpawnIdentifiedByDefault(base, qualityTier) + let rawFlags = isIdentified ? ItemFlag.IDENTIFIED : 0 let name = base.name let prefix = null @@ -894,6 +911,7 @@ export function createDroppedItem( value: base.value, rawFlags, flags: decodeItemFlags(rawFlags), + identified: isIdentified, version: 0x65, code: formatItemCode(base.id), quality, diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index 2864d7a..8d72c78 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -29,7 +29,12 @@ import { rayTraceScene, } from './d2map.ts' import type { CollisionGrid } from './d2map.ts' -import { translateItemName, translateMagicName, translateRareName } from './tooltip-i18n.ts' +import { + resolveBaseItemNameEn, + translateItemName, + translateMagicName, + translateRareName, +} from './tooltip-i18n.ts' import { isRuneCode } from './gems-data.ts' import { getEmbeddedDropTables } from './embedded-drop-tables.ts' @@ -181,6 +186,25 @@ export interface GroundItemMetadata { readonly sockets?: number | undefined } +/** + * Checks whether an item or GroundItemSource is currently unidentified. + */ +export function isGroundSourceUnidentified(item: any): boolean { + if (!item || isGoldItem(item)) return false + if (item.identified === false) return true + if (item.identified === true) return false + if (item.flags && typeof item.flags.identified === 'boolean') { + return item.flags.identified === false + } + if (item.rawItem && item.rawItem !== item) { + return isGroundSourceUnidentified(item.rawItem) + } + if (item.item && item.item !== item) { + return isGroundSourceUnidentified(item.item) + } + return false +} + /** Extract ground item metadata from any item source. */ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata { const isGold = isGoldItem(item) @@ -208,21 +232,15 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): } } - const name = typeof item?.name === 'string' ? item.name : 'Unknown Item' const rawCode = typeof item?.code === 'string' ? item.code.trim().toLowerCase() : typeof item?.base?.id === 'string' ? item.base.id.trim().toLowerCase() - : '' - const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item) - const nameZh = formatBilingualGroundItemName(item, name, quality) - if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) { - try { - item.nameZh = nameZh - } catch { - // ignore if frozen object - } - } + : typeof item?.rawItem?.code === 'string' + ? item.rawItem.code.trim().toLowerCase() + : typeof item?.rawItem?.base?.id === 'string' + ? item.rawItem.base.id.trim().toLowerCase() + : '' let tableBase: any if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) { @@ -236,6 +254,39 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): } } + const rawName = typeof item?.name === 'string' ? item.name : 'Unknown Item' + const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item) + const unidentified = isGroundSourceUnidentified(item) + + let name = rawName + let nameZh: string + if (unidentified) { + const baseObj = item?.base ?? item?.rawItem?.base ?? tableBase + const rawBaseName = + typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0 + ? baseObj.name + : rawName + const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode) + const translatedBase = + translateItemName(rawBaseName) !== rawBaseName + ? translateItemName(rawBaseName) + : translateItemName(baseNameEn) + name = baseNameEn + nameZh = + translatedBase && translatedBase !== baseNameEn + ? `${translatedBase} (${baseNameEn})` + : baseNameEn + } else { + nameZh = formatBilingualGroundItemName(item, name, quality) + if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) { + try { + item.nameZh = nameZh + } catch { + // ignore if frozen object + } + } + } + const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? tableBase?.invWidth ?? 1) const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1) const amount = typeof item?.stack === 'number' ? item.stack : 1 diff --git a/src/game/item-cost.ts b/src/game/item-cost.ts index b3e783a..fcc98a3 100644 --- a/src/game/item-cost.ts +++ b/src/game/item-cost.ts @@ -510,7 +510,8 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe nRepCost = afterSkill.repCost } - const isIdentified = item.flags?.identified !== false && !item.isGambleMystery + const isIdentified = + item.identified !== false && item.flags?.identified !== false && !item.isGambleMystery if (isIdentified) { let nSell = 0 diff --git a/src/game/vendor-generator.ts b/src/game/vendor-generator.ts index 5e24ade..934175c 100644 --- a/src/game/vendor-generator.ts +++ b/src/game/vendor-generator.ts @@ -350,6 +350,7 @@ export function generateVendorStoreItem(options: { ilvl: nItemLevel, dwInitSeed, itemRng, + identified: true, }) // Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense) @@ -779,6 +780,7 @@ export function resolveGambledItem(options: { ilvl, dwInitSeed, itemRng, + identified: true, }) return { diff --git a/src/ui/ground-labels.ts b/src/ui/ground-labels.ts index 1d21054..7d55647 100644 --- a/src/ui/ground-labels.ts +++ b/src/ui/ground-labels.ts @@ -12,8 +12,13 @@ * - Clickable labels to trigger pathfinding and item pickup. */ -import type { GroundItemEntity } from '../game/ground-items.ts' -import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts' +import { isGroundSourceUnidentified, type GroundItemEntity } from '../game/ground-items.ts' +import { + resolveBaseItemNameEn, + translateItemName, + translateRareName, + translateMagicName, +} from '../game/tooltip-i18n.ts' /** * Authentic 1.13c Diablo II quality color codes for ground item labels. @@ -112,6 +117,31 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string { if (item.isGold) { return `${item.amount} 金币` } + const src = item.item as any + if (isGroundSourceUnidentified(src)) { + const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base + const rawCode = + typeof src?.code === 'string' + ? src.code.trim().toLowerCase() + : typeof baseObj?.id === 'string' + ? baseObj.id.trim().toLowerCase() + : '' + const rawBaseName = + typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0 + ? baseObj.name + : item.name + if (rawBaseName) { + const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode) + if (item.nameZh && item.name === baseNameEn) { + return item.nameZh + } + const translatedBase = + translateItemName(rawBaseName) !== rawBaseName + ? translateItemName(rawBaseName) + : translateItemName(baseNameEn) + return translatedBase || baseNameEn + } + } if (item.nameZh) { return item.nameZh } diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 671d5ae..47a72e4 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -39,6 +39,7 @@ export interface UiInventoryItem { readonly id: string readonly code: string readonly invFile: string + readonly baseInvFile?: string | undefined readonly name: string readonly nameZh: string readonly baseNameZh: string @@ -146,6 +147,7 @@ export interface ResolvableItem { readonly nameZh?: string | undefined readonly baseNameZh?: string | undefined readonly invFile?: string | undefined + readonly baseInvFile?: string | undefined readonly flippyFile?: string | undefined readonly isGold?: boolean | undefined readonly amount?: number | undefined @@ -158,6 +160,9 @@ export interface ResolvableItem { readonly invHeight?: number | undefined readonly width?: number | undefined readonly height?: number | undefined + readonly identified?: boolean | undefined + readonly isGambleMystery?: boolean | undefined + readonly rawItem?: Item | undefined } /** @@ -179,7 +184,28 @@ export function resolveItemSpriteRect( itemRects: Record = BAKED_UI_MANIFEST.itemRects, ): SpriteRect | null { const normalizedCode = normalizeItemCode(rawItem) - const item: ResolvableItem = normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode } + const normalizedItem: ResolvableItem = + normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode } + const isUnid = Boolean( + normalizedItem.identified === false || + normalizedItem.isGambleMystery === true || + normalizedItem.rawItem?.identified === false || + normalizedItem.rawItem?.isGambleMystery === true || + (normalizedItem.rawItem?.flags !== undefined && normalizedItem.rawItem.flags.identified === false), + ) + // Per D2Client.dll, unidentified items render their base item graphic without unique/set invfile or dye transform + const item: ResolvableItem = isUnid + ? { + ...normalizedItem, + invFile: + normalizedItem.baseInvFile ?? + (normalizedItem.code && BAKED_UI_MANIFEST.codeToInvFile[normalizedItem.code] + ? BAKED_UI_MANIFEST.codeToInvFile[normalizedItem.code] + : normalizedItem.invFile), + invtransform: undefined, + name: normalizedItem.rawItem?.base?.name ?? undefined, + } + : normalizedItem // Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}` const targetInv = item.invFile || @@ -1056,6 +1082,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem { } const rebuilt = itemToUiInventoryItem(item.rawItem) Object.assign(item, { + invFile: rebuilt.invFile, + ...(rebuilt.baseInvFile !== undefined ? { baseInvFile: rebuilt.baseInvFile } : {}), name: rebuilt.name, nameZh: rebuilt.nameZh, baseNameZh: rebuilt.baseNameZh, @@ -1645,8 +1673,9 @@ export class InventoryPanel { const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx - ctx.fillStyle = - placed.item.quality === 'unique' + ctx.fillStyle = isUiItemUnidentified(placed.item) + ? 'rgba(72, 18, 18, 0.50)' + : placed.item.quality === 'unique' ? 'rgba(58, 42, 16, 0.45)' : placed.item.quality === 'set' ? 'rgba(16, 52, 22, 0.45)' diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index ec9ae89..2e2f217 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -383,6 +383,12 @@ export function itemToUiInventoryItem( // 3. invFile & Dye Resolution const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables) + const { invFile: baseInvFile } = resolveItemInvFile( + { ...rawItem, name: baseEn, uniqueItemDef: undefined, setItemDef: undefined } as Item, + 'normal', + code, + dropTables, + ) // 4. Allowed Equipment Slots const allowedSlots = resolveAllowedSlots( @@ -494,6 +500,7 @@ export function itemToUiInventoryItem( id, code, invFile, + baseInvFile, name: rawItem.name || baseEn, nameZh, baseNameZh, diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 8b42ecd..1f04f92 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -2420,12 +2420,19 @@ export class WorldPanelsHud { : 'white' // Header: Title, Runeword Runes line, Base item name - lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' }) - if (item.runewordRunes) { - lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' }) - } - if (item.baseNameZh) { - lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' }) + const unid = isUiItemUnidentified(item) + if (unid) { + const maskedBaseHeader = + (item.baseNameZh ? item.baseNameZh.split(' · ')[0] : undefined) || item.nameZh + lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' }) + } else { + lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' }) + if (item.runewordRunes) { + lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' }) + } + if (item.baseNameZh) { + lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' }) + } } // Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) @@ -2467,33 +2474,52 @@ export class WorldPanelsHud { if (item.reqStr !== undefined) { lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' }) } - if (item.reqLevel !== undefined) { - lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' }) + const displayReqLevel = unid + ? item.rawItem?.base?.level && item.rawItem.base.level > 1 + ? item.rawItem.base.level + : undefined + : item.reqLevel + if (displayReqLevel !== undefined) { + lines.push({ text: '需要等级: ' + displayReqLevel, color: 'white', font: 'font8' }) } - // Magic Affixes - for (const st of item.stats) { - lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' }) - } - - // Sockets Block - if ( - item.sockets !== undefined && - item.sockets > 0 && - !item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed')) - ) { - lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' }) - } - - // Set Pieces & Set Bonuses Block - if (item.setPieces) { - for (const piece of item.setPieces) { - lines.push({ text: piece, color: 'gray', font: 'font8' }) + if (unid) { + if (item.isGambleMystery) { + lines.push({ text: '未鉴定 (Unidentified Gamble Item)', color: 'red', font: 'font8' }) + } else { + lines.push({ text: '未辨识 (Unidentified)', color: 'red', font: 'font8' }) + if (item.allowedSlots.length > 0) { + lines.push({ text: '无法使用 (Cannot be Equipped)', color: 'red', font: 'font8' }) + } + if (item.ethereal) { + lines.push({ text: '无形的 (无法修复) (Ethereal)', color: 'blue', font: 'font8' }) + } } - } - if (item.setBonuses) { - for (const bonus of item.setBonuses) { - lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' }) + } else { + // Magic Affixes + for (const st of item.stats) { + lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' }) + } + + // Sockets Block + if ( + item.sockets !== undefined && + item.sockets > 0 && + !item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed')) + ) { + lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' }) + } + + // Set Pieces & Set Bonuses Block + if (item.setPieces) { + for (const piece of item.setPieces) { + lines.push({ text: piece, color: 'gray', font: 'font8' }) + } + } + if (item.setBonuses) { + for (const bonus of item.setBonuses) { + lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' }) + } } } diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index 8600987..6f819a0 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -35,6 +35,8 @@ import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts' import { InventoryPanel, resolveItemSpriteRect, + identifyUiItem, + isUiItemUnidentified, PLAYER_GOLD_CAP, type UiInventoryItem, type EquipSlotId, @@ -575,6 +577,14 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S expect(invPanel.equipped[invalidSlot]).toBeUndefined() expect(invPanel.cursorItem).toBe(placed!.item) + // Unidentified magic/rare/set/unique drops cannot be equipped until identified (D2 1.13c) + if (isUiItemUnidentified(placed!.item)) { + const unidEquip = invPanel.clickEquipSlot(equippableSlot!) + expect(unidEquip).toBe(false) + expect(invPanel.equipped[equippableSlot!]).toBeUndefined() + identifyUiItem(placed!.item) + } + // Equip into valid matching slot const validEquip = invPanel.clickEquipSlot(equippableSlot!) expect(validEquip).toBe(true) diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts new file mode 100644 index 0000000..cd31832 --- /dev/null +++ b/tests/unidentified-drops-identify.test.ts @@ -0,0 +1,449 @@ +import { describe, expect, it, vi } from 'vitest' +import { D2Rng } from '../src/game/d2-rng.ts' +import { + createDroppedItem, + shouldSpawnIdentifiedByDefault, +} from '../src/game/drop-pipeline.ts' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { + GroundItemManager, + isGroundSourceUnidentified, + resolveGroundItemMetadata, +} from '../src/game/ground-items.ts' +import { + calculateTransactionCost, + TransactionType, +} from '../src/game/item-cost.ts' +import { ItemFlag, ItemQuality, type Item } from '../src/game/items.ts' +import { VendorNpcId } from '../src/game/npc-table.ts' +import { + generateGambleInventory, + generateVendorStoreItem, + resolveGambledItem, +} from '../src/game/vendor-generator.ts' +import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' +import type { D2ColorCode, D2FontName, D2FontRenderer } from '../src/ui/font.ts' +import { + formatGroundItemLabelText, + getGroundItemQualityColor, + GROUND_LABEL_QUALITY_COLORS, +} from '../src/ui/ground-labels.ts' +import { + EQUIP_SLOTS_LAYOUT, + identifyUiItem, + INV_GRID_ORIGIN, + InventoryPanel, + isUiItemUnidentified, + itemToUiInventoryItem, + resolveItemSpriteRect, + type UiInventoryItem, +} from '../src/ui/inventory.ts' +import { WorldPanelsHud } from '../src/ui/world-panels.ts' + +function createMockTooltipCanvas() { + const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = [] + const fillStyles: string[] = [] + const ctx = { + save: vi.fn(), + restore: vi.fn(), + drawImage: vi.fn(), + fillRect: vi.fn(function (this: { fillStyle: string }) { + fillStyles.push(this.fillStyle) + }), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + } as unknown as CanvasRenderingContext2D + + const font = { + loaded: true, + measureText: vi.fn((text: string) => text.length * 8), + drawText: vi.fn( + ( + _ctx: CanvasRenderingContext2D, + text: string, + _x: number, + _y: number, + opts?: { font?: D2FontName; color?: D2ColorCode }, + ) => { + fontCalls.push({ text, color: opts?.color, font: opts?.font }) + }, + ), + } as unknown as D2FontRenderer + + return { ctx, font, fontCalls, fillStyles } +} + +describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System', () => { + const dropTables = getEmbeddedDropTables() + const shakoBase = dropTables.getBase('uap')! + const harlequinDef = dropTables.uniques.getEnabledByCode('uap')[0]! + + function makeDroppedShako(qualityTier: 'normal' | 'magic' | 'rare' | 'unique', seed = 101): Item { + const rng = new D2Rng(seed) + return createDroppedItem(shakoBase, qualityTier, { + uniqueItem: qualityTier === 'unique' ? harlequinDef : undefined, + itemTypes: dropTables.itemTypes, + ilvl: 85, + dwInitSeed: seed, + itemRng: rng, + }) + } + + describe('1. Drop Pipeline Unidentified Spawn Rules (D2Game.dll / Items.cpp)', () => { + it('spawns low, normal, superior, and quest items as identified, and magic/rare/set/unique/crafted items as unidentified', () => { + expect(shakoBase).toBeDefined() + expect(harlequinDef).toBeDefined() + + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'low')).toBe(true) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'normal')).toBe(true) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'superior')).toBe(true) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'magic')).toBe(false) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'rare')).toBe(false) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'set')).toBe(false) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'unique')).toBe(false) + expect(shouldSpawnIdentifiedByDefault(shakoBase, 'crafted' as any)).toBe(false) + + // Quest items (base.quest > 0) always spawn identified regardless of quality tier + const cubeBase = dropTables.getBase('box')! + expect(cubeBase.quest).toBeGreaterThan(0) + expect(shouldSpawnIdentifiedByDefault(cubeBase, 'unique')).toBe(true) + + // Verify concrete createDroppedItem outputs + const normalItem = makeDroppedShako('normal', 1) + expect(normalItem.identified).toBe(true) + expect(normalItem.flags?.identified).toBe(true) + expect((normalItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true) + + const magicItem = makeDroppedShako('magic', 2) + expect(magicItem.identified).toBe(false) + expect(magicItem.flags?.identified).toBe(false) + expect((magicItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false) + + const rareItem = makeDroppedShako('rare', 3) + expect(rareItem.identified).toBe(false) + expect(rareItem.flags?.identified).toBe(false) + expect((rareItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false) + + const uniqueShako = makeDroppedShako('unique', 4) + expect(uniqueShako.quality).toBe(ItemQuality.UNIQUE) + expect(uniqueShako.name).toBe('Harlequin Crest') + expect(uniqueShako.identified).toBe(false) + expect(uniqueShako.flags?.identified).toBe(false) + expect((uniqueShako.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false) + }) + }) + + describe('2. Vendor Store, Gambling Reveal & Unidentified Transaction Cost', () => { + it('spawns vendor trade items and resolved gamble items as pre-identified', () => { + const rng = new D2Rng(42) + const capBase = dropTables.getBase('cap')! + const vendorMagic = generateVendorStoreItem({ + base: capBase, + vendorId: VendorNpcId.CHARSI, + qualityTier: 'magic', + cLvl: 25, + nItemLevel: 30, + difficulty: 0, + permStore: false, + tables: dropTables, + rng, + }) + expect(vendorMagic).not.toBeNull() + expect(vendorMagic!.identified).toBe(true) + expect(vendorMagic!.flags?.identified).toBe(true) + expect((vendorMagic!.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true) + + const gambleGrid = generateGambleInventory({ + vendorId: VendorNpcId.GHEED, + cLvl: 30, + tables: dropTables, + rng, + }) + const mystery = gambleGrid.list()[0]!.item + expect(mystery.isGambleMystery).toBe(true) + expect(mystery.identified).toBe(false) + + const gambled = resolveGambledItem({ + mysteryItem: mystery, + cLvl: 30, + tables: dropTables, + rng, + }) + expect(gambled.identified).toBe(true) + expect(gambled.flags?.identified).toBe(true) + expect(gambled.isGambleMystery).toBe(false) + }) + + it('discounts sell price of unidentified magic/unique items until identified (0x6FD9B1C0)', () => { + const uniqueShako = makeDroppedShako('unique', 7) + expect(uniqueShako.identified).toBe(false) + + const unidSellCost = calculateTransactionCost({ + item: uniqueShako, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.CHARSI, + difficulty: 2, + tables: dropTables, + }) + const idShako: Item = { + ...uniqueShako, + identified: true, + flags: { ...uniqueShako.flags!, identified: true }, + rawFlags: uniqueShako.rawFlags! | ItemFlag.IDENTIFIED, + } + const idSellCost = calculateTransactionCost({ + item: idShako, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.CHARSI, + difficulty: 2, + tables: dropTables, + }) + + expect(unidSellCost).toBeGreaterThan(0) + expect(idSellCost).toBeGreaterThan(unidSellCost) + }) + }) + + describe('3. Ground Item Metadata & Alt Label Masking', () => { + it('masks unidentified ground item labels to base item name while preserving quality color and underlying secret name', () => { + const uniqueShako = makeDroppedShako('unique', 11) + expect(isGroundSourceUnidentified(uniqueShako)).toBe(true) + + const meta = resolveGroundItemMetadata(uniqueShako) + expect(meta.quality).toBe('unique') + expect(meta.name).toBe('Shako') + expect(meta.nameZh).toContain('军帽') + expect(meta.nameZh).not.toContain('Harlequin Crest') + expect(meta.nameZh).not.toContain('谐角之冠') + + const manager = new GroundItemManager() + const groundEntity = manager.add(uniqueShako, 10, 10, 320, 240) + expect(getGroundItemQualityColor(groundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.unique) + + const labelText = formatGroundItemLabelText(groundEntity) + expect(labelText).toContain('军帽') + expect(labelText).not.toContain('Harlequin Crest') + expect(labelText).not.toContain('谐角之冠') + + // Underlying item's secret identity is untouched so picking it up and identifying it works + expect(uniqueShako.name).toBe('Harlequin Crest') + }) + }) + + describe('4. UI Item Bridge, Sprite/Dye Masking, Tooltip Masking & Equipping Gate', () => { + it('masks unique dye transform and custom unique invFile while unidentified and restores them upon identification', () => { + const uniqueShako = makeDroppedShako('unique', 19) + const uiShako = itemToUiInventoryItem(uniqueShako, dropTables) + + expect(isUiItemUnidentified(uiShako)).toBe(true) + expect(uiShako.invtransform).toBeTruthy() + expect(uiShako.baseInvFile).toBe('invcap') + + const unidSprite = resolveItemSpriteRect(uiShako) + const baseCapSprite = BAKED_UI_MANIFEST.itemRects['invcap'] + const dyedCapSprite = BAKED_UI_MANIFEST.itemRects[`invcap_${uiShako.invtransform}`] + expect(unidSprite).toEqual(baseCapSprite) + if (dyedCapSprite) { + expect(unidSprite).not.toEqual(dyedCapSprite) + } + + // Identify and verify dyed sprite is now resolved + identifyUiItem(uiShako) + expect(isUiItemUnidentified(uiShako)).toBe(false) + if (dyedCapSprite) { + expect(resolveItemSpriteRect(uiShako)).toEqual(dyedCapSprite) + } + }) + + it('renders red cell backdrop in InventoryPanel.draw and hides secret name/affixes in drawItemTooltip while unidentified', () => { + const uniqueShako = makeDroppedShako('unique', 23) + const uiShako = itemToUiInventoryItem(uniqueShako, dropTables) + + const inv = new InventoryPanel() + inv.visible = true + inv.gridItems = [{ item: uiShako, col: 0, row: 0 }] + + const { ctx, font, fontCalls, fillStyles } = createMockTooltipCanvas() + inv.draw( + ctx, + { + borderRightImg: null, + invSheetImg: null, + invTab0Img: null, + invTab1Img: null, + buySellBtnImg: null, + itemsAtlasImg: null, + itemRects: BAKED_UI_MANIFEST.itemRects, + }, + font, + ) + expect(fillStyles).toContain('rgba(72, 18, 18, 0.50)') + + // Hover tooltip while unidentified + const wp = new WorldPanelsHud() + fontCalls.length = 0 + wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false) + + const texts = fontCalls.map(c => c.text) + expect(texts[0]).toContain('军帽') + expect(texts.some(t => t.includes('Harlequin Crest') || t.includes('谐角之冠'))).toBe(false) + expect(texts).toContain('未辨识 (Unidentified)') + expect(texts).toContain('无法使用 (Cannot be Equipped)') + + // Hover tooltip after identification + identifyUiItem(uiShako) + fontCalls.length = 0 + wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false) + const identifiedTexts = fontCalls.map(c => c.text) + expect(identifiedTexts[0]).toContain('Harlequin Crest') + expect(identifiedTexts).not.toContain('未辨识 (Unidentified)') + expect(identifiedTexts).not.toContain('无法使用 (Cannot be Equipped)') + expect(identifiedTexts.length).toBeGreaterThan(texts.length) + }) + }) + + describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => { + it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => { + const inv = new InventoryPanel() + inv.visible = true + delete inv.equipped.helm + + const uniqueShako = itemToUiInventoryItem(makeDroppedShako('unique', 31), dropTables) + const iscBase = dropTables.getBase('isc')! + const scrollItem = itemToUiInventoryItem( + createDroppedItem(iscBase, 'normal', { + itemTypes: dropTables.itemTypes, + ilvl: 1, + dwInitSeed: 32, + itemRng: new D2Rng(32), + }), + dropTables, + ) + + inv.gridItems = [ + { item: uniqueShako, col: 0, row: 0 }, + { item: scrollItem, col: 2, row: 0 }, + ] + + // Right-click Scroll of Identify at (2, 0) via handleClick(button=2) to enter Identify Cursor Mode + const scrollX = INV_GRID_ORIGIN.x + 2 * INV_GRID_ORIGIN.cellPx + 5 + const scrollY = INV_GRID_ORIGIN.y + 5 + expect(inv.handleClick(scrollX, scrollY, undefined, 2)).toBe(true) + expect(inv.isIdentifyMode()).toBe(true) + + // Left-click the unidentified Shako at (0, 0) via handleClick(button=0) to identify it + const shakoX = INV_GRID_ORIGIN.x + 5 + const shakoY = INV_GRID_ORIGIN.y + 5 + expect(inv.handleClick(shakoX, shakoY, undefined, 0)).toBe(true) + expect(inv.isIdentifyMode()).toBe(false) + expect(isUiItemUnidentified(uniqueShako)).toBe(false) + expect((uniqueShako.rawItem!.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true) + + // Scroll was consumed and removed from inventory + expect(inv.gridItems.some(p => p.item.id === scrollItem.id)).toBe(false) + + // Now pick up the identified Shako and equip it into the helm slot + expect(inv.clickGridCell(0, 0)).toBe(true) + expect(inv.cursorItem?.id).toBe(uniqueShako.id) + expect(inv.clickEquipSlot('helm')).toBe(true) + expect((inv.equipped.helm as UiInventoryItem | undefined)?.id).toBe(uniqueShako.id) + expect(inv.cursorItem).toBeNull() + }) + + it('identifies an item using a Tome of Identify (ibk) and decrements tome quantity', () => { + const inv = new InventoryPanel() + inv.visible = true + const rinBase = dropTables.getBase('rin')! + const ibkBase = dropTables.getBase('ibk')! + + const rareRing = itemToUiInventoryItem( + createDroppedItem(rinBase, 'rare', { + itemTypes: dropTables.itemTypes, + ilvl: 60, + dwInitSeed: 41, + itemRng: new D2Rng(41), + }), + dropTables, + ) + const tomeItem = itemToUiInventoryItem( + { + ...createDroppedItem(ibkBase, 'normal', { + itemTypes: dropTables.itemTypes, + ilvl: 1, + dwInitSeed: 42, + itemRng: new D2Rng(42), + }), + quantity: 5, + stack: 5, + }, + dropTables, + ) + + inv.gridItems = [ + { item: rareRing, col: 0, row: 0 }, + { item: tomeItem, col: 1, row: 0 }, + ] + + const tomeX = INV_GRID_ORIGIN.x + 1 * INV_GRID_ORIGIN.cellPx + 5 + const tomeY = INV_GRID_ORIGIN.y + 5 + expect(inv.handleClick(tomeX, tomeY, undefined, 2)).toBe(true) + expect(inv.isIdentifyMode()).toBe(true) + + const ringX = INV_GRID_ORIGIN.x + 5 + const ringY = INV_GRID_ORIGIN.y + 5 + expect(inv.handleClick(ringX, ringY, undefined, 0)).toBe(true) + expect(isUiItemUnidentified(rareRing)).toBe(false) + expect(tomeItem.quantity).toBe(4) + expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true) + }) + + it('identifies all unidentified items across Inventory, Stash, and Horadric Cube via Deckard Cain', () => { + const inv = new InventoryPanel() + const wp = new WorldPanelsHud() + const rinBase = dropTables.getBase('rin')! + const amuBase = dropTables.getBase('amu')! + + const bagUnid = itemToUiInventoryItem(makeDroppedShako('unique', 51), dropTables) + const stashUnid = itemToUiInventoryItem( + createDroppedItem(rinBase, 'rare', { + itemTypes: dropTables.itemTypes, + ilvl: 60, + dwInitSeed: 52, + itemRng: new D2Rng(52), + }), + dropTables, + ) + const cubeUnid = itemToUiInventoryItem( + createDroppedItem(amuBase, 'magic', { + itemTypes: dropTables.itemTypes, + ilvl: 50, + dwInitSeed: 53, + itemRng: new D2Rng(53), + }), + dropTables, + ) + + inv.gridItems = [{ item: bagUnid, col: 0, row: 0 }] + wp.stashItems = [{ item: stashUnid, col: 0, row: 0 }] + wp.cubeItems = [{ item: cubeUnid, col: 0, row: 0 }] + + expect(isUiItemUnidentified(bagUnid)).toBe(true) + expect(isUiItemUnidentified(stashUnid)).toBe(true) + expect(isUiItemUnidentified(cubeUnid)).toBe(true) + + const count = wp.identifyAllUiItems(inv) + expect(count).toBe(3) + expect(isUiItemUnidentified(bagUnid)).toBe(false) + expect(isUiItemUnidentified(stashUnid)).toBe(false) + expect(isUiItemUnidentified(cubeUnid)).toBe(false) + expect(wp.identifyAllUiItems(inv)).toBe(0) + }) + }) +}) diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts index 9be4363..d020f1d 100644 --- a/tests/vendor-panel-ui.test.ts +++ b/tests/vendor-panel-ui.test.ts @@ -10,7 +10,7 @@ import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' -import type { D2ColorCode, D2FontRenderer, FontSizeName } from '../src/ui/font.ts' +import type { D2ColorCode, D2FontName as FontSizeName, D2FontRenderer } from '../src/ui/font.ts' import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts' import { BLACKSMITH_CLASS_IDS, @@ -364,17 +364,19 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const helm: UiInventoryItem = { id: 'damaged-cap', code: 'cap', - nameEn: 'Cap', + name: 'Cap', nameZh: '帽子', + baseNameZh: '帽子', quality: 'normal', invFile: 'invcap', invWidth: 2, invHeight: 2, - equipSlots: ['head'], + allowedSlots: ['helm'], + stats: [], identified: true, durability: { current: 4, max: 12 }, } - inv.equipped.head = helm + inv.equipped.helm = helm const expectedCost = wp.getRepairAllCost(inv) expect(expectedCost).toBeGreaterThan(0) @@ -449,7 +451,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { expect(wp.activeVendorTab).toBe('armor') // Leave only 1 non-permStore item on the armor tab and buy it - const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.rawItem?.permStoreItem) + const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore) expect(nonPerm).toBeDefined() wp.vendorTabPlacements.armor = [nonPerm!]