chore: initial commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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';
|
||||
|
||||
export interface EntityInfo {
|
||||
entity_count: number;
|
||||
player_count: number;
|
||||
players: log[][];
|
||||
entities: log[][];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<EntityInfo> {
|
||||
readonly name = 'entity';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '附近实体', description: '当前已加载的附近实体和玩家,不包含机器人自身',
|
||||
fields: {
|
||||
entity_count: {lang: 'zh-CN', name: '附近实体数量', description: '已加载的实体数量,包含玩家,不包含机器人自身'},
|
||||
player_count: {lang: 'zh-CN', name: '附近玩家数量', description: '已加载的其他玩家实体数量'},
|
||||
players: {lang: 'zh-CN', name: '附近玩家列表', description: '每个玩家名称返回一组 log 片段,优先使用玩家对象的 displayName,其次 username'},
|
||||
entities: {lang: 'zh-CN', name: '附近实体列表', description: '按显示名称合并去重,每个名称返回一组 log 片段并保留首次出现的样式,包含玩家'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: EntityInfo) => void>();
|
||||
private readonly events = ['entitySpawn', 'entityGone', 'entityUpdate', 'playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'respawn', 'physicsTick'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_entities();
|
||||
for (const event of this.events) instance.on(event, this.handle_entities);
|
||||
}
|
||||
|
||||
private readonly handle_entities = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const bot = this.instance;
|
||||
const info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []};
|
||||
const entity_names = new Set<string>();
|
||||
const players = new Map(Object.values(bot.players ?? {})
|
||||
.filter(player => player.entity)
|
||||
.map(player => [player.entity.id, player]));
|
||||
for (const entity of Object.values(bot.entities ?? {}).sort((a, b) => a.id - b.id)) {
|
||||
if (entity.id === bot.entity?.id || entity.isValid === false) continue;
|
||||
|
||||
const player = players.get(entity.id) ?? (entity.username ? bot.players?.[entity.username] : undefined);
|
||||
const is_player = entity.type === 'player' || Boolean(player);
|
||||
const fallback = player?.username || entity.username || entity.name || '未知';
|
||||
const display_name = is_player ? player?.displayName ?? entity.displayName : entity.displayName;
|
||||
const name = MinecraftJsonParser.toLog(display_name ?? fallback);
|
||||
const segments = name.length ? name : MinecraftJsonParser.toLog(fallback);
|
||||
info.entity_count++;
|
||||
const entity_name = segments.map(segment => segment.message).join('');
|
||||
if (!entity_names.has(entity_name)) {
|
||||
entity_names.add(entity_name);
|
||||
info.entities.push(segments);
|
||||
}
|
||||
if (is_player) info.players.push(segments);
|
||||
}
|
||||
info.player_count = info.players.length;
|
||||
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
};
|
||||
|
||||
get_info(): EntityInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: EntityInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('EntityInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_entities);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo} from '../types.ts';
|
||||
|
||||
export class init implements BaseInfo<number> {
|
||||
readonly name = 'food';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '饥饿值',
|
||||
description: '机器人的当前饥饿值',
|
||||
};
|
||||
|
||||
private food: number;
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: number) => void>();
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.food = instance.food ?? 0;
|
||||
instance.on('health', this.handle_food);
|
||||
}
|
||||
|
||||
private readonly handle_food = (): void => {
|
||||
this.update_food(this.instance.food ?? 0);
|
||||
};
|
||||
|
||||
get_info(): number {
|
||||
return this.food;
|
||||
}
|
||||
|
||||
on_update(callback: (data: number) => void): () => void {
|
||||
if (this.disposed) {
|
||||
throw new Error('FoodInfo 已销毁');
|
||||
}
|
||||
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private update_food(food: number): void {
|
||||
if (this.disposed || Object.is(this.food, food)) return;
|
||||
|
||||
this.food = food;
|
||||
for (const callback of Array.from(this.listeners)) {
|
||||
callback(food);
|
||||
}
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.instance.off('health', this.handle_food);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo} from '../types.ts';
|
||||
|
||||
export class init implements BaseInfo<number> {
|
||||
readonly name = 'health';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '生命值',
|
||||
description: '机器人的当前生命值',
|
||||
};
|
||||
|
||||
private health: number;
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: number) => void>();
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.health = instance.health ?? 0;
|
||||
instance.on('health', this.handle_health);
|
||||
}
|
||||
|
||||
private readonly handle_health = (): void => {
|
||||
this.update_health(this.instance.health ?? 0);
|
||||
};
|
||||
|
||||
get_info(): number {
|
||||
return this.health;
|
||||
}
|
||||
|
||||
on_update(callback: (data: number) => void): () => void {
|
||||
if (this.disposed) {
|
||||
throw new Error('HealthInfo 已销毁');
|
||||
}
|
||||
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private update_health(health: number): void {
|
||||
if (this.disposed || Object.is(this.health, health)) return;
|
||||
|
||||
this.health = health;
|
||||
for (const callback of Array.from(this.listeners)) {
|
||||
callback(health);
|
||||
}
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.instance.off('health', this.handle_health);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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';
|
||||
|
||||
export interface PlayerListEntry {
|
||||
displayName: log[];
|
||||
ping: number;
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<PlayerListEntry[]> {
|
||||
readonly name = 'player_list';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '玩家列表', description: '服务器玩家列表,包含机器人自身,fields 描述每个数组元素',
|
||||
fields: {
|
||||
displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失、为空或解析失败时使用用户名,返回 log 片段'},
|
||||
ping: {lang: 'zh-CN', name: '玩家延迟', description: '玩家延迟,单位为毫秒,缺失、无效或读取失败时返回 0'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private players: PlayerListEntry[] = [];
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: PlayerListEntry[]) => void>();
|
||||
private readonly events = ['playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'physicsTick'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_players();
|
||||
for (const event of this.events) instance.on(event, this.handle_players);
|
||||
}
|
||||
|
||||
private readonly handle_players = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const players = Object.entries(this.instance.players ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([username, player]): PlayerListEntry => {
|
||||
let displayName: log[] = [];
|
||||
try {
|
||||
displayName = MinecraftJsonParser.toLog(player.displayName);
|
||||
} catch {
|
||||
// A malformed nickname must not prevent the player list from updating.
|
||||
}
|
||||
if (!displayName.length) displayName = MinecraftJsonParser.toLog(player.username || username);
|
||||
|
||||
let ping = 0;
|
||||
try {
|
||||
const value = player.ping;
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) ping = value;
|
||||
} catch {
|
||||
// Keep the default when latency cannot be read.
|
||||
}
|
||||
return {displayName, ping};
|
||||
});
|
||||
|
||||
if (JSON.stringify(this.players) === JSON.stringify(players)) return;
|
||||
this.players = players;
|
||||
for (const callback of Array.from(this.listeners)) callback(players);
|
||||
};
|
||||
|
||||
get_info(): PlayerListEntry[] {
|
||||
return this.players;
|
||||
}
|
||||
|
||||
on_update(callback: (data: PlayerListEntry[]) => void): () => void {
|
||||
if (this.disposed) throw new Error('PlayerListInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_players);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoContext, InfoI18n} from '../types.ts';
|
||||
import type {PluginSnapshot} from '../../plugins/init.ts';
|
||||
|
||||
export interface PluginStatesInfo {
|
||||
available: boolean;
|
||||
plugins: Omit<PluginSnapshot, 'config'>[];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<PluginStatesInfo> {
|
||||
readonly name = 'plugin_states';
|
||||
readonly i18n: InfoI18n = {
|
||||
lang: 'zh-CN', name: '插件状态', description: '插件启用情况、生命周期和内部状态,不包含配置',
|
||||
fields: {
|
||||
available: {lang: 'zh-CN', name: '状态源可用', description: '是否提供插件管理器状态源'},
|
||||
plugins: {lang: 'zh-CN', name: '插件列表', description: '包含已禁用和失败记录', fields: {
|
||||
name: {lang: 'zh-CN', name: '插件名称', description: '稳定的插件标识'},
|
||||
path: {lang: 'zh-CN', name: '插件路径', description: '插件分类路径'},
|
||||
enabled: {lang: 'zh-CN', name: '已启用', description: '仅初始化成功且尚未开始清理时为 true'},
|
||||
lifecycle: {lang: 'zh-CN', name: '生命周期', description: 'initializing / enabled / disabling / disabled / error'},
|
||||
states: {lang: 'zh-CN', name: '内部状态', description: '插件发布的内部状态;禁用记录保留清理完成时的最终快照'},
|
||||
}},
|
||||
},
|
||||
};
|
||||
private value: PluginStatesInfo;
|
||||
private readonly listeners = new Set<(value: PluginStatesInfo) => void>();
|
||||
private unsubscribe?: () => void;
|
||||
private disposed = false;
|
||||
|
||||
constructor(_bot: Bot, options?: InfoContext) {
|
||||
const source = options?.plugin_source;
|
||||
const read = (): PluginStatesInfo => ({available: !!source,
|
||||
plugins: (source?.get_all_plugins() ?? []).map(({name, path, enabled, lifecycle, states}) =>
|
||||
structuredClone({name, path, enabled, lifecycle, states})),
|
||||
});
|
||||
this.value = read();
|
||||
this.unsubscribe = source?.on_update(() => {
|
||||
if (this.disposed) return;
|
||||
this.value = read();
|
||||
for (const listener of Array.from(this.listeners)) {
|
||||
if (!this.listeners.has(listener)) continue;
|
||||
try { listener(this.get_info()); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get_info(): PluginStatesInfo { return structuredClone(this.value); }
|
||||
|
||||
on_update(callback: (value: PluginStatesInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('Plugin states info has been disposed');
|
||||
const listener = (value: PluginStatesInfo) => callback(value);
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.unsubscribe?.();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
|
||||
export interface PositionInfo {
|
||||
location?: {x: number; y: number; z: number};
|
||||
yaw?: number;
|
||||
face_direction?: 'south' | 'west' | 'north' | 'east';
|
||||
pitch?: number;
|
||||
velocity?: {x: number; y: number; z: number};
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<PositionInfo> {
|
||||
readonly name = 'position';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '位置',
|
||||
description: '机器人的当前位置、朝向和速度',
|
||||
fields: {
|
||||
location: {
|
||||
lang: 'zh-CN',
|
||||
name: '坐标',
|
||||
description: '机器人在世界中的坐标',
|
||||
fields: {
|
||||
x: {lang: 'zh-CN', name: 'X 坐标', description: '东西方向的坐标'},
|
||||
y: {lang: 'zh-CN', name: 'Y 坐标', description: '垂直方向的坐标'},
|
||||
z: {lang: 'zh-CN', name: 'Z 坐标', description: '南北方向的坐标'},
|
||||
},
|
||||
},
|
||||
yaw: {
|
||||
lang: 'zh-CN',
|
||||
name: '偏航角',
|
||||
description: '机器人水平方向的朝向角度,单位为弧度',
|
||||
},
|
||||
face_direction: {
|
||||
lang: 'zh-CN',
|
||||
name: '朝向',
|
||||
description: '机器人面向的水平方位',
|
||||
values: {
|
||||
south: {lang: 'zh-CN', name: '南', description: '朝向南方'},
|
||||
west: {lang: 'zh-CN', name: '西', description: '朝向西方'},
|
||||
north: {lang: 'zh-CN', name: '北', description: '朝向北方'},
|
||||
east: {lang: 'zh-CN', name: '东', description: '朝向东方'},
|
||||
},
|
||||
},
|
||||
pitch: {
|
||||
lang: 'zh-CN',
|
||||
name: '俯仰角',
|
||||
description: '机器人垂直方向的朝向角度,单位为弧度',
|
||||
},
|
||||
velocity: {
|
||||
lang: 'zh-CN',
|
||||
name: '速度',
|
||||
description: '机器人在各坐标轴上的速度分量,单位为格每物理刻',
|
||||
fields: {
|
||||
x: {lang: 'zh-CN', name: 'X 轴速度', description: '东西方向的速度分量'},
|
||||
y: {lang: 'zh-CN', name: 'Y 轴速度', description: '垂直方向的速度分量'},
|
||||
z: {lang: 'zh-CN', name: 'Z 轴速度', description: '南北方向的速度分量'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private position: PositionInfo = {};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: PositionInfo) => void>();
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_position();
|
||||
instance.on('move', this.handle_position);
|
||||
instance.on('forcedMove', this.handle_position);
|
||||
instance.on('physicsTick', this.handle_position);
|
||||
instance.on('spawn', this.handle_position);
|
||||
}
|
||||
|
||||
private readonly handle_position = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const entity = this.instance.entity;
|
||||
const position: PositionInfo = {};
|
||||
const pos = entity?.position;
|
||||
if (pos) {
|
||||
position.location = {
|
||||
x: parseFloat(pos.x.toFixed(2)),
|
||||
y: parseFloat(pos.y.toFixed(2)),
|
||||
z: parseFloat(pos.z.toFixed(2)),
|
||||
};
|
||||
}
|
||||
if (typeof entity?.yaw === 'number') {
|
||||
position.yaw = parseFloat(entity.yaw.toFixed(3));
|
||||
// Mineflayer yaw: 0 = south, PI / 2 = west.
|
||||
const directions = ['south', 'west', 'north', 'east'] as const;
|
||||
const index = ((Math.round(entity.yaw / (Math.PI / 2)) % 4) + 4) % 4;
|
||||
position.face_direction = directions[index];
|
||||
}
|
||||
if (typeof entity?.pitch === 'number') {
|
||||
position.pitch = parseFloat(entity.pitch.toFixed(3));
|
||||
}
|
||||
const vel = entity?.velocity;
|
||||
if (vel) {
|
||||
position.velocity = {
|
||||
x: parseFloat(vel.x.toFixed(3)),
|
||||
y: parseFloat(vel.y.toFixed(3)),
|
||||
z: parseFloat(vel.z.toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.position) === JSON.stringify(position)) return;
|
||||
this.position = position;
|
||||
for (const callback of Array.from(this.listeners)) {
|
||||
callback(position);
|
||||
}
|
||||
};
|
||||
|
||||
get_info(): PositionInfo {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
on_update(callback: (data: PositionInfo) => void): () => void {
|
||||
if (this.disposed) {
|
||||
throw new Error('PositionInfo 已销毁');
|
||||
}
|
||||
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.instance.off('move', this.handle_position);
|
||||
this.instance.off('forcedMove', this.handle_position);
|
||||
this.instance.off('physicsTick', this.handle_position);
|
||||
this.instance.off('spawn', this.handle_position);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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';
|
||||
|
||||
export interface ScoreboardInfo {
|
||||
scoreboard_title: log[];
|
||||
scoreboard: log[][];
|
||||
header: log[];
|
||||
footer: log[];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<ScoreboardInfo> {
|
||||
readonly name = 'scoreboard';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '计分板与 TAB', description: '当前侧边栏计分板及 TAB 页眉、页脚,以 log 片段返回',
|
||||
fields: {
|
||||
scoreboard_title: {lang: 'zh-CN', name: '计分板标题', description: '当前侧边栏标题的 log 片段,无侧边栏时为空数组'},
|
||||
scoreboard: {lang: 'zh-CN', name: '计分板内容', description: '按分数降序排列,每行是一组 log 片段,保留空行,不混入隐藏计分板'},
|
||||
header: {lang: 'zh-CN', name: 'TAB 页眉', description: 'TAB 页眉的 log 片段,保留换行和文本样式'},
|
||||
footer: {lang: 'zh-CN', name: 'TAB 页脚', description: 'TAB 页脚的 log 片段,保留换行和文本样式'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private info: ScoreboardInfo = {scoreboard_title: [], scoreboard: [], header: [], footer: []};
|
||||
private disposed = false;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private last_update = -Infinity;
|
||||
private readonly listeners = new Set<(data: ScoreboardInfo) => void>();
|
||||
private readonly events = ['scoreboardCreated', 'scoreboardDeleted', 'scoreboardTitleChanged', 'scoreboardPosition', 'scoreUpdated', 'scoreRemoved', 'teamCreated', 'teamRemoved', 'teamUpdated', 'teamMemberAdded', 'teamMemberRemoved', 'spawn', 'respawn', 'physicsTick'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.update();
|
||||
for (const event of this.events) instance.on(event, this.schedule_update);
|
||||
instance._client.on('playerlist_header', this.schedule_update);
|
||||
}
|
||||
|
||||
private readonly schedule_update = (): void => {
|
||||
if (this.disposed || this.timer) return;
|
||||
// Some Mineflayer events fire before their state mutation finishes.
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
this.update();
|
||||
}, Math.max(0, 2000 - (performance.now() - this.last_update)));
|
||||
this.timer.unref();
|
||||
};
|
||||
|
||||
private clean_text(component: Parameters<typeof MinecraftJsonParser.toLog>[0]): log[] {
|
||||
let line_start = true;
|
||||
return MinecraftJsonParser.toLog(component).flatMap(segment => {
|
||||
let message = '';
|
||||
for (const character of String(segment.message)) {
|
||||
if (character === '\n' || character === '\r') {
|
||||
message += character;
|
||||
line_start = true;
|
||||
} else if (!line_start || !/[\s\u200B-\u200D\u2060]/u.test(character)) {
|
||||
message += character;
|
||||
line_start = false;
|
||||
}
|
||||
}
|
||||
return message ? [{...segment, message}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
if (this.disposed) return;
|
||||
this.last_update = performance.now();
|
||||
const bot = this.instance;
|
||||
const board = bot.scoreboard?.sidebar;
|
||||
const info: ScoreboardInfo = {
|
||||
scoreboard_title: this.clean_text(board?.title),
|
||||
scoreboard: board ? board.items.map(item => {
|
||||
try {
|
||||
return this.clean_text(item.displayName);
|
||||
} catch {
|
||||
return this.clean_text(item.name);
|
||||
}
|
||||
}) : [],
|
||||
header: this.clean_text(bot.tablist?.header),
|
||||
footer: this.clean_text(bot.tablist?.footer),
|
||||
};
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
}
|
||||
|
||||
get_info(): ScoreboardInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: ScoreboardInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('ScoreboardInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
for (const event of this.events) this.instance.off(event, this.schedule_update);
|
||||
this.instance._client.off('playerlist_header', this.schedule_update);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import minecraftData from '@wp2508/minecraft-data';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
import type {log} from '../../../types/utils/logger.ts';
|
||||
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
|
||||
|
||||
export interface SelfInfo {
|
||||
displayName?: log[];
|
||||
level?: number;
|
||||
experience?: {points: number; progress: number};
|
||||
effects?: {id: number; name?: string; display_name: string; amplifier: number; duration: number}[];
|
||||
armor?: number;
|
||||
game_mode?: Bot['game']['gameMode'];
|
||||
difficulty?: Bot['game']['difficulty'];
|
||||
}
|
||||
|
||||
interface Attribute {
|
||||
value: number;
|
||||
modifiers: {operation: number; amount: number}[];
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<SelfInfo> {
|
||||
readonly name = 'self';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '自身信息', description: '机器人的等级、经验、药水效果、护甲、游戏模式和难度',
|
||||
fields: {
|
||||
displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失或为空时使用用户名,返回 log 片段'},
|
||||
level: {lang: 'zh-CN', name: '等级', description: '机器人当前的经验等级'},
|
||||
experience: {
|
||||
lang: 'zh-CN', name: '经验值', description: '服务器同步的经验值和升级进度',
|
||||
fields: {
|
||||
points: {lang: 'zh-CN', name: '总经验值', description: '服务器同步的累计经验点数'},
|
||||
progress: {lang: 'zh-CN', name: '等级进度', description: '当前等级的经验条进度,范围为 0 至 1'},
|
||||
},
|
||||
},
|
||||
effects: {
|
||||
lang: 'zh-CN', name: '药水效果', description: '机器人当前具有的状态效果,fields 描述每个数组元素',
|
||||
fields: {
|
||||
id: {lang: 'zh-CN', name: '效果 ID', description: '当前版本的状态效果数值标识'},
|
||||
name: {lang: 'zh-CN', name: '效果名称', description: '状态效果的英文标识,未知效果省略'},
|
||||
display_name: {lang: 'zh-CN', name: '效果中文昵称', description: '按机器人版本查询的中文名称,没有中文名称时返回未知'},
|
||||
amplifier: {lang: 'zh-CN', name: '效果增幅', description: '效果增幅等级,从 0 开始,0 表示一级'},
|
||||
duration: {lang: 'zh-CN', name: '持续时间', description: '服务器最近下发的持续游戏刻数,-1 表示无限,不在本地倒计时'},
|
||||
},
|
||||
},
|
||||
armor: {lang: 'zh-CN', name: '护甲值', description: '应用属性修饰符后的护甲点数,2 点对应一个护甲图标'},
|
||||
game_mode: {
|
||||
lang: 'zh-CN', name: '游戏模式', description: '机器人当前的游戏模式',
|
||||
values: {
|
||||
survival: {lang: 'zh-CN', name: '生存', description: '生存模式'},
|
||||
creative: {lang: 'zh-CN', name: '创造', description: '创造模式'},
|
||||
adventure: {lang: 'zh-CN', name: '冒险', description: '冒险模式'},
|
||||
spectator: {lang: 'zh-CN', name: '旁观', description: '旁观模式'},
|
||||
},
|
||||
},
|
||||
difficulty: {
|
||||
lang: 'zh-CN', name: '游戏难度', description: '服务器当前的游戏难度',
|
||||
values: {
|
||||
peaceful: {lang: 'zh-CN', name: '和平', description: '和平难度'},
|
||||
easy: {lang: 'zh-CN', name: '简单', description: '简单难度'},
|
||||
normal: {lang: 'zh-CN', name: '普通', description: '普通难度'},
|
||||
hard: {lang: 'zh-CN', name: '困难', description: '困难难度'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private self: SelfInfo = {};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: SelfInfo) => void>();
|
||||
private readonly events = ['experience', 'game', 'spawn', 'respawn', 'playerJoined', 'playerUpdated', 'playerLeft', 'physicsTick'] as const;
|
||||
private readonly entity_events = ['entityEffect', 'entityEffectEnd', 'entityAttributes'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_self();
|
||||
for (const event of this.events) instance.on(event, this.handle_self);
|
||||
for (const event of this.entity_events) instance.on(event, this.handle_entity);
|
||||
}
|
||||
|
||||
private readonly handle_entity = (entity: Bot['entity']): void => {
|
||||
if (entity === this.instance.entity) this.handle_self();
|
||||
};
|
||||
|
||||
private readonly handle_self = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const bot = this.instance;
|
||||
const self: SelfInfo = {};
|
||||
const player = bot.players?.[bot.username] ?? bot.player;
|
||||
const username = player?.username || bot.username;
|
||||
const displayName = MinecraftJsonParser.toLog(player?.displayName ?? username);
|
||||
if (displayName.length) self.displayName = displayName;
|
||||
else if (username) self.displayName = MinecraftJsonParser.toLog(username);
|
||||
if (typeof bot.experience?.level === 'number') self.level = bot.experience.level;
|
||||
if (typeof bot.experience?.points === 'number' && typeof bot.experience.progress === 'number') {
|
||||
self.experience = {points: bot.experience.points, progress: bot.experience.progress};
|
||||
}
|
||||
const entity = bot.entity;
|
||||
if (entity) {
|
||||
const data = bot.version ? minecraftData(bot.version) : null;
|
||||
self.effects = Object.values(entity.effects ?? {}).map(effect => {
|
||||
const name = bot.registry?.effects?.[effect.id]?.name;
|
||||
const display_name = data?.effects[effect.id]?.displayName;
|
||||
return {
|
||||
id: effect.id,
|
||||
...(name ? {name} : {}),
|
||||
display_name: display_name && /\p{Script=Han}/u.test(display_name) ? display_name : '未知',
|
||||
amplifier: effect.amplifier,
|
||||
duration: effect.duration,
|
||||
};
|
||||
}).sort((a, b) => a.id - b.id);
|
||||
// Mineflayer populates attributes at runtime, but its Entity type omits them.
|
||||
const attributes = (entity as typeof entity & {attributes?: Record<string, Attribute>}).attributes;
|
||||
const armor = attributes?.['minecraft:armor'] ?? attributes?.armor
|
||||
?? attributes?.['minecraft:generic.armor'] ?? attributes?.['generic.armor'];
|
||||
if (armor) {
|
||||
let base = armor.value;
|
||||
for (const modifier of armor.modifiers ?? []) {
|
||||
if (modifier.operation === 0) base += modifier.amount;
|
||||
}
|
||||
let value = base;
|
||||
for (const modifier of armor.modifiers ?? []) {
|
||||
if (modifier.operation === 1) value += base * modifier.amount;
|
||||
}
|
||||
for (const modifier of armor.modifiers ?? []) {
|
||||
if (modifier.operation === 2) value *= 1 + modifier.amount;
|
||||
}
|
||||
self.armor = Math.min(30, Math.max(0, value));
|
||||
}
|
||||
}
|
||||
if (bot.game?.gameMode != null) self.game_mode = bot.game.gameMode;
|
||||
if (bot.game?.difficulty != null) self.difficulty = bot.game.difficulty;
|
||||
|
||||
if (JSON.stringify(this.self) === JSON.stringify(self)) return;
|
||||
this.self = self;
|
||||
for (const callback of Array.from(this.listeners)) callback(self);
|
||||
};
|
||||
|
||||
get_info(): SelfInfo {
|
||||
return this.self;
|
||||
}
|
||||
|
||||
on_update(callback: (data: SelfInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('SelfInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_self);
|
||||
for (const event of this.entity_events) this.instance.off(event, this.handle_entity);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
|
||||
export interface ServerInfo {
|
||||
host?: string;
|
||||
brand?: string;
|
||||
version?: string;
|
||||
protocol?: number;
|
||||
compression_threshold?: number;
|
||||
online_seconds: number;
|
||||
online_time: string;
|
||||
ping: number;
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<ServerInfo> {
|
||||
readonly name = 'server';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN', name: '服务器信息', description: '当前连接的服务器、协议、在线时长和延迟',
|
||||
fields: {
|
||||
host: {lang: 'zh-CN', name: '主机', description: '连接配置中的服务器主机和端口'},
|
||||
brand: {lang: 'zh-CN', name: 'Brand', description: '服务器报告的品牌字符串'},
|
||||
version: {lang: 'zh-CN', name: '版本', description: '当前连接使用的 Minecraft 版本'},
|
||||
protocol: {lang: 'zh-CN', name: '协议', description: '当前连接使用的协议版本号'},
|
||||
compression_threshold: {lang: 'zh-CN', name: '压缩阈值', description: '触发数据包压缩的字节数,负数表示未启用压缩'},
|
||||
online_seconds: {lang: 'zh-CN', name: '本次在线秒数', description: '从本次首次登录成功起累计的秒数'},
|
||||
online_time: {lang: 'zh-CN', name: '本次在线', description: '本次在线时长,格式为小时和分钟,例如 3h 05m'},
|
||||
ping: {lang: 'zh-CN', name: '服务器延迟', description: '服务器下发的当前机器人玩家延迟,单位毫秒,缺失或读取失败时为 0'},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private info: ServerInfo = {online_seconds: 0, online_time: '0h 00m', ping: 0};
|
||||
private login_at: number | undefined;
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: ServerInfo) => void>();
|
||||
private readonly events = ['game', 'playerJoined', 'playerUpdated'] as const;
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_server();
|
||||
instance.on('login', this.handle_login);
|
||||
for (const event of this.events) instance.on(event, this.handle_server);
|
||||
this.timer = setInterval(this.handle_server, 1000);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private readonly handle_login = (): void => {
|
||||
// Backend switches can emit login again without starting a new connection.
|
||||
this.login_at ??= performance.now();
|
||||
this.handle_server();
|
||||
};
|
||||
|
||||
private readonly handle_server = (): void => {
|
||||
if (this.disposed) return;
|
||||
const bot = this.instance;
|
||||
const client = bot._client;
|
||||
const online_seconds = this.login_at === undefined ? 0
|
||||
: Math.max(0, Math.floor((performance.now() - this.login_at) / 1000));
|
||||
const info: ServerInfo = {
|
||||
online_seconds,
|
||||
online_time: `${Math.floor(online_seconds / 3600)}h ${String(Math.floor(online_seconds / 60) % 60).padStart(2, '0')}m`,
|
||||
ping: 0,
|
||||
};
|
||||
const host = (bot as Bot & {server_address?: string}).server_address;
|
||||
if (host) info.host = host;
|
||||
if (typeof bot.game?.serverBrand === 'string') info.brand = bot.game.serverBrand;
|
||||
if (bot.version) info.version = bot.version;
|
||||
if (typeof client?.protocolVersion === 'number') info.protocol = client.protocolVersion;
|
||||
// The protocol library exposes a number at runtime despite its string declaration.
|
||||
const threshold = client?.compressionThreshold as unknown;
|
||||
if (typeof threshold === 'number') info.compression_threshold = threshold;
|
||||
try {
|
||||
const ping = (bot.players?.[bot.username] ?? bot.player)?.ping;
|
||||
if (typeof ping === 'number' && Number.isFinite(ping) && ping >= 0) info.ping = ping;
|
||||
} catch {
|
||||
// Keep zero when player latency is unavailable.
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
|
||||
this.info = info;
|
||||
for (const callback of Array.from(this.listeners)) callback(info);
|
||||
};
|
||||
|
||||
get_info(): ServerInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
on_update(callback: (data: ServerInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('ServerInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearInterval(this.timer);
|
||||
this.instance.off('login', this.handle_login);
|
||||
for (const event of this.events) this.instance.off(event, this.handle_server);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoContext} from '../types.ts';
|
||||
import type {WorkflowRuntimeSnapshot} from '../../../types/bot/workflow/runtime.ts';
|
||||
import {summarize_run, type RunSummary} from '../../workflow/runtime/summary.ts';
|
||||
|
||||
interface WorkflowStates extends WorkflowRuntimeSnapshot {available: boolean; runs: RunSummary[]}
|
||||
const push_interval_ms = 250;
|
||||
|
||||
export class init implements BaseInfo<WorkflowStates> {
|
||||
readonly name = 'workflow_states';
|
||||
readonly i18n = {lang: 'zh-CN', name: '工作流状态', description: '工作流运行状态、当前节点与结束原因'};
|
||||
private readonly listeners = new Set<(value: WorkflowStates) => void>();
|
||||
private unsubscribe?: () => void;
|
||||
private value: WorkflowStates;
|
||||
private disposed = false;
|
||||
private dirty = false;
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private last_sent: string;
|
||||
private readonly read: () => WorkflowStates;
|
||||
|
||||
constructor(_bot: Bot, options?: InfoContext) {
|
||||
const source = options?.workflow_source;
|
||||
this.read = (): WorkflowStates => {
|
||||
const runtime = source?.get_runtime_snapshot?.() ?? {source_config_revision: 0, workflows: [], commands: []};
|
||||
// Older command results remain queryable by ID; do not resend all 100 on each live update.
|
||||
const recent = new Set(runtime.commands.filter(command => command.state !== 'accepted').slice(-20).map(command => command.command_id));
|
||||
return {available: !!source, ...runtime,
|
||||
commands: runtime.commands.filter(command => command.state === 'accepted' || recent.has(command.command_id)),
|
||||
runs: (source?.get_snapshots() ?? []).map(summarize_run)};
|
||||
};
|
||||
this.value = this.read();
|
||||
this.last_sent = JSON.stringify(this.value);
|
||||
this.unsubscribe = source?.on_update(event => {
|
||||
if (this.disposed || event.type !== 'run_updated' && event.type !== 'runtime_updated') return;
|
||||
this.dirty = true;
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
private schedule() {
|
||||
if (this.disposed || this.timer !== undefined || !this.listeners.size) return;
|
||||
// A fixed window bounds traffic without starving updates during continuous activity.
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (this.disposed || !this.dirty || !this.listeners.size) return;
|
||||
this.value = this.read();
|
||||
this.dirty = false;
|
||||
const serialized = JSON.stringify(this.value);
|
||||
if (serialized === this.last_sent) return;
|
||||
this.last_sent = serialized;
|
||||
const snapshot = this.value;
|
||||
for (const listener of [...this.listeners]) {
|
||||
if (this.disposed || !this.listeners.has(listener)) continue;
|
||||
try {listener(structuredClone(snapshot));} catch { /* Observers own their failures. */ }
|
||||
}
|
||||
}, push_interval_ms);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
get_info() {
|
||||
if (!this.disposed && this.dirty) this.value = this.read();
|
||||
return structuredClone(this.value);
|
||||
}
|
||||
on_update(callback: (value: WorkflowStates) => void) {
|
||||
if (this.disposed) throw new Error('Workflow info has been disposed');
|
||||
if (!this.listeners.size) this.last_sent = JSON.stringify(this.get_info());
|
||||
this.listeners.add(callback);
|
||||
if (this.dirty) this.schedule();
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
if (!this.listeners.size && this.timer !== undefined) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
if (this.dirty) this.value = this.read();
|
||||
this.disposed = true;
|
||||
if (this.timer !== undefined) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.unsubscribe?.();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from '../types.ts';
|
||||
|
||||
export interface WorldInfo {
|
||||
time?: {time: number; time_of_day: number; day: number; is_day: boolean};
|
||||
biome?: {id: number; name: string};
|
||||
dimension?: string;
|
||||
weather?: 'clear' | 'rain' | 'thunder';
|
||||
light?: {block: number; sky: number};
|
||||
}
|
||||
|
||||
export class init implements BaseInfo<WorldInfo> {
|
||||
readonly name = 'world';
|
||||
readonly i18n = {
|
||||
lang: 'zh-CN',
|
||||
name: '世界信息',
|
||||
description: '机器人所在世界的时间、群系、维度、天气和光照',
|
||||
fields: {
|
||||
time: {
|
||||
lang: 'zh-CN', name: '世界时间', description: '服务器同步的世界时间',
|
||||
fields: {
|
||||
time: {lang: 'zh-CN', name: '总时间', description: '世界累计昼夜时间,单位为游戏刻'},
|
||||
time_of_day: {lang: 'zh-CN', name: '日内时间', description: '一天内的时间,范围为 0 至 23999 游戏刻'},
|
||||
day: {lang: 'zh-CN', name: '天数', description: '世界时间对应的天数,从 0 开始'},
|
||||
is_day: {lang: 'zh-CN', name: '是否白天', description: '日内时间是否处于 0 至 12999 游戏刻'},
|
||||
},
|
||||
},
|
||||
biome: {
|
||||
lang: 'zh-CN', name: '当前群系', description: '机器人当前位置的生物群系',
|
||||
fields: {
|
||||
id: {lang: 'zh-CN', name: '群系 ID', description: '当前版本的群系数值标识'},
|
||||
name: {lang: 'zh-CN', name: '群系名称', description: '群系的英文标识'},
|
||||
},
|
||||
},
|
||||
dimension: {
|
||||
lang: 'zh-CN', name: '当前维度', description: '机器人所在的世界维度',
|
||||
values: {
|
||||
overworld: {lang: 'zh-CN', name: '主世界', description: '主世界维度'},
|
||||
the_nether: {lang: 'zh-CN', name: '地狱', description: '下界维度'},
|
||||
the_end: {lang: 'zh-CN', name: '末地', description: '末地维度'},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
lang: 'zh-CN', name: '当前天气', description: '世界天气状态,局部降水表现由群系决定',
|
||||
values: {
|
||||
clear: {lang: 'zh-CN', name: '晴天', description: '当前没有降水'},
|
||||
rain: {lang: 'zh-CN', name: '降水', description: '当前正在降水,寒冷群系表现为降雪'},
|
||||
thunder: {lang: 'zh-CN', name: '雷暴', description: '当前正在发生雷暴'},
|
||||
},
|
||||
},
|
||||
light: {
|
||||
lang: 'zh-CN', name: '当前位置光照', description: '当前位置的原始光照等级,不包含昼夜和天气衰减',
|
||||
fields: {
|
||||
block: {lang: 'zh-CN', name: '方块光照', description: '方块光源产生的光照等级,范围为 0 至 15'},
|
||||
sky: {lang: 'zh-CN', name: '天空光照', description: '天空光照等级,范围为 0 至 15'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies InfoI18n;
|
||||
|
||||
private world: WorldInfo = {};
|
||||
private disposed = false;
|
||||
private readonly listeners = new Set<(data: WorldInfo) => void>();
|
||||
private readonly events = ['time', 'rain', 'game', 'spawn', 'respawn', 'move', 'forcedMove', 'physicsTick', 'chunkColumnLoad', 'chunkColumnUnload', 'blockUpdate'] as const;
|
||||
|
||||
constructor(private readonly instance: Bot) {
|
||||
this.handle_world();
|
||||
for (const event of this.events) instance.on(event, this.handle_world);
|
||||
}
|
||||
|
||||
private readonly handle_world = (): void => {
|
||||
if (this.disposed) return;
|
||||
|
||||
const bot = this.instance;
|
||||
const world: WorldInfo = {};
|
||||
if (typeof bot.time?.time === 'number') {
|
||||
world.time = {
|
||||
time: bot.time.time,
|
||||
time_of_day: bot.time.timeOfDay,
|
||||
day: bot.time.day,
|
||||
is_day: bot.time.isDay,
|
||||
};
|
||||
}
|
||||
if (typeof bot.game?.dimension === 'string') {
|
||||
world.dimension = bot.game.dimension.replace(/^minecraft:/, '');
|
||||
}
|
||||
if (typeof bot.isRaining === 'boolean') {
|
||||
// Nether and End have no weather, even if the server retains rain state.
|
||||
const no_weather = world.dimension === 'the_nether' || world.dimension === 'the_end';
|
||||
world.weather = no_weather || !bot.isRaining ? 'clear'
|
||||
: bot.thunderState > 0 ? 'thunder' : 'rain';
|
||||
}
|
||||
const pos = bot.entity?.position;
|
||||
const block = pos && bot.blockAt(pos, false);
|
||||
if (block) {
|
||||
if (block.biome) {
|
||||
world.biome = {
|
||||
id: block.biome.id,
|
||||
name: block.biome.name,
|
||||
};
|
||||
}
|
||||
world.light = {block: block.light, sky: block.skyLight};
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.world) === JSON.stringify(world)) return;
|
||||
this.world = world;
|
||||
for (const callback of Array.from(this.listeners)) callback(world);
|
||||
};
|
||||
|
||||
get_info(): WorldInfo {
|
||||
return this.world;
|
||||
}
|
||||
|
||||
on_update(callback: (data: WorldInfo) => void): () => void {
|
||||
if (this.disposed) throw new Error('WorldInfo 已销毁');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
|
||||
readonly dispose = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const event of this.events) this.instance.off(event, this.handle_world);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {readdir} from 'node:fs/promises';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoConstructor, InfoContext, InfoI18n} from './types.ts';
|
||||
|
||||
export interface InfoSnapshot {
|
||||
readonly name: string;
|
||||
readonly i18n: InfoI18n;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
export interface InfoUpdate {
|
||||
readonly name: string;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
export interface InfoManager {
|
||||
get_info(name: string): InfoSnapshot | undefined;
|
||||
get_all_info(): InfoSnapshot[];
|
||||
/** Subscribes to future changes; returns an idempotent unsubscribe function. */
|
||||
on_update(callback: (event: InfoUpdate) => void): () => void;
|
||||
/** Safe to call repeatedly, including before the bot emits end. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Scans bot/ and instance/ recursively and creates fresh modules for this bot. */
|
||||
export async function create_info_manager(bot: Bot, options?: InfoContext): Promise<InfoManager> {
|
||||
const infos = new Map<string, BaseInfo<unknown>>();
|
||||
const subscriptions = new Set<() => void>();
|
||||
const listeners = new Set<(event: InfoUpdate) => void>();
|
||||
let disposed = false;
|
||||
|
||||
const assert_active = () => {
|
||||
if (disposed) throw new Error('Info manager has been disposed');
|
||||
};
|
||||
const cleanup = (callback: () => void) => {
|
||||
try { callback(); }
|
||||
catch (error) { console.error('Info manager cleanup failed', error); }
|
||||
};
|
||||
const snapshot = (info: BaseInfo<unknown>): InfoSnapshot => ({
|
||||
name: info.name,
|
||||
i18n: {...info.i18n},
|
||||
value: structuredClone(info.get_info()),
|
||||
});
|
||||
const manager: InfoManager = {
|
||||
get_info(name) {
|
||||
const info = infos.get(name);
|
||||
return info ? snapshot(info) : undefined;
|
||||
},
|
||||
get_all_info() {
|
||||
return Array.from(infos.values(), snapshot);
|
||||
},
|
||||
on_update(callback) {
|
||||
assert_active();
|
||||
// Each subscription owns its cancellation, even for the same callback.
|
||||
const listener = (event: InfoUpdate) => callback(event);
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
bot.off('end', manager.dispose);
|
||||
listeners.clear();
|
||||
for (const unsubscribe of subscriptions) cleanup(unsubscribe);
|
||||
subscriptions.clear();
|
||||
for (const info of infos.values()) cleanup(() => info.dispose());
|
||||
infos.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// Install before the first await so an end during loading also cancels creation.
|
||||
bot.once('end', manager.dispose);
|
||||
try {
|
||||
const directories = [new URL('./bot/', import.meta.url), new URL('./instance/', import.meta.url)];
|
||||
const modules: URL[] = [];
|
||||
for (const directory of directories) {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
assert_active();
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry.name)) {
|
||||
directories.push(new URL(`${entry.name}/`, directory));
|
||||
} else if (entry.isFile() && /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) {
|
||||
modules.push(new URL(entry.name, directory));
|
||||
}
|
||||
}
|
||||
}
|
||||
modules.sort((a, b) => a.href.localeCompare(b.href));
|
||||
for (const url of modules) {
|
||||
let info: BaseInfo<unknown> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
assert_active();
|
||||
url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`);
|
||||
const module = await import(url.href) as {init?: unknown};
|
||||
assert_active();
|
||||
if (typeof module.init !== 'function') {
|
||||
throw new Error('Info module must export an init constructor');
|
||||
}
|
||||
const candidate = new (module.init as InfoConstructor<unknown>)(bot, options);
|
||||
if (!candidate || typeof candidate.dispose !== 'function') {
|
||||
throw new Error('Info module must implement dispose()');
|
||||
}
|
||||
// Keep ownership locally until validation and subscription both succeed.
|
||||
info = candidate;
|
||||
assert_active();
|
||||
if (typeof info.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(info.name)
|
||||
|| !info.i18n || typeof info.i18n.lang !== 'string'
|
||||
|| typeof info.i18n.name !== 'string' || typeof info.i18n.description !== 'string'
|
||||
|| typeof info.get_info !== 'function' || typeof info.on_update !== 'function') {
|
||||
throw new Error('Info module does not implement BaseInfo');
|
||||
}
|
||||
const name = info.name;
|
||||
if (infos.has(name)) throw new Error(`Duplicate info name: ${name}`);
|
||||
const cancel = info.on_update(value => {
|
||||
if (disposed) return;
|
||||
for (const listener of Array.from(listeners)) {
|
||||
if (disposed) break;
|
||||
if (!listeners.has(listener)) continue;
|
||||
try { listener({name, value: structuredClone(value)}); }
|
||||
catch (error) { console.error(`Info update listener failed: ${name}`, error); }
|
||||
}
|
||||
});
|
||||
if (typeof cancel !== 'function') {
|
||||
throw new Error('Info on_update() must return an unsubscribe function');
|
||||
}
|
||||
unsubscribe = cancel;
|
||||
assert_active();
|
||||
infos.set(name, info);
|
||||
subscriptions.add(unsubscribe);
|
||||
} catch (error) {
|
||||
if (unsubscribe) cleanup(unsubscribe);
|
||||
if (info) cleanup(() => info!.dispose());
|
||||
throw new Error(`Info module ${url.pathname} initialization failed`, {cause: error});
|
||||
}
|
||||
}
|
||||
assert_active();
|
||||
return manager;
|
||||
} catch (error) {
|
||||
manager.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import {instrument_bot, metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface CpuInfo {
|
||||
scope: 'bot_context';
|
||||
estimated: true;
|
||||
method: 'instrumented_sync_time';
|
||||
usage_percent: number | null;
|
||||
sampled_time_ms: number;
|
||||
interval_ms: number;
|
||||
coverage: string;
|
||||
}
|
||||
|
||||
const coverage = '计量 Bot.emit 和初始 _client.emit 监听器的同步执行(包括数据包监听器和 physicsTick),'
|
||||
+ '以及函数 run 的同步调用阶段;嵌套调用去重。不包含 emit 前的协议解析、异步等待及后续执行、'
|
||||
+ '无关定时器、上述入口之外的插件执行和 BotContext 通知。'
|
||||
+ '同步经过时间包含垃圾回收和操作系统调度暂停,因此该指标不是进程 CPU 使用率。';
|
||||
|
||||
export class init extends ResourceInfo<CpuInfo> {
|
||||
readonly name = 'used_cpu';
|
||||
readonly i18n = metadata('上下文 CPU 占用估算', coverage, {
|
||||
scope: ['归属范围', '指标归属于机器人上下文,固定为 bot_context'],
|
||||
estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'],
|
||||
method: ['计量方法', '通过插桩计量同步经过时间,不使用操作系统 CPU 计数器'],
|
||||
usage_percent: ['占用比例', '采样同步耗时除以实际经过时间再乘以 100,单位为百分比;首次采样前为 null'],
|
||||
sampled_time_ms: ['采样同步耗时', '本次采样间隔内去重后的同步执行耗时,单位为毫秒'],
|
||||
interval_ms: ['采样间隔', '通过单调时钟测得的实际经过时间,单位为毫秒'],
|
||||
coverage: ['计量覆盖范围', '同步执行计量包含的入口及未覆盖的执行阶段'],
|
||||
});
|
||||
private readonly meter: ReturnType<typeof instrument_bot>;
|
||||
constructor(bot: Bot) {
|
||||
super({scope: 'bot_context', estimated: true, method: 'instrumented_sync_time',
|
||||
usage_percent: null, sampled_time_ms: 0, interval_ms: 0, coverage});
|
||||
this.meter = instrument_bot(bot);
|
||||
let time = performance.now();
|
||||
let total = this.meter.total();
|
||||
this.start(1000, () => {
|
||||
const now = performance.now();
|
||||
const next = this.meter.total();
|
||||
const elapsed = now - time;
|
||||
const sampled = Math.max(0, next - total);
|
||||
time = now;
|
||||
total = next;
|
||||
this.publish({...this.info, usage_percent: elapsed > 0 ? sampled / elapsed * 100 : null,
|
||||
sampled_time_ms: sampled, interval_ms: elapsed});
|
||||
});
|
||||
}
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.meter.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {setImmediate as yield_batch} from 'node:timers/promises';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {InfoI18n} from '../types.ts';
|
||||
import {metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface MemoryInfo {
|
||||
estimated: true;
|
||||
total_bytes: number;
|
||||
chunks_bytes: number;
|
||||
entities_bytes: number;
|
||||
inventory_bytes: number;
|
||||
other_bytes: number;
|
||||
partial: boolean;
|
||||
categories_complete: Record<Category, boolean>;
|
||||
}
|
||||
|
||||
type Category = 'chunks' | 'entities' | 'inventory' | 'other';
|
||||
const excluded = new Set(['registry', 'world', '_client', 'client', 'bot', 'global', 'globalThis',
|
||||
'_events', '_eventsCount', '_maxListeners', '__proto__', 'constructor', 'prototype']);
|
||||
const own = (object: unknown, key: PropertyKey): unknown => object && typeof object === 'object'
|
||||
? Object.getOwnPropertyDescriptor(object, key)?.value : undefined;
|
||||
const typed_buffer = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'buffer')!.get!;
|
||||
const data_buffer = Object.getOwnPropertyDescriptor(DataView.prototype, 'buffer')!.get!;
|
||||
const array_length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')!.get!;
|
||||
const shared_length = Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')!.get!;
|
||||
|
||||
/** Bounded live-data estimate. Objects/slots use approximate JS overhead; backing stores use real byteLength. */
|
||||
export async function estimate_memory(bot: Bot, cancelled = () => false,
|
||||
limits = {nodes: 50000, properties: 200000, milliseconds: 200}): Promise<MemoryInfo> {
|
||||
const info: MemoryInfo = {estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0,
|
||||
inventory_bytes: 0, other_bytes: 0, partial: true,
|
||||
categories_complete: {chunks: false, entities: false, inventory: false, other: false}};
|
||||
const start = performance.now();
|
||||
const seen = new WeakSet<object>();
|
||||
const blocked = new WeakSet<object>();
|
||||
for (const key of ['registry', 'world', '_client']) {
|
||||
const value = own(bot, key);
|
||||
if (value && typeof value === 'object') blocked.add(value);
|
||||
}
|
||||
blocked.add(bot);
|
||||
blocked.add(globalThis);
|
||||
type Task = {value: unknown} | {object: object; keys: PropertyKey[]; index: number; columns?: boolean}
|
||||
| {load_columns: true};
|
||||
const categories: Category[] = ['chunks', 'entities', 'inventory', 'other'];
|
||||
const roots = {entities: ['entity', 'entities', 'players'], inventory: ['inventory', 'currentWindow'],
|
||||
other: ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist']};
|
||||
// Fixed category order and DFS order make first-visit ownership deterministic, including backing stores.
|
||||
const states = categories.map((category, index) => ({category,
|
||||
stack: (category === 'chunks' ? [{load_columns: true}]
|
||||
: [{object: bot, keys: roots[category], index: 0}]) as Task[],
|
||||
nodes: 0, properties: 0, elapsed: 0, incomplete: false,
|
||||
node_limit: Math.floor(limits.nodes / 4) + (index < limits.nodes % 4 ? 1 : 0),
|
||||
property_limit: Math.floor(limits.properties / 4) + (index < limits.properties % 4 ? 1 : 0),
|
||||
}));
|
||||
let steps = 0;
|
||||
let processed = 0;
|
||||
while (states.some(state => state.stack.length)) {
|
||||
if (cancelled() || performance.now() - start >= limits.milliseconds) break;
|
||||
const state = states[steps++ % states.length];
|
||||
if (!state.stack.length) continue;
|
||||
if (state.elapsed >= limits.milliseconds / 4) {
|
||||
state.incomplete = true;
|
||||
state.stack.length = 0;
|
||||
continue;
|
||||
}
|
||||
const turn = performance.now();
|
||||
const {stack, category} = state;
|
||||
const add = (bytes: number) => { info[`${category}_bytes`] += bytes; };
|
||||
const cap = () => { state.incomplete = true; stack.length = 0; };
|
||||
const task = stack.pop()!;
|
||||
try {
|
||||
if ('load_columns' in task) {
|
||||
// WorldSync exposes getColumns on its prototype; resolve descriptors without invoking getters.
|
||||
const world = own(bot, 'world');
|
||||
const world_descriptor = Object.getOwnPropertyDescriptor(bot, 'world');
|
||||
if (world_descriptor && !('value' in world_descriptor)) state.incomplete = true;
|
||||
let target = world;
|
||||
let descriptor: PropertyDescriptor | undefined;
|
||||
for (let depth = 0; target && depth < 16; depth++, target = Object.getPrototypeOf(target)) {
|
||||
descriptor = Object.getOwnPropertyDescriptor(target, 'getColumns');
|
||||
if (descriptor) break;
|
||||
}
|
||||
if (world && typeof descriptor?.value !== 'function') state.incomplete = true;
|
||||
else if (world) {
|
||||
const columns = Reflect.apply(descriptor!.value, world, []);
|
||||
const length = own(columns, 'length');
|
||||
if (!Array.isArray(columns) || typeof length !== 'number') state.incomplete = true;
|
||||
else {
|
||||
if (length > 4096) state.incomplete = true;
|
||||
// Extraction is incremental and charged to the chunks property budget.
|
||||
stack.push({object: columns,
|
||||
keys: Array.from({length: Math.min(length, 4096)}, (_, i) => String(i)),
|
||||
index: 0, columns: true});
|
||||
}
|
||||
}
|
||||
} else if ('object' in task) {
|
||||
if (task.index < task.keys.length) {
|
||||
if (state.properties >= state.property_limit) cap();
|
||||
else {
|
||||
state.properties++;
|
||||
const key = task.keys[task.index++];
|
||||
if (task.index < task.keys.length) stack.push(task);
|
||||
if (typeof key !== 'string' || !excluded.has(key)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(task.object, key);
|
||||
if (descriptor && !('value' in descriptor)) state.incomplete = true;
|
||||
else if (descriptor) {
|
||||
if (task.columns) {
|
||||
const column = own(descriptor.value, 'column');
|
||||
if (column && typeof column === 'object') stack.push({value: column});
|
||||
else state.incomplete = true;
|
||||
} else {
|
||||
if (task.object !== bot) add(8 + (typeof key === 'string' ? key.length * 2 : 0));
|
||||
if (typeof descriptor.value !== 'function') stack.push({value: descriptor.value});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const {value} = task;
|
||||
if (typeof value === 'string') add(value.length * 2);
|
||||
else if (typeof value === 'number' || typeof value === 'bigint') add(8);
|
||||
else if (typeof value === 'boolean') add(4);
|
||||
else if (value && typeof value === 'object' && !seen.has(value) && !blocked.has(value)) {
|
||||
if (state.nodes >= state.node_limit) { cap(); continue; }
|
||||
seen.add(value);
|
||||
state.nodes++;
|
||||
add(32);
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
// Includes a pooled Buffer's full backing allocation once, even for overlapping views.
|
||||
stack.push({value: Reflect.apply(value instanceof DataView ? data_buffer : typed_buffer, value, [])});
|
||||
} else if (value instanceof ArrayBuffer || value instanceof SharedArrayBuffer) {
|
||||
add(Reflect.apply(value instanceof ArrayBuffer ? array_length : shared_length, value, []));
|
||||
} else {
|
||||
// Never read property values directly: accessors and prototypes are deliberately omitted.
|
||||
const keys = Reflect.ownKeys(value);
|
||||
const remaining = Math.max(0, state.property_limit - state.properties);
|
||||
if (keys.length > remaining) { keys.length = remaining; state.incomplete = true; }
|
||||
if (keys.length) stack.push({object: value, keys, index: 0});
|
||||
if (value instanceof Map || value instanceof Set) state.incomplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { state.incomplete = true; }
|
||||
finally { state.elapsed += performance.now() - turn; }
|
||||
if (++processed % 256 === 0) await yield_batch();
|
||||
}
|
||||
for (const state of states) info.categories_complete[state.category] = !state.incomplete && !state.stack.length;
|
||||
info.partial = Object.values(info.categories_complete).some(complete => !complete);
|
||||
info.total_bytes = info.chunks_bytes + info.entities_bytes + info.inventory_bytes + info.other_bytes;
|
||||
return info;
|
||||
}
|
||||
|
||||
export class init extends ResourceInfo<MemoryInfo> {
|
||||
readonly name = 'used_memory';
|
||||
readonly i18n: InfoI18n = {...metadata('自有数据内存估算', '仅通过自身属性描述符分批估算当前数据,不代表堆内存或进程常驻内存。'
|
||||
+ '扫描根为已加载的区块列对象(最多 4096 个)、entity/entities/players 和 inventory/currentWindow。'
|
||||
+ '其他分类仅扫描本实例的 game、time、settings、scoreboards、teams、tablist 自有数据根。'
|
||||
+ '排除注册表、世界对象遍历、客户端、全局对象、监听器、函数和原型;不调用属性 getter。'
|
||||
+ '按区块、实体、物品栏、其他的固定顺序逐步轮转,共享对象及底层缓冲区归首次访问分类且只计一次,池化缓冲区按完整底层分配大小计入。'
|
||||
+ '对象和字符串开销为近似值,不统计 Map/Set 内部数据。上限为 50000 个节点、200000 个属性和 200 毫秒,'
|
||||
+ '节点、属性和同步处理时间各分类至多使用四分之一,总时限包含让出事件循环的时间。'
|
||||
+ '每轮只处理一个值或属性,每 256 步让出事件循环;getColumns 和 ownKeys 枚举本身仍同步执行,单次调用可能超时。'
|
||||
+ '完成状态仅针对上述有限范围;未完成分类的零值不表示没有内存占用。', {}), fields: {
|
||||
...metadata('', '', {
|
||||
estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'],
|
||||
total_bytes: ['估算总内存', '各分类已扫描部分内存估算值之和,单位为字节;partial 为 true 时不是完整总量'],
|
||||
chunks_bytes: ['区块内存', '已加载区块列自身数据的内存估算,单位为字节'],
|
||||
entities_bytes: ['实体内存', '实体和玩家自身数据的内存估算,单位为字节'],
|
||||
inventory_bytes: ['物品栏内存', '物品栏和当前窗口自身数据的内存估算,单位为字节'],
|
||||
other_bytes: ['其他内存', '仅 game、time、settings、scoreboards、teams、tablist 自有数据的内存估算,单位为字节'],
|
||||
partial: ['结果是否不完整', '首次采样尚未完成,或因扫描上限、取消、不支持或无法访问而遗漏了数据'],
|
||||
}).fields,
|
||||
categories_complete: metadata('分类完成状态', '各分类是否完成约定范围的扫描;初始均为 false,false 时字节数仅为已扫描部分,零值不能说明实际无占用。', {
|
||||
chunks: ['区块扫描完成', '已扫描约定的全部区块列且未触及上限或遗漏数据时为 true'],
|
||||
entities: ['实体扫描完成', '已扫描 entity、entities、players 且未触及上限或遗漏数据时为 true'],
|
||||
inventory: ['物品栏扫描完成', '已扫描 inventory、currentWindow 且未触及上限或遗漏数据时为 true'],
|
||||
other: ['其他扫描完成', '已扫描 game、time、settings、scoreboards、teams、tablist 且未触及上限或遗漏数据时为 true'],
|
||||
}),
|
||||
}};
|
||||
constructor(bot: Bot) {
|
||||
super({estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0,
|
||||
inventory_bytes: 0, other_bytes: 0, partial: true,
|
||||
categories_complete: {chunks: false, entities: false, inventory: false, other: false}});
|
||||
this.start(5000, async () => { this.publish(await estimate_memory(bot, () => this.disposed)); }, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import {metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface NetworkInfo {
|
||||
available: boolean;
|
||||
received_bytes: number;
|
||||
sent_bytes: number;
|
||||
received_bytes_per_second: number | null;
|
||||
sent_bytes_per_second: number | null;
|
||||
interval_ms: number;
|
||||
}
|
||||
|
||||
/** Totals include the first observed counters of every new socket. Unobserved old-socket tails are unknowable. */
|
||||
export class NetworkCounter {
|
||||
private socket: object | undefined;
|
||||
private read = 0;
|
||||
private written = 0;
|
||||
private time: number | undefined;
|
||||
private received = 0;
|
||||
private sent = 0;
|
||||
sample(socket: {bytesRead?: number; bytesWritten?: number} | undefined, now: number): NetworkInfo {
|
||||
const read = socket?.bytesRead;
|
||||
const written = socket?.bytesWritten;
|
||||
const available = typeof read === 'number' && Number.isFinite(read) && read >= 0
|
||||
&& typeof written === 'number' && Number.isFinite(written) && written >= 0;
|
||||
const elapsed = this.time === undefined ? 0 : now - this.time;
|
||||
const continuous = available && socket === this.socket && this.time !== undefined
|
||||
&& read >= this.read && written >= this.written && elapsed > 0;
|
||||
const dr = available ? (socket === this.socket && read >= this.read ? read - this.read : read) : 0;
|
||||
const dw = available ? (socket === this.socket && written >= this.written ? written - this.written : written) : 0;
|
||||
this.received += dr;
|
||||
this.sent += dw;
|
||||
if (available) {
|
||||
this.socket = socket;
|
||||
this.read = read;
|
||||
this.written = written;
|
||||
}
|
||||
this.time = available ? now : undefined;
|
||||
return {available, received_bytes: this.received, sent_bytes: this.sent,
|
||||
received_bytes_per_second: continuous ? dr * 1000 / elapsed : null,
|
||||
sent_bytes_per_second: continuous ? dw * 1000 / elapsed : null, interval_ms: continuous ? elapsed : 0};
|
||||
}
|
||||
}
|
||||
|
||||
export class init extends ResourceInfo<NetworkInfo> {
|
||||
readonly name = 'used_network';
|
||||
readonly i18n = metadata('机器人网络流量', '读取传输层套接字的字节计数,包含协议开销;'
|
||||
+ '更换套接字后保留累计观察量,不包含旧套接字被替换前未观察到的尾部流量。', {
|
||||
available: ['计数是否可用', '套接字是否同时提供有效的接收和发送字节计数'],
|
||||
received_bytes: ['累计接收量', '累计观察到的接收流量,单位为字节'],
|
||||
sent_bytes: ['累计发送量', '累计观察到的发送流量,单位为字节'],
|
||||
received_bytes_per_second: ['接收速率', '接收流量速率,单位为字节每秒;缺少连续采样基线时为 null'],
|
||||
sent_bytes_per_second: ['发送速率', '发送流量速率,单位为字节每秒;缺少连续采样基线时为 null'],
|
||||
interval_ms: ['速率采样间隔', '通过单调时钟测得的实际速率采样间隔,单位为毫秒;缺少连续采样基线时为 0'],
|
||||
});
|
||||
constructor(bot: Bot) {
|
||||
const counter = new NetworkCounter();
|
||||
super(counter.sample(bot._client?.socket, performance.now()));
|
||||
this.start(1000, () => { this.publish(counter.sample(bot._client?.socket, performance.now())); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {lstat, opendir} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import {get_project_root_path} from '../../../utils/path_utils.ts';
|
||||
import type {InfoContext} from '../types.ts';
|
||||
import {metadata, ResourceInfo} from '../resource_metrics.ts';
|
||||
|
||||
export interface StorageInfo {
|
||||
available: boolean;
|
||||
total_bytes: number | null;
|
||||
file_count: number;
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
/** lstat and streamed directory enumeration never intentionally follow symlinks. Live mutation can race a scan. */
|
||||
export async function scan_storage(directory: string, cancelled = () => false,
|
||||
limits = {entries: 20000, depth: 32, milliseconds: 2000}): Promise<StorageInfo> {
|
||||
const info: StorageInfo = {available: false, total_bytes: null, file_count: 0, partial: false};
|
||||
const start = performance.now();
|
||||
let entries = 0;
|
||||
const pending: [string, number][] = [[directory, 0]];
|
||||
while (pending.length) {
|
||||
if (cancelled() || entries >= limits.entries || performance.now() - start >= limits.milliseconds) {
|
||||
info.partial = true; break;
|
||||
}
|
||||
const [current, depth] = pending.pop()!;
|
||||
entries++;
|
||||
try {
|
||||
const stat = await lstat(current);
|
||||
if (current === directory) {
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) { info.partial = true; break; }
|
||||
info.available = true;
|
||||
info.total_bytes = 0;
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
if (stat.isFile()) {
|
||||
info.total_bytes! += stat.size;
|
||||
info.file_count++;
|
||||
} else if (stat.isDirectory()) {
|
||||
if (depth >= limits.depth) { info.partial = true; continue; }
|
||||
const dir = await opendir(current);
|
||||
for await (const entry of dir) {
|
||||
if (cancelled() || entries + pending.length >= limits.entries
|
||||
|| performance.now() - start >= limits.milliseconds) { info.partial = true; break; }
|
||||
if (!entry.isSymbolicLink()) pending.push([path.join(current, entry.name), depth + 1]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (current === directory && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
info.available = true;
|
||||
info.total_bytes = 0;
|
||||
} else info.partial = true;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
export class init extends ResourceInfo<StorageInfo> {
|
||||
readonly name = 'used_storage';
|
||||
readonly i18n = metadata('机器人数据存储', '统计 data/bot/<bot_id> 下文件的逻辑大小,跳过符号链接。'
|
||||
+ '每 30 秒尽力扫描一次,不重叠执行;上限为 20000 个条目、32 层深度和 2 秒。'
|
||||
+ '扫描期间的并发文件变动可能影响结果;该指标不代表实际分配的磁盘空间。', {
|
||||
available: ['存储统计是否可用', '目录可访问或不存在时为 true,不存在按 0 统计;缺少有效机器人编号或根目录不可访问时为 false'],
|
||||
total_bytes: ['文件总大小', '已观察到的普通文件逻辑大小之和,单位为字节;不可用时为 null'],
|
||||
file_count: ['文件数量', '已观察到的普通文件数量'],
|
||||
partial: ['结果是否不完整', '因扫描上限、取消或错误而未完成扫描;保留已读取文件的统计小计'],
|
||||
});
|
||||
constructor(_bot: Bot, options?: InfoContext) {
|
||||
super({available: false, total_bytes: null, file_count: 0, partial: true});
|
||||
if (!Number.isSafeInteger(options?.bot_id) || options!.bot_id! <= 0) return;
|
||||
const directory = path.join(get_project_root_path(), 'data', 'bot', String(options!.bot_id));
|
||||
this.start(30000, async () => { this.publish(await scan_storage(directory, () => this.disposed)); }, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseInfo, InfoI18n} from './types.ts';
|
||||
|
||||
type Meter = {depth: number; total: number};
|
||||
const meters = new WeakMap<object, Meter>();
|
||||
|
||||
/** Only the synchronous invocation is timed, even when it returns a Promise. */
|
||||
export function measure_sync<T>(bot: object, invoke: () => T): T {
|
||||
const meter = meters.get(bot);
|
||||
if (!meter) return invoke();
|
||||
const outer = meter.depth++ === 0;
|
||||
const start = outer ? performance.now() : 0;
|
||||
try { return invoke(); }
|
||||
finally {
|
||||
meter.depth--;
|
||||
if (outer) meter.total += performance.now() - start;
|
||||
}
|
||||
}
|
||||
|
||||
export function instrument_bot(bot: Bot): {total: () => number; dispose: () => void} {
|
||||
if (meters.has(bot)) throw new Error('Bot resource meter already installed');
|
||||
const meter: Meter = {depth: 0, total: 0};
|
||||
meters.set(bot, meter);
|
||||
const restore: (() => void)[] = [];
|
||||
try {
|
||||
for (const target of new Set([bot, bot._client])) {
|
||||
if (!target || typeof target.emit !== 'function') continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, 'emit');
|
||||
const original = target.emit;
|
||||
const wrapper = function(this: typeof target, ...args: Parameters<typeof original>) {
|
||||
return measure_sync(bot, () => Reflect.apply(original, this, args));
|
||||
};
|
||||
Object.defineProperty(target, 'emit', {configurable: true, writable: true,
|
||||
enumerable: descriptor?.enumerable ?? false, value: wrapper});
|
||||
restore.push(() => {
|
||||
if (Object.getOwnPropertyDescriptor(target, 'emit')?.value !== wrapper) return;
|
||||
if (descriptor) Object.defineProperty(target, 'emit', descriptor);
|
||||
else delete (target as unknown as {emit?: unknown}).emit;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
for (const undo of restore.reverse()) undo();
|
||||
meters.delete(bot);
|
||||
throw error;
|
||||
}
|
||||
let disposed = false;
|
||||
return {total: () => meter.total, dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
meters.delete(bot);
|
||||
for (const undo of restore.reverse()) undo();
|
||||
}};
|
||||
}
|
||||
|
||||
export function metadata(name: string, description: string, fields: Record<string, readonly [string, string]>): InfoI18n {
|
||||
return {lang: 'zh-CN', name, description, fields: Object.fromEntries(Object.entries(fields).map(
|
||||
([key, [name, description]]) => [key, {lang: 'zh-CN', name, description}],
|
||||
))};
|
||||
}
|
||||
|
||||
/** No module owns bot end listeners; InfoManager owns disposal. */
|
||||
export abstract class ResourceInfo<T> implements BaseInfo<T> {
|
||||
abstract readonly name: string;
|
||||
abstract readonly i18n: InfoI18n;
|
||||
protected disposed = false;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private running = false;
|
||||
private readonly listeners = new Set<(value: T) => void>();
|
||||
constructor(protected info: T) {}
|
||||
get_info(): T { return this.info; }
|
||||
on_update(callback: (value: T) => void): () => void {
|
||||
if (this.disposed) throw new Error('Resource info disposed');
|
||||
this.listeners.add(callback);
|
||||
return () => { this.listeners.delete(callback); };
|
||||
}
|
||||
protected publish(info: T): void {
|
||||
if (this.disposed) return;
|
||||
this.info = info;
|
||||
for (const listener of Array.from(this.listeners)) {
|
||||
if (this.disposed) break;
|
||||
try { listener(info); }
|
||||
catch (error) { console.error('Resource info listener failed', error); }
|
||||
}
|
||||
}
|
||||
protected start(interval: number, sample: () => void | Promise<void>, immediate = false): void {
|
||||
const run = async () => {
|
||||
if (this.running || this.disposed) return;
|
||||
this.running = true;
|
||||
try { await sample(); }
|
||||
catch (error) { if (!this.disposed) console.error('Resource sampling failed', error); }
|
||||
finally { this.running = false; }
|
||||
};
|
||||
this.timer = setInterval(() => { void run(); }, interval);
|
||||
this.timer.unref?.();
|
||||
if (immediate) void run();
|
||||
}
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
clearInterval(this.timer);
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {PluginSnapshot} from '../plugins/init.ts';
|
||||
import type {RunSnapshot, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts';
|
||||
|
||||
export interface InfoI18n {
|
||||
readonly lang: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly fields?: Readonly<Record<string, InfoI18n>>;
|
||||
readonly values?: Readonly<Record<string, InfoI18n>>;
|
||||
}
|
||||
|
||||
/** 一个信息对象绑定一个 Bot 实例;由信息管理器统一监听 end 并销毁,单独使用时由调用方负责销毁。 */
|
||||
export interface BaseInfo<T> {
|
||||
/** 稳定的模块标识,不使用翻译后的显示名称。 */
|
||||
readonly name: string;
|
||||
readonly i18n: InfoI18n;
|
||||
|
||||
/** 同步读取当前值;销毁后仍可读取最后保存的值。 */
|
||||
get_info(): T;
|
||||
|
||||
/**
|
||||
* 订阅后续变化,不立即回调;初始值通过 get_info() 获取。
|
||||
* 返回的取消函数可重复调用,销毁后禁止新增订阅并抛出错误。
|
||||
* 回调同步执行,订阅者应自行处理异常。
|
||||
*/
|
||||
on_update(callback: (data: T) => void): () => void;
|
||||
|
||||
/** 移除 Bot 事件监听和全部订阅;可重复调用,销毁后不再发布更新。 */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** 构造签名单独约束类本身,不属于 BaseInfo 的实例成员。 */
|
||||
export interface InfoConstructor<T> {
|
||||
new (instance: Bot, options?: InfoContext): BaseInfo<T>;
|
||||
}
|
||||
|
||||
export interface InfoContext {
|
||||
readonly bot_id?: number;
|
||||
readonly plugin_source?: {
|
||||
get_all_plugins(): PluginSnapshot[];
|
||||
on_update(callback: (event: PluginSnapshot) => void): () => void;
|
||||
};
|
||||
readonly workflow_source?: {
|
||||
get_snapshots(): RunSnapshot[];
|
||||
get_runtime_snapshot?(): import('../../types/bot/workflow/runtime.ts').WorkflowRuntimeSnapshot;
|
||||
on_update(callback: (event: WorkflowRunEvent) => void): () => void;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user