import zhCn from '../data/runtime/language/zh_cn.json'; import type {font_style, log} from '../types/utils/logger'; type TextComponent = MinecraftTextComponent | NbtTag | string | number | boolean | TextComponent[]; interface NbtTag { type: string; value: unknown; } type Format = 'bold' | 'italic' | 'underlined' | 'strikethrough' | 'obfuscated'; const formats: Format[] = ['bold', 'italic', 'underlined', 'strikethrough', 'obfuscated']; const maxDepth = 64; interface MinecraftTextComponent { text?: string; translate?: string; fallback?: string; with?: TextComponent[]; color?: string; bold?: boolean | number; italic?: boolean | number; underlined?: boolean | number; strikethrough?: boolean | number; obfuscated?: boolean | number; extra?: TextComponent[]; hoverEvent?: HoverEvent; hover_event?: HoverEvent; clickEvent?: ClickEvent; click_event?: ClickEvent; insertion?: string; font?: string; json?: TextComponent; warn?: Function; } interface HoverEvent { action: 'show_text' | 'show_item' | 'show_entity'; contents?: TextComponent | Record; value?: TextComponent | Record; } interface ClickEvent { action: 'open_url' | 'open_file' | 'run_command' | 'suggest_command' | 'change_page' | 'copy_to_clipboard' | 'show_dialog' | 'custom'; value?: string; command?: string; url?: string; page?: string | number; path?: string; dialog?: string; id?: string; payload?: string; text?: string; } interface ParsedMessage { username: string; message: string; log: log[]; normalized: MinecraftTextComponent | null; plainText: string; } const colorMap: Record = { black: '#000000', dark_blue: '#0000AA', dark_green: '#00AA00', dark_aqua: '#00AAAA', dark_red: '#AA0000', dark_purple: '#AA00AA', gold: '#FFAA00', gray: '#AAAAAA', dark_gray: '#555555', blue: '#5555FF', green: '#55FF55', aqua: '#55FFFF', red: '#FF5555', light_purple: '#FF55FF', yellow: '#FFFF55', white: '#FFFFFF' }; /** Minecraft / prismarine-chat 文本组件解析器。 */ export class MinecraftJsonParser { static parse(jsonData: TextComponent | null | undefined): ParsedMessage { const normalized = this.normalizeJson(jsonData); const entries = this.toLog(normalized); const plainText = entries.map(entry => entry.message).join(''); return { ...this.parseUsernameAndMessage(plainText), plainText, log: entries, normalized }; } static normalizeJson(node: TextComponent | null | undefined, depth = 0): MinecraftTextComponent | null { if (node == null || depth > maxDepth) return null; if (typeof node !== 'object') return {text: String(node)}; if (!Array.isArray(node) && 'type' in node && 'value' in node) { const unwrap = (tag: unknown, level: number): unknown => { if (level > maxDepth || tag == null) return null; if (Array.isArray(tag)) return tag.map(value => unwrap(value, level + 1)); if (typeof tag !== 'object') return tag; const value = tag as NbtTag; if (value.type === 'compound' && value.value && typeof value.value === 'object') { return Object.fromEntries(Object.entries(value.value).map(([key, child]) => [key, unwrap(child, level + 1)])); } if (value.type === 'list' && value.value && typeof value.value === 'object') { const list = value.value as {type: string; value: unknown[]}; return Array.isArray(list.value) ? list.value.map(child => unwrap({type: list.type, value: child}, level + 1)) : []; } if ('value' in value) return value.value; return tag; }; return this.normalizeJson(unwrap(node, depth) as TextComponent, depth + 1); } if (Array.isArray(node)) { // Minecraft 数组中的后续组件继承第一个组件的样式。 const [first, ...rest] = node; const root = this.normalizeJson(first, depth + 1); if (!root) return null; const extra = rest.map(child => this.normalizeJson(child, depth + 1)) .filter((child): child is MinecraftTextComponent => child !== null); if (extra.length) root.extra = [...(root.extra ?? []), ...extra]; return root; } // ChatMessage 的原始协议组件保留了 font、事件和完整的 Unicode 文本。 if (node.json != null && typeof node.warn === 'function') { return this.normalizeJson(node.json, depth + 1); } const result: MinecraftTextComponent = {}; if (node.text !== undefined) result.text = String(node.text ?? ''); if (node.translate !== undefined) result.translate = node.translate; if (node.fallback !== undefined) result.fallback = node.fallback; if (node.color) result.color = node.color; if (node.font) result.font = node.font; for (const format of formats) { // false / 0 必须保留,用于关闭父组件继承下来的样式。 if (node[format] !== undefined) result[format] = !!node[format]; } const hoverEvent = this.extractHoverEvent(node); if (hoverEvent) result.hoverEvent = hoverEvent; const clickEvent = this.extractClickEvent(node); if (clickEvent) result.clickEvent = clickEvent; const insertion = node.insertion ?? (node.json as MinecraftTextComponent)?.insertion; if (insertion !== undefined) result.insertion = insertion; if (Array.isArray(node.with)) { result.with = node.with.map(child => this.normalizeJson(child, depth + 1) ?? {text: ''}); } if (Array.isArray(node.extra)) { result.extra = node.extra.map(child => this.normalizeJson(child, depth + 1)) .filter((child): child is MinecraftTextComponent => child !== null); } return Object.keys(result).length ? result : null; } static extractHoverEvent(node: MinecraftTextComponent): HoverEvent | undefined { const raw = node.json as MinecraftTextComponent | undefined; return node.hoverEvent ?? node.hover_event ?? raw?.hoverEvent ?? raw?.hover_event; } static extractClickEvent(node: MinecraftTextComponent): ClickEvent | undefined { const raw = node.json as MinecraftTextComponent | undefined; const event = node.clickEvent ?? node.click_event ?? raw?.clickEvent ?? raw?.click_event; if (!event || typeof event.action !== 'string') return undefined; const value = event.value ?? (() => { switch (event.action) { case 'open_url': return event.url; case 'run_command': case 'suggest_command': return event.command; case 'change_page': return event.page; case 'open_file': return event.path; case 'show_dialog': return event.dialog; case 'custom': return event.payload ?? event.id; case 'copy_to_clipboard': return event.text; default: return undefined; } })(); if (typeof value === 'string') return {action: event.action, value}; if (typeof value === 'number' && Number.isFinite(value)) return {action: event.action, value: String(value)}; return undefined; } static expandTranslate(node: MinecraftTextComponent): MinecraftTextComponent | null { if (node.translate === undefined) return null; const template = (zhCn as Record)[node.translate] ?? node.fallback ?? node.translate; const params = node.with ?? []; const extra: TextComponent[] = []; let lastIndex = 0; let seqIndex = 0; for (const match of template.matchAll(/%%|%(?:(\d+)\$)?s/g)) { if (match.index > lastIndex) extra.push({text: template.slice(lastIndex, match.index)}); if (match[0] === '%%') { extra.push({text: '%'}); } else { const index = match[1] ? Number(match[1]) - 1 : seqIndex++; extra.push(params[index] ?? {text: match[0]}); } lastIndex = match.index + match[0].length; } if (lastIndex < template.length) extra.push({text: template.slice(lastIndex)}); // 在同一父组件下展开,保留事件、样式继承以及翻译文本后的 extra。 const {translate, fallback, with: withParams, ...base} = node; return {...base, text: '', extra: [...extra, ...(node.extra ?? [])]}; } static extractPlainText(node: TextComponent | null | undefined): string { const visit = (component: MinecraftTextComponent | null, depth: number): string => { if (!component || depth > maxDepth) return ''; const current = component.translate !== undefined ? this.expandTranslate(component)! : component; return (current.text ?? '') + (current.extra ?? []) .map(child => visit(child as MinecraftTextComponent, depth + 1)).join(''); }; return visit(this.normalizeJson(node), 0); } static parseUsernameAndMessage(plainText: string): {username: string; message: string} { const text = plainText.trim(); const vanilla = text.match(/^<([^>]+)>\s*([\s\S]*)$/); if (vanilla) return {username: vanilla[1], message: vanilla[2]}; const arrowIndex = text.indexOf('»'); if (arrowIndex === -1) return {username: '', message: text}; const beforeArrow = text.slice(0, arrowIndex).trim(); const username = beforeArrow.match(/(\S+)\s*$/)?.[1] ?? ''; return {username, message: text.slice(arrowIndex + 1).trim()}; } /** 返回可直接传给 Logger.log() 的日志片段。 */ static toLog(node: TextComponent | null | undefined, includeEvents = true): log[] { const result: log[] = []; const visit = (component: MinecraftTextComponent | null, inherited: MinecraftTextComponent, depth: number): void => { if (!component || depth > maxDepth) return; const current = component.translate !== undefined ? this.expandTranslate(component)! : component; const style: MinecraftTextComponent = { color: current.color ?? inherited.color, clickEvent: current.clickEvent ?? inherited.clickEvent, hoverEvent: current.hoverEvent ?? inherited.hoverEvent, insertion: current.insertion ?? inherited.insertion }; for (const format of formats) style[format] = current[format] ?? inherited[format]; if (current.text) { const font_style: font_style[] = []; if (style.bold) font_style.push('bold'); if (style.italic) font_style.push('italic'); if (style.underlined) font_style.push('underline'); if (style.clickEvent?.action === 'open_url') font_style.push('url'); if (!font_style.length) font_style.push('normal'); const font_color = this.normalizeColor(style.color); const entry: log = {message: current.text, font_color, font_style}; if (includeEvents && style.clickEvent) entry.clickEvent = {...style.clickEvent} as log['clickEvent']; if (includeEvents && style.insertion !== undefined) entry.insertion = style.insertion; if (includeEvents && style.hoverEvent) { const {action} = style.hoverEvent; const content = style.hoverEvent.contents ?? style.hoverEvent.value; if (content !== undefined && ['show_text', 'show_item', 'show_entity'].includes(action)) { let value: string | undefined; if (action === 'show_text') value = this.extractPlainText(content as TextComponent); else { try { value = typeof content === 'string' ? content : JSON.stringify(content); } catch { /* Ignore malformed hover payloads without losing message text. */ } } if (value !== undefined) { entry.hoverEvent = {action, value}; if (action === 'show_text') { let remaining = 4096; entry.hoverEvent.segments = this.toLog(content as TextComponent, false).slice(0, 64) .map(({message, font_color, font_style}) => { const text = String(message).slice(0, remaining); remaining -= text.length; return {message: text, font_color, font_style}; }).filter(segment => segment.message.length > 0); } } } } const previous = result[result.length - 1]; if (previous && previous.font_color === font_color && previous.font_style.join(',') === font_style.join(',') && JSON.stringify(previous.hoverEvent) === JSON.stringify(entry.hoverEvent) && JSON.stringify(previous.clickEvent) === JSON.stringify(entry.clickEvent) && previous.insertion === entry.insertion) { previous.message += current.text; } else { result.push(entry); } } for (const child of current.extra ?? []) visit(child as MinecraftTextComponent, style, depth + 1); }; visit(this.normalizeJson(node), {}, 0); return result; } static normalizeColor(color: string | undefined): string { if (color && /^#[\da-f]{6}$/i.test(color)) return color; return color && Object.hasOwn(colorMap, color) ? colorMap[color] : '#FFFFFF'; } static debug(jsonData: TextComponent | null | undefined): MinecraftTextComponent | null { return this.normalizeJson(jsonData); } }