318 lines
20 KiB
TypeScript
318 lines
20 KiB
TypeScript
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();
|
||
};
|
||
}
|