import type {Bot} from '@wp2508/mineflayer'; import type {log} from '../types/utils/logger.ts'; import zhCn from '../data/runtime/language/zh_cn.json'; import {MinecraftJsonParser} from './message_json_parser.ts'; export type ItemValue = null | boolean | number | string | ItemValue[] | {[key: string]: ItemValue}; export interface InventoryItem { slot: number | null; type: number; name: string; i18n: {lang: string; name: string; description: string}[]; count: number; metadata: number; displayName: log[]; lore: log[][]; enchants: {id?: number; name?: string; i18n: InventoryItem['i18n']; level: number; hidden: boolean}[]; durability: {used: number; max: number; remaining: number} | null; unbreakable: boolean; glint: boolean | null; book: {title: string; author: string; pages: log[][]} | null; details: Record; truncated: boolean; } const tags = new Set(['compound', 'list', 'string', 'byte', 'short', 'int', 'long', 'float', 'double', 'byteArray', 'intArray', 'longArray']); const record = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; const array = (value: unknown): unknown[] => Array.isArray(value) ? value : []; const number = (value: unknown, fallback = 0): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback; const identifier = (value: unknown): string => typeof value === 'string' ? value.replace(/^minecraft:/, '') : ''; /** Projects both legacy NBT and modern components; never returns raw tag wrappers or live Item objects. */ export function parse_inventory_item(input: unknown, slot: number | null, registry: Bot['registry']): InventoryItem | null { if (!input || typeof input !== 'object') return null; let truncated = false; let nodes = 0; let text_left = 24_576; const ancestors = new Set(); const normalize = (value: unknown, depth = 0): ItemValue => { if (++nodes > 8192 || depth > 32) { truncated = true; return null; } if (value == null) return null; if (typeof value === 'bigint') return value.toString(); if (typeof value === 'string') { let text = ''; for (const character of value) { const bytes = Buffer.byteLength(character); if (bytes > text_left) break; text += character; text_left -= bytes; } if (text !== value) truncated = true; return text; } if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value === 'boolean') return value; if (typeof value !== 'object') { truncated = true; return null; } if (ancestors.has(value)) { truncated = true; return null; } ancestors.add(value); try { if (Array.isArray(value) || Buffer.isBuffer(value)) { if (value.length > 1024) truncated = true; return Array.from(value as unknown[]).slice(0, 1024).map(entry => normalize(entry, depth + 1)); } const descriptors = Object.getOwnPropertyDescriptors(value); const type = descriptors.type?.value; if (typeof type === 'string' && tags.has(type) && descriptors.value && 'value' in descriptors.value && Object.keys(descriptors).every(key => ['type', 'value', 'name'].includes(key))) { return normalize(descriptors.value.value, depth + 1); } const entries = Object.entries(descriptors).filter(([, descriptor]) => descriptor.enumerable); if (entries.length > 256) truncated = true; return Object.fromEntries(entries.slice(0, 256).map(([key, descriptor]) => { if (!('value' in descriptor)) { truncated = true; return [key, null]; } return [key, normalize(descriptor.value, depth + 1)]; })); } finally { ancestors.delete(value); } }; const text = (value: unknown): log[] => { let component = value; if (typeof component === 'string' && /^[\s]*[\[{\"]/.test(component)) { try { component = JSON.parse(component); } catch { /* Plain item names need not be JSON. */ } } const logs = MinecraftJsonParser.toLog(component as Parameters[0], false); const colors = ['#000000', '#0000AA', '#00AA00', '#00AAAA', '#AA0000', '#AA00AA', '#FFAA00', '#AAAAAA', '#555555', '#5555FF', '#55FF55', '#55FFFF', '#FF5555', '#FF55FF', '#FFFF55', '#FFFFFF']; return logs.flatMap(segment => { let color = segment.font_color; let styles = segment.font_style.filter(style => style !== 'normal'); const result: log[] = []; const pieces = String(segment.message).split(/(§[0-9a-fk-or])/gi); for (const piece of pieces) { if (/^§[0-9a-fk-or]$/i.test(piece)) { const code = piece[1].toLowerCase(); const color_index = '0123456789abcdef'.indexOf(code); if (color_index >= 0) { color = colors[color_index]; styles = []; } else if (code === 'r') { color = '#FFFFFF'; styles = []; } else { const style = ({l: 'bold', o: 'italic', n: 'underline'} as const)[code as 'l' | 'o' | 'n']; if (style && !styles.includes(style)) styles = [...styles, style]; } } else if (piece) result.push({message: piece, font_color: color, font_style: styles.length ? [...styles] : ['normal']}); } return result; }); }; const parse = (raw: unknown, item_slot: number | null, nesting: number): InventoryItem | null => { const item = record(raw); const raw_id = item.type ?? item.itemId ?? item.id; const lookup = typeof raw_id === 'string' ? registry?.itemsByName?.[identifier(raw_id)] : registry?.items?.[number(raw_id, -1)]; const type = typeof raw_id === 'number' ? raw_id : lookup?.id; const count = number(item.count ?? item.itemCount ?? item.Count, 1); if (type == null || type < 0 || count <= 0) return null; const legacy = record(normalize(item.nbt ?? item.tag)); const raw_components = new Map(); for (const entry of array(item.components)) { const component = record(entry); const key = identifier(component.type); if (key) raw_components.set(key, component.data); } // Item setters can update componentMap before rebuilding the components array. if (item.componentMap instanceof Map) for (const [key, component] of item.componentMap) { raw_components.set(identifier(key), record(component).data); } const components = Object.fromEntries(Array.from(raw_components, ([key, value]) => [key, normalize(value)])); const display = record(legacy.display); const name = typeof item.name === 'string' ? item.name : lookup?.name ?? (identifier(raw_id) || 'unknown'); let displayName: log[] = []; for (const candidate of [components.custom_name, display.Name, components.item_name, item.displayName, lookup?.displayName, name]) { if (candidate == null) continue; displayName = text(candidate); if (displayName.length) break; } const lore = array(components.lore ?? display.Lore).slice(0, 128).map(text); if (array(components.lore ?? display.Lore).length > 128) truncated = true; const flags = number(legacy.HideFlags); const tooltip = record(components.tooltip_display); const hidden_components = array(tooltip.hiddenComponents); const component_types = registry?.protocol?.types?.SlotComponentType as unknown as [string, {mappings?: Record}] | undefined; const hidden_names = hidden_components.map(value => typeof value === 'number' ? component_types?.[1]?.mappings?.[String(value)] ?? value : identifier(value)); const hidden_tooltip = Object.hasOwn(components, 'hide_tooltip') || tooltip.hideTooltip === true; const enchants: InventoryItem['enchants'] = []; const enchant_sources = [ {source: components.enchantments ?? legacy.Enchantments ?? legacy.ench, hidden: Boolean(flags & 1)}, {source: components.stored_enchantments ?? legacy.StoredEnchantments, hidden: Boolean(flags & 32)}, ]; for (const {source, hidden} of enchant_sources) { const data = record(source); const entries = Array.isArray(source) ? source : Array.isArray(data.enchantments) ? data.enchantments : Object.entries(record(data.levels)).map(([id, level]) => ({id, level})); for (const entry of entries) { const enchant = record(entry); const raw_id = enchant.id ?? enchant.name; const id = typeof raw_id === 'number' ? raw_id : undefined; const name = typeof raw_id === 'string' ? identifier(raw_id) : registry?.enchantments?.[id ?? -1]?.name; enchants.push({...(id === undefined ? {} : {id}), ...(name ? {name} : {}), i18n: [{lang: 'zh-CN', name: (zhCn as Record)[`enchantment.minecraft.${name}`] || name || String(id ?? 'unknown'), description: ''}], level: number(enchant.level ?? enchant.lvl), hidden: hidden_tooltip || hidden || hidden_names.includes('enchantments') || hidden_names.includes('stored_enchantments') || data.showTooltip === false || data.showInTooltip === false}); } } const max = number(components.max_damage ?? item.maxDurability ?? lookup?.maxDurability); let used = number(components.damage ?? legacy.Damage ?? item.metadata); if (!Object.hasOwn(components, 'damage') && legacy.Damage == null && max > 0) { try { used = number(item.durabilityUsed, used); } catch { truncated = true; } } const book_data = record(components.written_book_content ?? components.writable_book_content ?? (['written_book', 'writable_book'].includes(name) ? legacy : null)); const written = name === 'written_book' || Object.hasOwn(components, 'written_book_content'); const book = Array.isArray(book_data.pages) ? { title: String(book_data.rawTitle ?? book_data.title ?? ''), author: String(book_data.author ?? ''), pages: book_data.pages.slice(0, 100).map(page => { const content = record(page).content ?? page; // Writable books contain literal text, even when a page looks like JSON. return written ? text(content) : text({text: String(content ?? '')}); }), } : null; if (array(book_data.pages).length > 100) truncated = true; const details: Record = {}; if (components.potion_contents != null || legacy.Potion != null || legacy.CustomPotionEffects != null) { const potion = record(components.potion_contents); details.potion = { type: (potion.potionId ?? legacy.Potion ?? null) as ItemValue, color: (potion.customColor ?? legacy.CustomPotionColor ?? null) as ItemValue, effects: array(potion.customEffects ?? legacy.CustomPotionEffects).map(entry => { const effect = record(entry); const id = effect.id ?? effect.Id; const data = record(effect.details ?? effect); const name = typeof id === 'string' ? identifier(id) : registry?.effects?.[number(id, -1)]?.name; return {id: id as ItemValue ?? null, name: name ?? null, amplifier: number(data.amplifier ?? data.Amplifier), duration: number(data.duration ?? data.Duration), ambient: Boolean(data.ambient ?? data.Ambient), showParticles: (data.showParticles ?? data.ShowParticles ?? true) as ItemValue}; }), }; } if (components.attribute_modifiers != null || legacy.AttributeModifiers != null) { const modifiers = record(components.attribute_modifiers); details.attributes = {hidden: hidden_tooltip || Boolean(flags & 2) || hidden_names.includes('attribute_modifiers') || modifiers.showTooltip === false, modifiers: array(Array.isArray(components.attribute_modifiers) ? components.attribute_modifiers : modifiers.attributes ?? legacy.AttributeModifiers).map(entry => { const attribute = record(entry); return { type: (attribute.typeId ?? attribute.type ?? attribute.AttributeName ?? null) as ItemValue, name: String(attribute.name ?? attribute.Name ?? attribute.id ?? ''), amount: number(attribute.value ?? attribute.amount ?? attribute.Amount), operation: (attribute.operation ?? attribute.Operation ?? null) as ItemValue, slot: (attribute.slot ?? attribute.Slot ?? null) as ItemValue, hidden: record(attribute.display).type === 'hidden', displayName: text(record(attribute.display).component) as unknown as ItemValue, }; })}; } const nested = (entries: unknown[], preserve_slot: boolean): ItemValue[] => { if (nesting >= 4) { truncated = true; return []; } if (entries.length > 256) truncated = true; return entries.slice(0, 256).map((entry, index) => { const wrapper = record(entry); const raw = wrapper.item ?? entry; return parse(raw, preserve_slot ? number(wrapper.slot ?? wrapper.Slot, index) : null, nesting + 1) as unknown as ItemValue; }); }; const container = components.container ?? record(legacy.BlockEntityTag).Items; if (container != null) details.container = nested(array(record(container).contents ?? container), true); if (components.bundle_contents != null || name === 'bundle' && legacy.Items != null) { const contents = components.bundle_contents ?? legacy.Items; details.bundle = nested(array(record(contents).contents ?? contents), false); } const known_components = new Set(['custom_name', 'item_name', 'lore', 'enchantments', 'stored_enchantments', 'damage', 'max_damage', 'unbreakable', 'enchantment_glint_override', 'written_book_content', 'writable_book_content', 'potion_contents', 'attribute_modifiers', 'container', 'bundle_contents', 'tooltip_display', 'hide_tooltip', 'hide_additional_tooltip']); const known_legacy = new Set(['display', 'Enchantments', 'ench', 'StoredEnchantments', 'Damage', 'Unbreakable', 'HideFlags', 'Potion', 'CustomPotionEffects', 'CustomPotionColor', 'AttributeModifiers']); if (book) for (const key of ['pages', 'title', 'author']) known_legacy.add(key); if (name === 'bundle') known_legacy.add('Items'); const extra_legacy = Object.fromEntries(Object.entries(legacy).filter(([key]) => !known_legacy.has(key))); if (container != null) { const block = {...record(extra_legacy.BlockEntityTag)}; delete block.Items; if (Object.keys(block).length) extra_legacy.BlockEntityTag = block; else delete extra_legacy.BlockEntityTag; } const extra_display = Object.fromEntries(Object.entries(display).filter(([key]) => !['Name', 'Lore'].includes(key))); if (Object.keys(extra_display).length) extra_legacy.display = extra_display; const extra: Record = Object.fromEntries(Object.entries(components).filter(([key]) => !known_components.has(key))); if (Object.keys(extra_legacy).length) extra.legacy = extra_legacy; if (Object.keys(extra).length) details.extra = extra as ItemValue; if (flags || hidden_tooltip || Object.hasOwn(components, 'hide_additional_tooltip') || hidden_components.length) { details.tooltip = {hideAll: hidden_tooltip, hideAdditional: Object.hasOwn(components, 'hide_additional_tooltip'), hiddenComponents: hidden_names as ItemValue[], legacyFlags: flags}; } return { slot: item_slot, type, name, count, metadata: number(item.metadata), displayName, lore, enchants, i18n: [{lang: 'zh-CN', name: (zhCn as Record)[`block.minecraft.${name}`] || (zhCn as Record)[`item.minecraft.${name}`] || name, description: ''}], durability: max > 0 ? {used, max, remaining: Math.max(0, max - used)} : null, unbreakable: Object.hasOwn(components, 'unbreakable') ? components.unbreakable !== false : Boolean(legacy.Unbreakable), glint: typeof components.enchantment_glint_override === 'boolean' ? components.enchantment_glint_override : null, book, details, truncated, }; }; const result = parse(input, slot, 0); if (result) result.truncated = truncated; return result; }