267 lines
17 KiB
TypeScript
267 lines
17 KiB
TypeScript
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<string, ItemValue>;
|
|
truncated: boolean;
|
|
}
|
|
|
|
const tags = new Set(['compound', 'list', 'string', 'byte', 'short', 'int', 'long', 'float', 'double', 'byteArray', 'intArray', 'longArray']);
|
|
const record = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown> : {};
|
|
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<object>();
|
|
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<typeof MinecraftJsonParser.toLog>[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<string, unknown>();
|
|
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<string, string>}] | 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<string, string>)[`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<string, ItemValue> = {};
|
|
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<string, unknown> = 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<string, string>)[`block.minecraft.${name}`]
|
|
|| (zhCn as Record<string, string>)[`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;
|
|
}
|