Files
node_server/bot/info/bot/inventory.ts
T
2026-10-22 17:07:08 +08:00

318 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
import type {log} from '../../../types/utils/logger.ts';
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
import {parse_inventory_item, type InventoryItem} from '../../../utils/inventory_item.ts';
export type {InventoryItem} from '../../../utils/inventory_item.ts';
type WireValue = null | boolean | number | string | WireValue[] | {[key: string]: WireValue};
type Item = NonNullable<Bot['inventory']['slots'][number]>;
type Window = Bot['inventory'];
type WindowKind = 'inventory' | 'chest' | 'large_chest' | 'crafting' | 'enchantment'
| 'stonecutter' | 'furnace' | 'merchant' | 'lectern' | 'generic';
export interface InventoryInfo {
window: {
id: number;
kind: WindowKind;
type: string | number;
title: log[];
inventoryStart: number | null;
inventoryEnd: number | null;
hotbarStart: number | null;
hasPlayerInventory: boolean;
craftingResultSlot: number | null;
slots: (InventoryItem | null)[];
properties: Record<string, number>;
} | null;
cursorItem: InventoryItem | null;
handSlot: number | null;
special: Record<string, WireValue>;
}
function to_wire(value: unknown, ancestors = new Set<object>()): WireValue {
if (value == null) return null;
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (Buffer.isBuffer(value)) return Array.from(value);
if (typeof value === 'object') {
if (ancestors.has(value)) return null;
ancestors.add(value);
try {
if (Array.isArray(value)) return Array.from(value, entry => to_wire(entry, ancestors));
return Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(value))
.filter(([, descriptor]) => descriptor.enumerable)
.map(([key, descriptor]) => [key, 'value' in descriptor ? to_wire(descriptor.value, ancestors) : null]));
} finally {
ancestors.delete(value);
}
}
return null;
}
function window_kind(window: Window): WindowKind {
const type = String(window.type).replace(/^minecraft:/, '');
if (window.id === 0 || type === 'inventory') return 'inventory';
if (['furnace', 'blast_furnace', 'smoker'].includes(type)) return 'furnace';
if (['crafting', 'crafting_table', 'crafting3x3'].includes(type)) return 'crafting';
if (['enchantment', 'enchanting_table'].includes(type)) return 'enchantment';
if (type === 'stonecutter' || type === 'lectern') return type;
if (type === 'merchant' || type === 'villager') return 'merchant';
if (['container', 'chest'].includes(type) || /^generic_9x[1-6]$/.test(type)) {
if (window.inventoryStart === 54) return 'large_chest';
if (window.inventoryStart === 27) return 'chest';
}
return 'generic';
}
const field = (name: string, description: string): InfoI18n => ({lang: 'zh-CN', name, description});
const item_fields = {
slot: field('槽位', '当前窗口槽位编号,鼠标物品和交易物品为 null'),
type: field('物品 ID', '当前版本的物品数值 ID,不包含图标'),
name: field('物品标识', '物品英文标识'), displayName: field('物品名称', '优先自定义名称,其次物品名称组件和默认名称,返回 log 片段'),
i18n: {...field('物品翻译', '按方块、物品翻译键依次查询中文名称,缺失时使用英文标识'), fields: {
lang: field('语言', '翻译语言标识'), name: field('中文名称', '物品默认中文名称,不覆盖自定义昵称'),
description: field('描述', '翻译描述,没有时为空字符串'),
}},
count: field('数量', '物品堆叠数量'), metadata: field('元数据', '旧版本物品元数据'),
lore: field('物品描述', '每行是一组 log 片段,保留空行'),
enchants: {...field('附魔', '已解析的附魔列表'), fields: {
id: field('附魔 ID', '版本内数值标识,未知时省略'), name: field('附魔标识', '英文附魔标识,未知时省略'),
i18n: {...field('附魔翻译', '查询中文附魔名称,缺失时回退到附魔标识或 ID'), fields: {
lang: field('语言', '翻译语言标识'), name: field('中文名称', '附魔中文名称'), description: field('描述', '翻译描述,没有时为空字符串'),
}},
level: field('附魔等级', '实际附魔等级'), hidden: field('隐藏附魔提示', '服务器是否要求隐藏此附魔提示'),
}},
durability: {...field('耐久', '耐久信息,没有耐久时为 null'), fields: {
used: field('已用耐久', '已消耗耐久'), max: field('最大耐久', '最大耐久'), remaining: field('剩余耐久', '剩余可用耐久'),
}},
unbreakable: field('不可破坏', '物品是否不会损耗耐久'), glint: field('附魔光效', '强制光效开关,未指定为 null'),
book: {...field('书籍', '已解析的现代或旧版书籍,没有时为 null'), fields: {
title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页是一组 log 片段'),
}},
details: {...field('特殊属性', '已解析特殊数据及解除 NBT 包装的未知字段'), fields: {
potion: {...field('药水', '药水类型、颜色和效果'), fields: {
type: field('药水类型', '版本内药水数值 ID 或旧版标识'), color: field('药水颜色', '自定义 RGB 整数颜色,未指定为 null'),
effects: {...field('药水效果', '自定义状态效果列表'), fields: {
id: field('效果 ID', '版本内数值 ID 或英文标识'), name: field('效果名称', '已识别效果名称'),
amplifier: field('效果增幅', '从 0 开始'), duration: field('持续时间', '游戏刻数'),
ambient: field('环境效果', '是否为环境效果'), showParticles: field('显示粒子', '是否显示效果粒子'),
}},
}},
attributes: {...field('属性修饰符', '属性及提示可见性'), fields: {
hidden: field('隐藏属性提示', '是否隐藏属性修饰符提示'), modifiers: {...field('修饰符列表', '已解析属性记录'), fields: {
type: field('属性类型', '版本内数值 ID 或英文标识'), name: field('修饰符名称', '修饰符名称或标识'),
amount: field('数值', '属性修饰数值'), operation: field('运算方式', '加法或乘法运算标识'), slot: field('生效槽位', '属性生效的装备槽'),
hidden: field('隐藏此项', '是否单独隐藏此属性'), displayName: field('显示文本', '覆盖显示文本的 log 片段'),
}},
}},
container: field('容器物品', '嵌套物品,使用相同物品结构'), bundle: field('收纳袋物品', '嵌套物品,使用相同物品结构'),
tooltip: {...field('提示可见性', '隐藏全部或附加提示及旧版标志'), fields: {
hideAll: field('隐藏全部提示', '隐藏全部物品提示'), hideAdditional: field('隐藏附加提示', '隐藏附加提示'),
hiddenComponents: field('隐藏组件', '被隐藏的组件名称,无法识别时保留 ID'), legacyFlags: field('旧版隐藏标志', '原始 HideFlags 数值'),
}},
extra: field('其他标签', '未识别标签解除 NBT 包装后保留'),
}},
truncated: field('数据已裁剪', '物品数据超出解析预算或存在循环引用、访问器等无法表示的数据'),
};
export class init implements BaseInfo<InventoryInfo> {
readonly name = 'inventory';
readonly i18n: InfoI18n = {
...field('背包与窗口', '只读查询当前窗口及物品,支持背包和各类容器,不提供操作'),
fields: {
window: {...field('当前窗口', '未打开容器时返回玩家背包,尚未初始化时为 null'), fields: {
id: field('窗口 ID', '服务器窗口编号'), kind: field('窗口类别', '背包、箱子、合成、附魔、切石、熔炉、交易、讲台或通用窗口'),
type: field('窗口类型', 'Mineflayer 原始窗口类型'), title: field('窗口标题', '标题的 log 片段'),
inventoryStart: field('背包起点', '当前窗口玩家背包起始槽,讲台为 null'),
inventoryEnd: field('背包终点', '玩家背包结束槽,不包含该槽,讲台为 null'),
hotbarStart: field('快捷栏起点', '当前窗口快捷栏起始槽,讲台为 null'),
hasPlayerInventory: field('包含玩家背包', '讲台不包含玩家背包'),
craftingResultSlot: field('结果槽', '窗口结果槽,没有时为 null'),
slots: {...field('物品槽', '按槽号排列,空槽为 null,fields 描述物品'), fields: item_fields},
properties: field('窗口属性', '服务器下发的属性 ID 到数值的映射,未收到属性不填充'),
}},
cursorItem: {...field('鼠标物品', '鼠标携带物品,没有时为 null'), fields: item_fields},
handSlot: field('主手槽', '当前窗口坐标系中的主手槽,讲台或未初始化时为 null'),
special: {...field('特殊窗口状态', '仅包含当前窗口对应状态,未知值为 null'), fields: {
furnace: {...field('熔炉', '熔炉、高炉和烟熏炉状态'), fields: {
burnRemaining: field('剩余燃烧', '剩余燃烧游戏刻'), burnTotal: field('总燃烧时间', '燃料总游戏刻'),
cookProgress: field('烧炼进度', '已烧炼游戏刻'), cookTotal: field('总烧炼时间', '所需游戏刻'),
fuelRatio: field('燃料比例', '剩余燃料比例,范围 0 至 1'), progressRatio: field('烧炼比例', '烧炼进度比例,范围 0 至 1'),
}},
enchantment: {...field('附魔台', '附魔选项与种子'), fields: {
seed: field('附魔种子', '服务器下发的附魔种子'),
options: {...field('附魔选项', '三个候选项,fields 描述每项'), fields: {
index: field('选项索引', '从 0 开始'), requiredLevel: field('所需等级', '选择该项需要达到的等级'),
levelCost: field('等级消耗', '消耗 1、2 或 3 级'), lapisCost: field('青金石消耗', '消耗 1、2 或 3 个青金石'),
enchantmentId: field('提示附魔 ID', '提示的附魔 ID'), enchantmentLevel: field('提示附魔等级', '提示的附魔等级'),
}},
}},
merchant: {...field('村民交易', '由 Mineflayer 专用交易 API 初始化的报价'), fields: {
ready: field('报价就绪', '尚未初始化交易列表时为 false'),
offers: {...field('报价列表', 'fields 描述每项报价'), fields: {
index: field('报价索引', '从 0 开始'),
input1: {...field('第一材料', '交易的第一种材料'), fields: item_fields},
input2: {...field('第二材料', '没有时为 null'), fields: item_fields},
output: {...field('交易产物', '交易结果物品'), fields: item_fields},
realPrice: field('实际价格', '第一材料实际数量'), disabled: field('不可交易', '服务器报告的交易禁用状态'),
uses: field('已交易次数', '当前报价已使用次数'), maxUses: field('最多交易次数', '报价最大使用次数'),
}},
}},
lectern: {...field('讲台', '讲台上的书及页码'), fields: {
page: field('当前页码', '服务器下发的页码,从 0 开始,未知为 null'),
book: {...field('书籍', '支持现代组件与旧版 NBT,未收到书籍时为 null'), fields: {
title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页为一组 log 片段'),
}},
}},
}},
},
};
private info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}};
private disposed = false;
private timer: ReturnType<typeof setTimeout> | undefined;
private attached_inventory: Window | undefined;
private attached_window: Window | null = null;
private readonly properties = new Map<number, Map<number, number>>();
private readonly listeners = new Set<(data: InventoryInfo) => void>();
private readonly events = ['heldItemChanged', 'spawn', 'physicsTick'] as const;
private readonly packets = ['trade_list', 'MC|TrList', 'minecraft:trader_list', 'window_items', 'set_slot', 'set_player_inventory'] as const;
constructor(private readonly instance: Bot) {
this.attach();
this.update();
instance.on('windowOpen', this.handle_open);
instance.on('windowClose', this.handle_close);
for (const event of this.events) instance.on(event, this.refresh);
for (const packet of this.packets) instance._client.on(packet, this.refresh);
instance._client.on('craft_progress_bar', this.handle_property);
}
private attach(): void {
const inventory = this.instance.inventory;
if (inventory !== this.attached_inventory) {
this.attached_inventory?.off('updateSlot', this.refresh);
this.attached_inventory = inventory;
inventory?.on('updateSlot', this.refresh);
}
const window = this.instance.currentWindow as Window | null;
if (window !== this.attached_window) {
this.attached_window?.off('updateSlot', this.refresh);
this.attached_window = window;
if (window) this.properties.delete(window.id);
window?.on('updateSlot', this.refresh);
}
}
private readonly handle_open = (): void => {
this.refresh();
};
private readonly handle_close = (window: Bot['inventory']): void => {
if (window) this.properties.delete(window.id);
this.refresh();
};
private readonly handle_property = (packet: {windowId: number; property: number; value: number}): void => {
const window = this.instance.currentWindow;
if (this.disposed || !window || window.id !== packet.windowId) return;
this.attach();
let values = this.properties.get(window.id);
if (!values) this.properties.set(window.id, values = new Map());
values.set(packet.property, packet.value);
this.refresh();
};
private readonly refresh = (): void => {
if (this.disposed) return;
this.attach();
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = undefined;
this.update();
}, 50);
this.timer.unref();
};
private update(): void {
if (this.disposed) return;
const bot = this.instance;
const read_item = (item: Item | null | undefined, slot: number | null = null) => parse_inventory_item(item, slot, bot.registry);
const window = bot.currentWindow ?? bot.inventory;
const info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}};
if (window) {
const kind = window_kind(window);
const hasPlayerInventory = kind !== 'lectern';
const values = this.properties.get(window.id);
const property = (id: number) => values?.get(id) ?? null;
const ratio = (value: number | null, total: number | null) => value === null || total === null || total <= 0
? null : Math.max(0, Math.min(1, value / total));
info.window = {
id: window.id, kind, type: window.type,
title: MinecraftJsonParser.toLog(window.title),
inventoryStart: hasPlayerInventory ? window.inventoryStart : null,
inventoryEnd: hasPlayerInventory ? window.inventoryEnd : null,
hotbarStart: hasPlayerInventory ? window.hotbarStart : null,
hasPlayerInventory,
craftingResultSlot: window.craftingResultSlot >= 0 ? window.craftingResultSlot : null,
slots: Array.from(kind === 'lectern' ? window.slots.slice(0, 1) : window.slots, (item, slot) => read_item(item, slot)),
properties: Object.fromEntries(values ?? []),
};
info.cursorItem = read_item(window.selectedItem);
info.handSlot = hasPlayerInventory && Number.isInteger(bot.quickBarSlot) ? window.hotbarStart + bot.quickBarSlot : null;
if (kind === 'furnace') {
info.special.furnace = {
burnRemaining: property(0), burnTotal: property(1), cookProgress: property(2), cookTotal: property(3),
fuelRatio: ratio(property(0), property(1)), progressRatio: ratio(property(2), property(3)),
};
} else if (kind === 'enchantment') {
info.special.enchantment = {
seed: property(3), options: [0, 1, 2].map(index => ({
index, requiredLevel: property(index), levelCost: index + 1, lapisCost: index + 1,
enchantmentId: property(4 + index), enchantmentLevel: property(7 + index),
})),
};
} else if (kind === 'merchant') {
const trades = (window as Window & {trades?: Awaited<ReturnType<Bot['openVillager']>>['trades']}).trades;
info.special.merchant = to_wire({ready: Array.isArray(trades), offers: (trades ?? []).map((trade, index) => ({
index, input1: read_item(trade.inputItem1), input2: trade.hasItem2 ? read_item(trade.inputItem2) : null,
output: read_item(trade.outputItem), realPrice: trade.realPrice ?? null,
disabled: Boolean(trade.tradeDisabled), uses: trade.nbTradeUses ?? null, maxUses: trade.maximumNbTradeUses ?? null,
}))});
} else if (kind === 'lectern') {
info.special.lectern = {page: property(0), book: to_wire(read_item(window.slots[0])?.book)};
}
}
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
this.info = info;
for (const callback of Array.from(this.listeners)) callback(info);
}
get_info(): InventoryInfo {
return this.info;
}
on_update(callback: (data: InventoryInfo) => void): () => void {
if (this.disposed) throw new Error('InventoryInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
clearTimeout(this.timer);
this.attached_inventory?.off('updateSlot', this.refresh);
this.attached_window?.off('updateSlot', this.refresh);
this.instance.off('windowOpen', this.handle_open);
this.instance.off('windowClose', this.handle_close);
for (const event of this.events) this.instance.off(event, this.refresh);
for (const packet of this.packets) this.instance._client.off(packet, this.refresh);
this.instance._client.off('craft_progress_bar', this.handle_property);
this.properties.clear();
this.listeners.clear();
};
}