feat(items): spawn magic/rare/set/unique drops unidentified and implement full 1.13c identification

This commit is contained in:
troytt 2026-09-27 05:38:58 +00:00
parent 7cc56ed967
commit e9c3b41768
11 changed files with 679 additions and 54 deletions

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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<string, SpriteRect> = 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)'

View File

@ -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,

View File

@ -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' })
}
}
}

View File

@ -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)

View File

@ -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)
})
})
})

View File

@ -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!]