Compare commits

...

5 Commits

Author SHA1 Message Date
SikongJueluo
7e03d960bd fix: wrong type, chat manager unicode string; feature: accesscontrol welcome message and chinese support 2025-11-02 21:04:12 +08:00
SikongJueluo
f76a3666b1 feature: chat manager support utf8 2025-11-01 16:58:18 +08:00
SikongJueluo
d6971fb22f fix: cli framework help option not work 2025-11-01 14:34:19 +08:00
SikongJueluo
796bf1c2dc feature: global timer manager; reconstruct: accesscontrol; fix: chat manager
feature:
- add global timer manager
reconstruct:
- use new cli framework for accesscontrol
- use chat manager for accesscontrol
fix:
- chat manager only send one time
2025-11-01 13:16:42 +08:00
SikongJueluo
959ec0c424 feature: ChatManager
feature:
- multi chatboxes manage
- chatbox message queue
2025-10-30 12:58:53 +08:00
12 changed files with 1705 additions and 664 deletions

View File

@@ -1,3 +1,5 @@
import { Command, createCli } from "@/lib/ccCLI";
import { Ok } from "@/lib/thirdparty/ts-result-es";
import { CCLog } from "@/lib/ccLog"; import { CCLog } from "@/lib/ccLog";
import { import {
AccessConfig, AccessConfig,
@@ -5,574 +7,382 @@ import {
loadConfig, loadConfig,
saveConfig, saveConfig,
} from "./config"; } from "./config";
import { ChatBoxEvent, pullEventAs } from "@/lib/event";
import { parseBoolean } from "@/lib/common"; import { parseBoolean } from "@/lib/common";
// CLI命令接口 // 1. Define AppContext
interface CLICommand { export interface AppContext {
name: string;
description: string;
usage: string;
execute: (args: string[], executor: string, context: CLIContext) => CLIResult;
}
// CLI执行结果
interface CLIResult {
success: boolean;
message?: string;
shouldSaveConfig?: boolean;
config?: AccessConfig;
}
// CLI上下文
interface CLIContext {
configFilepath: string; configFilepath: string;
reloadConfig: () => void; reloadConfig: () => void;
log: CCLog; logger: CCLog;
chatBox: ChatBoxPeripheral; print: (
message: string | MinecraftTextComponent | MinecraftTextComponent[],
) => void;
} }
function getGroupNames(config: AccessConfig) { function getGroupNames(config: AccessConfig) {
return config.usersGroups.flatMap((value) => value.groupName); return config.usersGroups.map((value) => value.groupName);
} }
// 基础命令处理器 // 2. Define Commands
class CLICommandProcessor {
private commands = new Map<string, CLICommand>();
private context: CLIContext;
constructor(context: CLIContext) { const addCommand: Command<AppContext> = {
this.context = context; name: "add",
this.initializeCommands(); description: "添加玩家到用户组",
} args: [
{
private initializeCommands() { name: "userGroup",
// 注册所有命令 description: "要添加到的用户组",
this.registerCommand(new AddCommand()); required: true,
this.registerCommand(new DelCommand()); },
this.registerCommand(new ListCommand()); { name: "playerName", description: "要添加的玩家", required: true },
this.registerCommand(new SetCommand()); ],
this.registerCommand(new EditCommand()); action: ({ args, context }) => {
this.registerCommand(new ShowConfigCommand()); const [groupName, playerName] = [
this.registerCommand(new HelpCommand()); args.userGroup as string,
} args.playerName as string,
];
private registerCommand(command: CLICommand) {
this.commands.set(command.name, command);
}
public processCommand(message: string, executor: string): CLIResult {
const params = message.split(" ");
// 移除 "@AC" 前缀
if (params.length < 2) {
return this.getHelpCommand().execute([], executor, this.context);
}
const commandName = params[1].replace("/", ""); // 移除 "/" 前缀
const args = params.slice(2);
const command = this.commands.get(commandName);
if (!command) {
return {
success: false,
message: `Unknown command: ${commandName}`,
};
}
const ret = command.execute(args, executor, this.context);
return ret;
}
private getHelpCommand(): CLICommand {
return this.commands.get("help")!;
}
public sendResponse(result: CLIResult, executor: string) {
if (result.message != null && result.message.length > 0) {
this.context.chatBox.sendMessageToPlayer(
result.message,
executor,
"AccessControl",
"[]",
undefined,
undefined,
true,
);
}
if (result.shouldSaveConfig === true) {
saveConfig(result.config!, this.context.configFilepath);
this.context.reloadConfig();
}
}
}
// 添加用户命令
class AddCommand implements CLICommand {
name = "add";
description = "Add player to group";
usage = "add <userGroup> <playerName>";
execute(args: string[], _executor: string, context: CLIContext): CLIResult {
if (args.length !== 2) {
return {
success: false,
message: `Usage: ${this.usage}`,
};
}
const [groupName, playerName] = args;
const config: AccessConfig = loadConfig(context.configFilepath)!;
if (groupName === "admin") {
config.adminGroupConfig.groupUsers.push(playerName);
return {
success: true,
message: `Add player ${playerName} to admin`,
shouldSaveConfig: true,
config,
};
}
const groupNames = getGroupNames(config);
if (!groupNames.includes(groupName)) {
return {
success: false,
message: `Invalid group: ${groupName}. Available groups: ${groupNames.join(
", ",
)}`,
};
}
const groupConfig = config.usersGroups.find(
(value) => value.groupName === groupName,
);
if (!groupConfig) {
return {
success: false,
message: `Group ${groupName} not found`,
};
}
if (groupConfig.groupUsers === undefined) {
groupConfig.groupUsers = [playerName];
} else {
groupConfig.groupUsers.push(playerName);
}
return {
success: true,
message: `Add player ${playerName} to ${groupConfig.groupName}`,
shouldSaveConfig: true,
config,
};
}
}
// 删除用户命令
class DelCommand implements CLICommand {
name = "del";
description = "Delete player from group";
usage = "del <userGroup> <playerName>";
execute(args: string[], _executor: string, context: CLIContext): CLIResult {
if (args.length !== 2) {
return {
success: false,
message: `Usage: ${this.usage}`,
};
}
const [groupName, playerName] = args;
if (groupName === "admin") {
return {
success: false,
message: "Could't delete admin, please edit config",
};
}
const config: AccessConfig = loadConfig(context.configFilepath)!;
const groupNames = getGroupNames(config);
if (!groupNames.includes(groupName)) {
return {
success: false,
message: `Invalid group: ${groupName}. Available groups: ${groupNames.join(
", ",
)}`,
};
}
const groupConfig = config.usersGroups.find(
(value) => value.groupName === groupName,
);
if (!groupConfig) {
return {
success: false,
message: `Group ${groupName} not found`,
};
}
if (groupConfig.groupUsers === undefined) {
groupConfig.groupUsers = [];
} else {
groupConfig.groupUsers = groupConfig.groupUsers.filter(
(user) => user !== playerName,
);
}
return {
success: true,
message: `Delete ${groupConfig.groupName} ${playerName}`,
shouldSaveConfig: true,
config,
};
}
}
// 列表命令
class ListCommand implements CLICommand {
name = "list";
description = "List all players with their groups";
usage = "list";
execute(_args: string[], _executor: string, context: CLIContext): CLIResult {
const config = loadConfig(context.configFilepath)!; const config = loadConfig(context.configFilepath)!;
let message = `Admins : [ ${config.adminGroupConfig.groupUsers.join(", ")} ]\n`;
if (groupName === "admin") {
if (!config.adminGroupConfig.groupUsers.includes(playerName)) {
config.adminGroupConfig.groupUsers.push(playerName);
}
} else {
const group = config.usersGroups.find((g) => g.groupName === groupName);
if (!group) {
const groupNames = getGroupNames(config);
context.print({
text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join(
", ",
)}`,
});
return Ok.EMPTY;
}
group.groupUsers ??= [];
if (!group.groupUsers.includes(playerName)) {
group.groupUsers.push(playerName);
}
}
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: `已添加玩家 ${playerName}${groupName}` });
return Ok.EMPTY;
},
};
const delCommand: Command<AppContext> = {
name: "del",
description: "从用户组删除玩家",
args: [
{
name: "userGroup",
description: "要从中删除玩家的用户组",
required: true,
},
{ name: "playerName", description: "要删除的玩家", required: true },
],
action: ({ args, context }) => {
const [groupName, playerName] = [
args.userGroup as string,
args.playerName as string,
];
if (groupName === "admin") {
context.print({ text: "无法删除管理员, 请直接编辑配置文件。" });
return Ok.EMPTY;
}
const config = loadConfig(context.configFilepath)!;
const group = config.usersGroups.find((g) => g.groupName === groupName);
if (!group) {
const groupNames = getGroupNames(config);
context.print({
text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join(
", ",
)}`,
});
return Ok.EMPTY;
}
if (group.groupUsers !== undefined) {
group.groupUsers = group.groupUsers.filter((user) => user !== playerName);
}
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: `已从 ${groupName} 中删除玩家 ${playerName}` });
return Ok.EMPTY;
},
};
const listCommand: Command<AppContext> = {
name: "list",
description: "列出所有玩家及其所在的用户组",
action: ({ context }) => {
const config = loadConfig(context.configFilepath)!;
let message = `管理员 : [ ${config.adminGroupConfig.groupUsers.join(
", ",
)} ]\n`;
for (const groupConfig of config.usersGroups) { for (const groupConfig of config.usersGroups) {
const users = groupConfig.groupUsers ?? []; const users = groupConfig.groupUsers ?? [];
message += `${groupConfig.groupName} : [ ${users.join(", ")} ]\n`; message += `${groupConfig.groupName} : [ ${users.join(", ")} ]\n`;
} }
context.print({ text: message.trim() });
return { return Ok.EMPTY;
success: true, },
message: message.trim(),
}; };
}
}
// 设置命令 const setCommand: Command<AppContext> = {
class SetCommand implements CLICommand { name: "set",
name = "set"; description: "配置访问控制设置",
description = "Config access control settings"; args: [
usage = "set <option> <value>"; {
name: "option",
execute(args: string[], _executor: string, context: CLIContext): CLIResult { description: "要设置的选项 (warnInterval, detectInterval, detectRange)",
if (args.length !== 2) { required: true,
return { },
success: false, { name: "value", description: "要设置的值", required: true },
message: `Usage: ${this.usage}\nOptions: warnInterval, detectInterval, detectRange`, ],
}; action: ({ args, context }) => {
} const [option, valueStr] = [args.option as string, args.value as string];
const [option, valueStr] = args;
const value = parseInt(valueStr); const value = parseInt(valueStr);
if (isNaN(value)) { if (isNaN(value)) {
return { context.print({ text: `无效的值: ${valueStr}. 必须是一个数字。` });
success: false, return Ok.EMPTY;
message: `Invalid value: ${valueStr}. Must be a number.`,
};
} }
const config: AccessConfig = loadConfig(context.configFilepath)!; const config = loadConfig(context.configFilepath)!;
let message = "";
switch (option) { switch (option) {
case "warnInterval": case "warnInterval":
config.watchInterval = value; config.watchInterval = value;
return { message = `已设置警告间隔为 ${value}`;
success: true, break;
message: `Set warn interval to ${config.watchInterval}`,
shouldSaveConfig: true,
config,
};
case "detectInterval": case "detectInterval":
config.detectInterval = value; config.detectInterval = value;
return { message = `已设置检测间隔为 ${value}`;
success: true, break;
message: `Set detect interval to ${config.detectInterval}`,
shouldSaveConfig: true,
config,
};
case "detectRange": case "detectRange":
config.detectRange = value; config.detectRange = value;
return { message = `已设置检测范围为 ${value}`;
success: true, break;
message: `Set detect range to ${config.detectRange}`,
shouldSaveConfig: true,
config,
};
default: default:
return { context.print({
success: false, text: `未知选项: ${option}. 可用选项: warnInterval, detectInterval, detectRange`,
message: `Unknown option: ${option}. Available options: warnInterval, detectInterval, detectRange`, });
return Ok.EMPTY;
}
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: message });
return Ok.EMPTY;
},
}; };
}
}
}
// 帮助命令 const editGroupCommand: Command<AppContext> = {
class HelpCommand implements CLICommand { name: "group",
name = "help"; description: "编辑用户组属性",
description = "Show command help"; args: [
usage = "help"; {
name: "groupName",
execute(_args: string[], _executor: string, context: CLIContext): CLIResult { description: "要编辑的用户组名称",
required: true,
},
{
name: "property",
description: "要更改的属性 (isAllowed, isNotice)",
required: true,
},
{ name: "value", description: "新值 (true/false)", required: true },
],
action: ({ args, context }) => {
const [groupName, property, valueStr] = [
args.groupName as string,
args.property as string,
args.value as string,
];
const config = loadConfig(context.configFilepath)!; const config = loadConfig(context.configFilepath)!;
const groupNames = getGroupNames(config);
const helpMessage = `
Command Usage: @AC /<Command> [args]
Commands:
- add <userGroup> <playerName>
add player to group
userGroup: ${groupNames.join(", ")}
- del <userGroup> <playerName>
delete player in the group, except Admin
userGroup: ${groupNames.join(", ")}
- list
list all of the player with its group
- set <options> [params]
config access control settings
options: warnInterval, detectInterval, detectRange
- edit <target> [args]
edit various configurations
targets: group (edit group properties)
examples: edit group <groupName> <property> <value> (properties: isAllowed, isNotice)
- showconfig [type]
show configuration (type: groups/toast/all)
- help
show this help message
`;
return {
success: true,
message: helpMessage.trim(),
};
}
}
// 统一编辑命令
class EditCommand implements CLICommand {
name = "edit";
description = "Edit various configurations (only group now)";
usage = "edit <target> [args]";
execute(args: string[], _executor: string, context: CLIContext): CLIResult {
if (args.length < 1) {
return {
success: false,
message: `Usage: ${this.usage}\nTargets: group`,
};
}
const [target, ...rest] = args;
switch (target) {
case "group":
return this.editGroup(rest, context);
default:
return {
success: false,
message: `Unknown target: ${target}. Available: group`,
};
}
}
private editGroup(args: string[], context: CLIContext): CLIResult {
if (args.length !== 3) {
return {
success: false,
message: `Usage: edit group <groupName> <property> <value>\nProperties: isAllowed, isNotice`,
};
}
const [groupName, property, valueStr] = args;
const config: AccessConfig = loadConfig(context.configFilepath)!;
let groupConfig: UserGroupConfig | undefined; let groupConfig: UserGroupConfig | undefined;
if (groupName === "admin") { if (groupName === "admin") {
groupConfig = config.adminGroupConfig; groupConfig = config.adminGroupConfig;
} else { } else {
groupConfig = config.usersGroups.find( groupConfig = config.usersGroups.find((g) => g.groupName === groupName);
(group) => group.groupName === groupName,
);
} }
if (!groupConfig) { if (!groupConfig) {
return { context.print({ text: `用户组 ${groupName} 未找到` });
success: false, return Ok.EMPTY;
message: `Group ${groupName} not found`,
};
} }
const boolValue = parseBoolean(valueStr);
if (boolValue === undefined) {
context.print({
text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`,
});
return Ok.EMPTY;
}
let message = "";
switch (property) { switch (property) {
case "isAllowed": { case "isAllowed":
const val = parseBoolean(valueStr); groupConfig.isAllowed = boolValue;
if (val != undefined) { message = `已设置 ${groupName}.isAllowed 为 ${boolValue}`;
groupConfig.isAllowed = val; break;
return { case "isNotice":
success: true, groupConfig.isNotice = boolValue;
message: `Set ${groupName}.isAllowed to ${groupConfig.isAllowed}`, message = `已设置 ${groupName}.isNotice 为 ${boolValue}`;
shouldSaveConfig: true, break;
config,
};
} else {
return {
success: false,
message: `Set ${groupName}.isAllowed failed`,
shouldSaveConfig: false,
};
}
}
case "isNotice": {
const val = parseBoolean(valueStr);
if (val != undefined) {
groupConfig.isNotice = val;
return {
success: true,
message: `Set ${groupName}.isNotice to ${groupConfig.isNotice}`,
shouldSaveConfig: true,
config,
};
} else {
return {
success: false,
message: `Set ${groupName}.isAllowed failed`,
shouldSaveConfig: false,
};
}
}
default: default:
return { context.print({
success: false, text: `未知属性: ${property}. 可用属性: isAllowed, isNotice`,
message: `Unknown property: ${property}. Available: isAllowed, isNotice`, });
return Ok.EMPTY;
}
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: message });
return Ok.EMPTY;
},
}; };
}
}
}
// 显示配置命令 const editCommand: Command<AppContext> = {
class ShowConfigCommand implements CLICommand { name: "edit",
name = "showconfig"; description: "编辑各项配置",
description = "Show configuration"; subcommands: new Map([["group", editGroupCommand]]),
usage = "showconfig [type]"; };
execute(args: string[], _executor: string, context: CLIContext): CLIResult { const showConfigCommand: Command<AppContext> = {
const type = args[0] || "all"; name: "showconfig",
description: "显示配置",
options: new Map([
[
"type",
{
name: "type",
description: "要显示的配置类型 (groups, toast, all)",
required: false,
defaultValue: "all",
},
],
]),
action: ({ options, context }) => {
const type = options.type as string;
const config = loadConfig(context.configFilepath)!; const config = loadConfig(context.configFilepath)!;
let message = "";
switch (type) { switch (type) {
case "groups": { case "groups": {
let groupsMessage = `Admin Group: ${config.adminGroupConfig.groupName}\n`; let groupsMessage = `管理员组: ${config.adminGroupConfig.groupName}\n`;
groupsMessage += ` Users: [${config.adminGroupConfig.groupUsers.join(", ")}]\n`; groupsMessage += ` 用户: [${config.adminGroupConfig.groupUsers.join(
groupsMessage += ` Allowed: ${config.adminGroupConfig.isAllowed}\n`; ", ",
groupsMessage += ` notice: ${config.adminGroupConfig.isNotice}\n\n`; )}]\n`;
groupsMessage += ` 允许: ${config.adminGroupConfig.isAllowed}\n`;
groupsMessage += ` 通知: ${config.adminGroupConfig.isNotice}\n\n`;
for (const group of config.usersGroups) { for (const group of config.usersGroups) {
groupsMessage += `Group: ${group.groupName}\n`; groupsMessage += `用户组: ${group.groupName}\n`;
groupsMessage += ` Users: [${(group.groupUsers ?? []).join(", ")}]\n`; groupsMessage += ` 用户: [${(group.groupUsers ?? []).join(", ")}]\n`;
groupsMessage += ` Allowed: ${group.isAllowed}\n`; groupsMessage += ` 允许: ${group.isAllowed}\n`;
groupsMessage += ` Notice: ${group.isNotice}\n`; groupsMessage += ` 通知: ${group.isNotice}\n`;
groupsMessage += "\n"; groupsMessage += "\n";
} }
message = groupsMessage.trim();
return { break;
success: true,
message: groupsMessage.trim(),
};
} }
case "toast": { case "toast": {
let toastMessage = "Default Toast Config:\n"; let toastMessage = "默认 Toast 配置:\n";
toastMessage += ` Title: ${config.welcomeToastConfig.title.text}\n`; toastMessage += ` 标题: ${config.welcomeToastConfig.title.text}\n`;
toastMessage += ` Message: ${config.welcomeToastConfig.msg.text}\n`; toastMessage += ` 消息: ${config.welcomeToastConfig.msg.text}\n`;
toastMessage += ` Prefix: ${config.welcomeToastConfig.prefix ?? "none"}\n`; toastMessage += ` 前缀: ${
toastMessage += ` Brackets: ${config.welcomeToastConfig.brackets ?? "none"}\n`; config.welcomeToastConfig.prefix ?? "none"
toastMessage += ` Bracket Color: ${config.welcomeToastConfig.bracketColor ?? "none"}\n\n`; }\n`;
toastMessage += ` 括号: ${
config.welcomeToastConfig.brackets ?? "none"
}\n`;
toastMessage += ` 括号颜色: ${
config.welcomeToastConfig.bracketColor ?? "none"
}\n\n`;
toastMessage += "Warn Toast Config:\n"; toastMessage += "警告 Toast 配置:\n";
toastMessage += ` Title: ${config.warnToastConfig.title.text}\n`; toastMessage += ` 标题: ${config.warnToastConfig.title.text}\n`;
toastMessage += ` Message: ${config.warnToastConfig.msg.text}\n`; toastMessage += ` 消息: ${config.warnToastConfig.msg.text}\n`;
toastMessage += ` Prefix: ${config.warnToastConfig.prefix ?? "none"}\n`; toastMessage += ` 前缀: ${config.warnToastConfig.prefix ?? "none"}\n`;
toastMessage += ` Brackets: ${config.warnToastConfig.brackets ?? "none"}\n`; toastMessage += ` 括号: ${
toastMessage += ` Bracket Color: ${config.warnToastConfig.bracketColor ?? "none"}`; config.warnToastConfig.brackets ?? "none"
}\n`;
return { toastMessage += ` 括号颜色: ${
success: true, config.warnToastConfig.bracketColor ?? "none"
message: toastMessage, }`;
}; message = toastMessage;
break;
} }
case "all": { case "all": {
let allMessage = `Detect Range: ${config.detectRange}\n`; let allMessage = `检测范围: ${config.detectRange}\n`;
allMessage += `Detect Interval: ${config.detectInterval}\n`; allMessage += `检测间隔: ${config.detectInterval}\n`;
allMessage += `Warn Interval: ${config.watchInterval}\n\n`; allMessage += `警告间隔: ${config.watchInterval}\n\n`;
allMessage += allMessage +=
"Use 'showconfig groups' or 'showconfig toast' for detailed view"; "使用 'showconfig --type groups' 'showconfig --type toast' 查看详细信息";
message = allMessage;
return { break;
success: true,
message: allMessage,
};
} }
default: default:
return { message = `无效类型: ${type}. 可用类型: groups, toast, all`;
success: false, break;
message: `Invalid type: ${type}. Available: groups, toast, all`, }
context.print({ text: message });
return Ok.EMPTY;
},
}; };
}
}
}
// CLI循环处理器 // Root command
export class AccessControlCLI { const rootCommand: Command<AppContext> = {
private processor: CLICommandProcessor; name: "@AC",
private context: CLIContext; description: "访问控制命令行界面",
subcommands: new Map([
["add", addCommand],
["del", delCommand],
["list", listCommand],
["set", setCommand],
["edit", editCommand],
["showconfig", showConfigCommand],
]),
action: ({ context }) => {
context.print([
{
text: "请使用 ",
},
{
text: "@AC --help",
clickEvent: {
action: "copy_to_clipboard",
value: "@AC --help",
},
hoverEvent: {
action: "show_text",
value: "点击复制命令",
},
},
{
text: " 获取门禁系统更详细的命令说明😊😊😊",
},
]);
return Ok.EMPTY;
},
};
constructor(context: CLIContext) { export function createAccessControlCli(context: AppContext) {
this.context = context; return createCli(rootCommand, {
this.processor = new CLICommandProcessor(context); globalContext: context,
} writer: (msg) => context.print(msg),
});
public startConfigLoop() {
while (true) {
const ev = pullEventAs(ChatBoxEvent, "chat");
if (ev === undefined) continue;
const config = loadConfig(this.context.configFilepath)!;
if (!config.adminGroupConfig.groupUsers.includes(ev.username)) continue;
if (!ev.message.startsWith("@AC")) continue;
this.context.log.info(
`Received command "${ev.message}" from admin ${ev.username}`,
);
const result = this.processor.processCommand(ev.message, ev.username);
this.processor.sendResponse(result, ev.username);
if (!result.success) {
this.context.log.warn(`Command failed: ${result.message}`);
}
}
}
}
// 导出类型和工厂函数
export { CLIContext, CLIResult, CLICommand };
export function createAccessControlCLI(context: CLIContext): AccessControlCLI {
return new AccessControlCLI(context);
} }

View File

@@ -12,6 +12,7 @@ interface UserGroupConfig {
groupName: string; groupName: string;
isAllowed: boolean; isAllowed: boolean;
isNotice: boolean; isNotice: boolean;
isWelcome: boolean;
groupUsers: string[]; groupUsers: string[];
} }
@@ -39,6 +40,7 @@ const defaultConfig: AccessConfig = {
groupUsers: ["Selcon"], groupUsers: ["Selcon"],
isAllowed: true, isAllowed: true,
isNotice: true, isNotice: true,
isWelcome: true,
}, },
usersGroups: [ usersGroups: [
{ {
@@ -46,57 +48,60 @@ const defaultConfig: AccessConfig = {
groupUsers: [], groupUsers: [],
isAllowed: true, isAllowed: true,
isNotice: true, isNotice: true,
isWelcome: false,
}, },
{ {
groupName: "VIP", groupName: "VIP",
groupUsers: [], groupUsers: [],
isAllowed: true, isAllowed: true,
isNotice: false, isNotice: false,
isWelcome: true,
}, },
{ {
groupName: "enemies", groupName: "enemies",
groupUsers: [], groupUsers: [],
isAllowed: false, isAllowed: false,
isNotice: false, isNotice: false,
isWelcome: false,
}, },
], ],
welcomeToastConfig: { welcomeToastConfig: {
title: { title: {
text: "Welcome", text: "欢迎",
color: "green", color: "green",
}, },
msg: { msg: {
text: "Hello User %playerName%", text: "欢迎 %playerName% 参观桃源星喵~",
color: "green", color: "#EDC8DA",
}, },
prefix: "Taohuayuan", prefix: "桃源星",
brackets: "[]", brackets: "<>",
bracketColor: "", bracketColor: "",
}, },
noticeToastConfig: { noticeToastConfig: {
title: { title: {
text: "Notice", text: "警告",
color: "red", color: "red",
}, },
msg: { msg: {
text: "Unfamiliar player %playerName% appeared at Position %playerPosX%, %playerPosY%, %playerPosZ%", text: "陌生玩家 %playerName% 出现在 %playerPosX%, %playerPosY%, %playerPosZ%",
color: "red", color: "red",
}, },
prefix: "Taohuayuan", prefix: "桃源星",
brackets: "[]", brackets: "<>",
bracketColor: "", bracketColor: "",
}, },
warnToastConfig: { warnToastConfig: {
title: { title: {
text: "Attention!!!", text: "注意",
color: "red", color: "red",
}, },
msg: { msg: {
text: "%playerName% you are not allowed to be here", text: "%playerName% 你已经进入桃源星领地",
color: "red", color: "red",
}, },
prefix: "Taohuayuan", prefix: "桃源星",
brackets: "[]", brackets: "<>",
bracketColor: "", bracketColor: "",
}, },
}; };

View File

@@ -1,10 +1,13 @@
import { CCLog, DAY, LogLevel } from "@/lib/ccLog"; import { CCLog, DAY, LogLevel } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config"; import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
import { createAccessControlCLI } from "./cli"; import { createAccessControlCli } from "./cli";
import { launchAccessControlTUI } from "./tui"; import { launchAccessControlTUI } from "./tui";
import * as peripheralManager from "../lib/PeripheralManager"; import * as peripheralManager from "../lib/PeripheralManager";
import { deepCopy } from "@/lib/common"; import { deepCopy } from "@/lib/common";
import { ReadWriteLock } from "@/lib/mutex/ReadWriteLock"; import { ReadWriteLock } from "@/lib/mutex/ReadWriteLock";
import { ChatManager } from "@/lib/ChatManager";
import { gTimerManager } from "@/lib/TimerManager";
import { KeyEvent, pullEventAs } from "@/lib/event";
const args = [...$vararg]; const args = [...$vararg];
@@ -12,7 +15,7 @@ const args = [...$vararg];
const logger = new CCLog("accesscontrol.log", { const logger = new CCLog("accesscontrol.log", {
printTerminal: true, printTerminal: true,
logInterval: DAY, logInterval: DAY,
outputMinLevel: LogLevel.Info, outputMinLevel: LogLevel.Debug,
}); });
// Load Config // Load Config
@@ -23,16 +26,20 @@ logger.info("Load config successfully!");
logger.debug(textutils.serialise(config, { allow_repetitions: true })); logger.debug(textutils.serialise(config, { allow_repetitions: true }));
// Peripheral // Peripheral
const playerDetector = peripheralManager.findByNameRequired("playerDetector"); const playerDetector = peripheral.find(
const chatBox = peripheralManager.findByNameRequired("chatBox"); "playerDetector",
)[0] as PlayerDetectorPeripheral;
const chatBox = peripheral.find("chatBox")[0] as ChatBoxPeripheral;
const chatManager: ChatManager = new ChatManager([chatBox]);
// Global // Global
let inRangePlayers: string[] = []; let gInRangePlayers: string[] = [];
let watchPlayersInfo: { name: string; hasNoticeTimes: number }[] = []; let gWatchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
let gIsRunning = true;
interface ParseParams { interface ParseParams {
name?: string; playerName?: string;
group?: string; groupName?: string;
info?: PlayerInfo; info?: PlayerInfo;
} }
@@ -44,8 +51,8 @@ function reloadConfig() {
} }
config = loadConfig(configFilepath)!; config = loadConfig(configFilepath)!;
inRangePlayers = []; gInRangePlayers = [];
watchPlayersInfo = []; gWatchPlayersInfo = [];
releaser.release(); releaser.release();
logger.info("Reload config successfully!"); logger.info("Reload config successfully!");
} }
@@ -53,7 +60,7 @@ function reloadConfig() {
function safeParseTextComponent( function safeParseTextComponent(
component: MinecraftTextComponent, component: MinecraftTextComponent,
params?: ParseParams, params?: ParseParams,
): string { ): MinecraftTextComponent {
const newComponent = deepCopy(component); const newComponent = deepCopy(component);
if (newComponent.text == undefined) { if (newComponent.text == undefined) {
@@ -61,11 +68,11 @@ function safeParseTextComponent(
} else if (newComponent.text.includes("%")) { } else if (newComponent.text.includes("%")) {
newComponent.text = newComponent.text.replace( newComponent.text = newComponent.text.replace(
"%playerName%", "%playerName%",
params?.name ?? "UnknowPlayer", params?.playerName ?? "UnknowPlayer",
); );
newComponent.text = newComponent.text.replace( newComponent.text = newComponent.text.replace(
"%groupName%", "%groupName%",
params?.group ?? "UnknowGroup", params?.groupName ?? "UnknowGroup",
); );
newComponent.text = newComponent.text.replace( newComponent.text = newComponent.text.replace(
"%playerPosX%", "%playerPosX%",
@@ -80,7 +87,34 @@ function safeParseTextComponent(
params?.info?.z.toString() ?? "UnknowPosZ", params?.info?.z.toString() ?? "UnknowPosZ",
); );
} }
return textutils.serialiseJSON(newComponent); return newComponent;
}
function sendMessage(
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
chatManager.sendMessage({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
targetPlayer: targetPlayer,
utf8Support: true,
});
releaser.release();
} }
function sendToast( function sendToast(
@@ -94,22 +128,22 @@ function sendToast(
releaser = configLock.tryAcquireRead(); releaser = configLock.tryAcquireRead();
} }
chatBox.sendFormattedToastToPlayer( chatManager.sendToast({
safeParseTextComponent( message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg, toastConfig.msg ?? config.welcomeToastConfig.msg,
params, params,
), ),
safeParseTextComponent( title: safeParseTextComponent(
toastConfig.title ?? config.welcomeToastConfig.title, toastConfig.title ?? config.welcomeToastConfig.title,
params, params,
), ),
targetPlayer, prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
toastConfig.prefix ?? config.welcomeToastConfig.prefix, brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
toastConfig.brackets ?? config.welcomeToastConfig.brackets, bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor, toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
undefined, targetPlayer: targetPlayer,
true, utf8Support: true,
); });
releaser.release(); releaser.release();
} }
@@ -131,7 +165,7 @@ function sendNotice(player: string, playerInfo?: PlayerInfo) {
for (const targetPlayer of noticeTargetPlayers) { for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue; if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(config.noticeToastConfig, targetPlayer, { sendToast(config.noticeToastConfig, targetPlayer, {
name: player, playerName: player,
info: playerInfo, info: playerInfo,
}); });
sleep(1); sleep(1);
@@ -149,32 +183,32 @@ function sendWarn(player: string) {
releaser = configLock.tryAcquireRead(); releaser = configLock.tryAcquireRead();
} }
sendToast(config.warnToastConfig, player, { name: player }); sendToast(config.warnToastConfig, player, { playerName: player });
chatBox.sendFormattedMessageToPlayer( chatManager.sendMessage({
safeParseTextComponent(config.warnToastConfig.msg, { name: player }), message: safeParseTextComponent(config.warnToastConfig.msg, {
player, playerName: player,
"AccessControl", }),
"[]", targetPlayer: player,
undefined, prefix: "AccessControl",
undefined, brackets: "[]",
true, utf8Support: true,
); });
releaser.release(); releaser.release();
} }
function watchLoop() { function watchLoop() {
while (true) { while (gIsRunning) {
const releaser = configLock.tryAcquireRead(); const releaser = configLock.tryAcquireRead();
if (releaser === undefined) { if (releaser === undefined) {
os.sleep(1); os.sleep(1);
continue; continue;
} }
const watchPlayerNames = watchPlayersInfo.flatMap((value) => value.name); const watchPlayerNames = gWatchPlayersInfo.flatMap((value) => value.name);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`); logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of watchPlayersInfo) { for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name); const playerInfo = playerDetector.getPlayerPos(player.name);
if (inRangePlayers.includes(player.name)) { if (gInRangePlayers.includes(player.name)) {
// Notice // Notice
if (player.hasNoticeTimes < config.noticeTimes) { if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo); sendNotice(player.name, playerInfo);
@@ -190,7 +224,7 @@ function watchLoop() {
); );
} else { } else {
// Get rid of player from list // Get rid of player from list
watchPlayersInfo = watchPlayersInfo.filter( gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name, (value) => value.name != player.name,
); );
logger.info( logger.info(
@@ -206,7 +240,7 @@ function watchLoop() {
} }
function mainLoop() { function mainLoop() {
while (true) { while (gIsRunning) {
const releaser = configLock.tryAcquireRead(); const releaser = configLock.tryAcquireRead();
if (releaser === undefined) { if (releaser === undefined) {
os.sleep(0.1); os.sleep(0.1);
@@ -218,20 +252,31 @@ function mainLoop() {
logger.debug(`Detected ${players.length} players: ${playersList}`); logger.debug(`Detected ${players.length} players: ${playersList}`);
for (const player of players) { for (const player of players) {
if (inRangePlayers.includes(player)) continue; if (gInRangePlayers.includes(player)) continue;
// Get player Info
const playerInfo = playerDetector.getPlayerPos(player);
if (config.adminGroupConfig.groupUsers.includes(player)) { if (config.adminGroupConfig.groupUsers.includes(player)) {
logger.info(`Admin ${player} appear`); logger.info(
`Admin ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.adminGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: "Admin",
info: playerInfo,
});
continue; continue;
} }
// New player appear // New player appear
const playerInfo = playerDetector.getPlayerPos(player);
let groupConfig: UserGroupConfig = { let groupConfig: UserGroupConfig = {
groupName: "Unfamiliar", groupName: "Unfamiliar",
groupUsers: [], groupUsers: [],
isAllowed: false, isAllowed: false,
isNotice: false, isNotice: false,
isWelcome: false,
}; };
for (const userGroupConfig of config.usersGroups) { for (const userGroupConfig of config.usersGroups) {
if (userGroupConfig.groupUsers == undefined) continue; if (userGroupConfig.groupUsers == undefined) continue;
@@ -244,28 +289,37 @@ function mainLoop() {
break; break;
} }
if (config.adminGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
if (groupConfig.isAllowed) continue; if (groupConfig.isAllowed) continue;
logger.warn( logger.warn(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`, `${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
); );
if (config.isWarn) sendWarn(player); if (config.isWarn) sendWarn(player);
watchPlayersInfo = [ gWatchPlayersInfo = [
...watchPlayersInfo, ...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 }, { name: player, hasNoticeTimes: 0 },
]; ];
} }
inRangePlayers = players; gInRangePlayers = players;
releaser.release(); releaser.release();
os.sleep(config.detectInterval); os.sleep(config.detectInterval);
} }
} }
function keyboardLoop() { function keyboardLoop() {
while (true) { while (gIsRunning) {
const [eventType, key] = os.pullEvent("key"); const event = pullEventAs(KeyEvent, "key");
if (eventType === "key" && key === keys.c) { if (event === undefined) continue;
if (event.key === keys.c) {
logger.info("Launching Access Control TUI..."); logger.info("Launching Access Control TUI...");
try { try {
logger.setInTerminal(false); logger.setInTerminal(false);
@@ -277,7 +331,66 @@ function keyboardLoop() {
logger.setInTerminal(true); logger.setInTerminal(true);
reloadConfig(); reloadConfig();
} }
} else if (event.key === keys.r) {
reloadConfig();
} }
// else if (event.key === keys.q) {
// gIsRunning = false;
// }
}
}
function cliLoop() {
let printTargetPlayer: string | undefined;
const cli = createAccessControlCli({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
logger: logger,
print: (msg) =>
chatManager.sendMessage({
message: msg,
targetPlayer: printTargetPlayer,
prefix: "Access Control System",
brackets: "[]",
utf8Support: true,
}),
});
while (gIsRunning) {
const result = chatManager.getReceivedMessage();
if (result.isErr()) {
sleep(0.5);
continue;
}
logger.debug(`Received message: ${result.value.message}`);
const ev = result.value;
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const isAdmin = config.adminGroupConfig.groupUsers.includes(ev.username);
releaser.release();
if (!isAdmin) continue;
if (!ev.message.startsWith("@AC")) continue;
printTargetPlayer = ev.username;
logger.info(
`Received command "${ev.message}" from admin ${printTargetPlayer}`,
);
const commandArgs = ev.message
.substring(3)
.split(" ")
.filter((s) => s.length > 0);
logger.debug(`Command arguments: ${commandArgs.join(", ")}`);
cli(commandArgs);
printTargetPlayer = undefined;
} }
} }
@@ -285,30 +398,18 @@ function main(args: string[]) {
logger.info("Starting access control system, get args: " + args.join(", ")); logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) { if (args.length == 1) {
if (args[0] == "start") { if (args[0] == "start") {
// 创建CLI处理器 const tutorial: string[] = [];
const cli = createAccessControlCLI({ tutorial.push("Access Control System started.");
configFilepath: configFilepath, tutorial.push("\tPress 'c' to open configuration TUI.");
reloadConfig: () => reloadConfig(), tutorial.push("\tPress 'r' to reload configuration.");
log: logger, print(tutorial.join("\n"));
chatBox: chatBox,
});
print(
"Access Control System started. Press 'c' to open configuration TUI.",
);
parallel.waitForAll( parallel.waitForAll(
() => { () => mainLoop(),
mainLoop(); () => gTimerManager.run(),
}, () => cliLoop(),
() => { () => watchLoop(),
cli.startConfigLoop(); () => keyboardLoop(),
}, () => chatManager.run(),
() => {
watchLoop();
},
() => {
keyboardLoop();
},
); );
return; return;

View File

@@ -355,6 +355,34 @@ const AccessControlTUI = () => {
{ class: "flex flex-col ml-2" }, { class: "flex flex-col ml-2" },
label({}, () => `Group: ${getSelectedGroup().groupName}`), label({}, () => `Group: ${getSelectedGroup().groupName}`),
div(
{ class: "flex flex-row" },
label({}, "Is Welcome:"),
input({
type: "checkbox",
checked: () => getSelectedGroup().isWelcome,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isWelcome: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isWelcome: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
),
div( div(
{ class: "flex flex-row" }, { class: "flex flex-row" },
label({}, "Is Allowed:"), label({}, "Is Allowed:"),
@@ -541,7 +569,12 @@ const AccessControlTUI = () => {
label({}, "Prefix:"), label({}, "Prefix:"),
input({ input({
type: "text", type: "text",
value: () => getTempToastConfig().prefix, value: () => {
const str = textutils.serialiseJSON(getTempToastConfig().prefix, {
unicode_strings: true,
});
return str.substring(1, str.length - 1);
},
onInput: (value) => onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), prefix: value }), setTempToastConfig({ ...getTempToastConfig(), prefix: value }),
onFocusChanged: () => { onFocusChanged: () => {

View File

@@ -1,16 +1,19 @@
/** /**
* Example CLI application demonstrating the ccCLI framework * Example CLI application demonstrating the ccCLI framework
* This example shows how to create a calculator CLI with global context injection * This example shows how to create a calculator CLI with global context injection
* and ChatManager integration for Minecraft chat functionality
*/ */
import { Command, createCli, CliError } from "../lib/ccCLI/index"; import { Command, createCli, CliError } from "../lib/ccCLI/index";
import { Ok, Result } from "../lib/thirdparty/ts-result-es"; import { Ok, Result } from "../lib/thirdparty/ts-result-es";
import { ChatManager, ChatMessage, ChatToast } from "../lib/ChatManager";
// 1. Define global context type // 1. Define global context type
interface AppContext { interface AppContext {
appName: string; appName: string;
log: (message: string) => void; log: (message: string) => void;
debugMode: boolean; debugMode: boolean;
chatManager?: ChatManager;
} }
// 2. Define individual commands // 2. Define individual commands
@@ -153,10 +156,346 @@ const configCommand: Command<AppContext> = {
]), ]),
}; };
// ChatManager commands
const chatSendCommand: Command<AppContext> = {
name: "send",
description: "Send a chat message",
args: [
{ name: "message", description: "The message to send", required: true },
],
options: new Map([
[
"player",
{
name: "player",
shortName: "p",
description: "Target player for private message",
defaultValue: undefined,
},
],
[
"prefix",
{
name: "prefix",
description: "Message prefix",
defaultValue: "CC",
},
],
]),
action: ({ args, options, context }): Result<void, CliError> => {
if (!context.chatManager) {
print(
"Error: ChatManager not initialized. No chatbox peripherals found.",
);
return Ok.EMPTY;
}
const message: ChatMessage = {
message: args.message as string,
targetPlayer: options.player as string | undefined,
prefix: options.prefix as string,
};
const result = context.chatManager.sendMessage(message);
if (result.isOk()) {
print(`Message queued: "${String(args.message)}"`);
const targetPlayer = options.player;
if (
targetPlayer !== undefined &&
targetPlayer !== null &&
typeof targetPlayer === "string"
) {
print(`Target: ${targetPlayer}`);
} else {
print("Target: Global chat");
}
} else {
print(`Failed to queue message: ${result.error.reason}`);
}
return Ok.EMPTY;
},
};
const chatToastCommand: Command<AppContext> = {
name: "toast",
description: "Send a toast notification to a player",
args: [
{ name: "player", description: "Target player username", required: true },
{ name: "title", description: "Toast title", required: true },
{ name: "message", description: "Toast message", required: true },
],
options: new Map([
[
"prefix",
{
name: "prefix",
description: "Message prefix",
defaultValue: "CC",
},
],
]),
action: ({ args, options, context }): Result<void, CliError> => {
if (!context.chatManager) {
print(
"Error: ChatManager not initialized. No chatbox peripherals found.",
);
return Ok.EMPTY;
}
const toast: ChatToast = {
targetPlayer: args.player as string,
title: args.title as string,
message: args.message as string,
prefix: options.prefix as string,
};
const result = context.chatManager.sendToast(toast);
if (result.isOk()) {
print(
`Toast queued for ${String(args.player)}: "${String(args.title)}" - "${String(args.message)}"`,
);
} else {
print(`Failed to queue toast: ${result.error.reason}`);
}
return Ok.EMPTY;
},
};
const chatStatusCommand: Command<AppContext> = {
name: "status",
description: "Show ChatManager status and queue information",
action: ({ context }): Result<void, CliError> => {
if (!context.chatManager) {
print("ChatManager: Not initialized (no chatbox peripherals found)");
return Ok.EMPTY;
}
print("=== ChatManager Status ===");
print(`Pending messages: ${context.chatManager.getPendingMessageCount()}`);
print(`Pending toasts: ${context.chatManager.getPendingToastCount()}`);
print(
`Buffered received: ${context.chatManager.getBufferedMessageCount()}`,
);
const chatboxStatus = context.chatManager.getChatboxStatus();
print(`Chatboxes: ${chatboxStatus.length} total`);
for (let i = 0; i < chatboxStatus.length; i++) {
const status = chatboxStatus[i] ? "idle" : "busy";
print(` Chatbox ${i + 1}: ${status}`);
}
return Ok.EMPTY;
},
};
const chatReceiveCommand: Command<AppContext> = {
name: "receive",
description: "Check for received chat messages",
options: new Map([
[
"count",
{
name: "count",
shortName: "c",
description: "Number of messages to retrieve",
defaultValue: 1,
},
],
]),
action: ({ options, context }): Result<void, CliError> => {
if (!context.chatManager) {
print(
"Error: ChatManager not initialized. No chatbox peripherals found.",
);
return Ok.EMPTY;
}
const count = tonumber(options.count as string) ?? 1;
let retrieved = 0;
print("=== Received Messages ===");
for (let i = 0; i < count; i++) {
const result = context.chatManager.getReceivedMessage();
if (result.isOk()) {
const event = result.value;
print(`[${event.username}]: ${event.message}`);
if (event.uuid !== undefined) {
print(` UUID: ${event.uuid}`);
}
retrieved++;
} else {
// Buffer is empty
break;
}
}
if (retrieved === 0) {
print("No messages in buffer");
} else {
print(`Retrieved ${retrieved} message(s)`);
}
return Ok.EMPTY;
},
};
const chatSendImmediateCommand: Command<AppContext> = {
name: "send-immediate",
description: "Send a chat message immediately (bypass queue)",
args: [
{ name: "message", description: "The message to send", required: true },
],
options: new Map([
[
"player",
{
name: "player",
shortName: "p",
description: "Target player for private message",
defaultValue: undefined,
},
],
[
"prefix",
{
name: "prefix",
description: "Message prefix",
defaultValue: "CC",
},
],
]),
action: ({ args, options, context }): Result<void, CliError> => {
if (!context.chatManager) {
print(
"Error: ChatManager not initialized. No chatbox peripherals found.",
);
return Ok.EMPTY;
}
const message: ChatMessage = {
message: args.message as string,
targetPlayer: options.player as string | undefined,
prefix: options.prefix as string,
};
const result = context.chatManager.sendMessageImmediate(message);
if (result.isOk()) {
print(`Message sent immediately: "${String(args.message)}"`);
} else {
print(`Failed to send message: ${result.error.reason}`);
if (result.error.kind === "NoIdleChatbox") {
print("All chatboxes are currently busy. Try queuing instead.");
}
}
return Ok.EMPTY;
},
};
const chatStopCommand: Command<AppContext> = {
name: "stop",
description: "Stop the ChatManager",
action: ({ context }): Result<void, CliError> => {
if (!context.chatManager) {
print("Error: ChatManager not initialized.");
return Ok.EMPTY;
}
const result = context.chatManager.stop();
if (result.isOk()) {
print("ChatManager stopped successfully.");
} else {
print(`Failed to stop ChatManager: ${result.error.reason}`);
}
return Ok.EMPTY;
},
};
const chatClearCommand: Command<AppContext> = {
name: "clear",
description: "Clear queues and buffer",
options: new Map([
[
"queues",
{
name: "queues",
shortName: "q",
description: "Clear message and toast queues",
defaultValue: false,
},
],
[
"buffer",
{
name: "buffer",
shortName: "b",
description: "Clear received message buffer",
defaultValue: false,
},
],
]),
action: ({ options, context }): Result<void, CliError> => {
if (!context.chatManager) {
print("Error: ChatManager not initialized.");
return Ok.EMPTY;
}
const clearQueues = options.queues as boolean;
const clearBuffer = options.buffer as boolean;
if (!clearQueues && !clearBuffer) {
print("Specify --queues or --buffer (or both) to clear.");
return Ok.EMPTY;
}
const results: string[] = [];
if (clearQueues) {
const result = context.chatManager.clearQueues();
if (result.isOk()) {
results.push("Queues cleared");
} else {
results.push(`Failed to clear queues: ${result.error.reason}`);
}
}
if (clearBuffer) {
const result = context.chatManager.clearBuffer();
if (result.isOk()) {
results.push("Buffer cleared");
} else {
results.push(`Failed to clear buffer: ${result.error.reason}`);
}
}
results.forEach((msg) => print(msg));
return Ok.EMPTY;
},
};
const chatCommand: Command<AppContext> = {
name: "chat",
description: "Chat management commands using ChatManager",
subcommands: new Map([
["send", chatSendCommand],
["send-immediate", chatSendImmediateCommand],
["toast", chatToastCommand],
["status", chatStatusCommand],
["receive", chatReceiveCommand],
["stop", chatStopCommand],
["clear", chatClearCommand],
]),
};
// 3. Define root command // 3. Define root command
const rootCommand: Command<AppContext> = { const rootCommand: Command<AppContext> = {
name: "calculator", name: "calculator",
description: "A feature-rich calculator program", description: "A feature-rich calculator and chat management program",
options: new Map([ options: new Map([
[ [
"debug", "debug",
@@ -172,6 +511,7 @@ const rootCommand: Command<AppContext> = {
["math", mathCommand], ["math", mathCommand],
["greet", greetCommand], ["greet", greetCommand],
["config", configCommand], ["config", configCommand],
["chat", chatCommand],
]), ]),
action: ({ options, context }): Result<void, CliError> => { action: ({ options, context }): Result<void, CliError> => {
// Update debug mode from command line option // Update debug mode from command line option
@@ -183,20 +523,58 @@ const rootCommand: Command<AppContext> = {
print(`Welcome to ${context.appName}!`); print(`Welcome to ${context.appName}!`);
print("Use --help to see available commands"); print("Use --help to see available commands");
if (context.chatManager) {
print("ChatManager initialized and ready!");
} else {
print("Note: No chatbox peripherals found - chat commands unavailable");
}
return Ok.EMPTY; return Ok.EMPTY;
}, },
}; };
// 4. Create global context instance // 4. Initialize ChatManager if chatbox peripherals are available
function initializeChatManager(): ChatManager | undefined {
// Find all available chatbox peripherals
const peripheralNames = peripheral.getNames();
const chatboxPeripherals: ChatBoxPeripheral[] = [];
for (const name of peripheralNames) {
const peripheralType = peripheral.getType(name);
if (peripheralType[0] === "chatBox") {
const chatbox = peripheral.wrap(name) as ChatBoxPeripheral;
chatboxPeripherals.push(chatbox);
}
}
if (chatboxPeripherals.length === 0) {
return undefined;
}
const chatManager = new ChatManager(chatboxPeripherals);
// Start ChatManager in async mode so it doesn't block the CLI
const runResult = chatManager.runAsync();
if (runResult.isErr()) {
print(`Warning: Failed to start ChatManager: ${runResult.error.reason}`);
return undefined;
}
return chatManager;
}
// 5. Create global context instance
const appContext: AppContext = { const appContext: AppContext = {
appName: "MyAwesomeCalculator", appName: "MyAwesome Calculator & Chat Manager",
debugMode: false, debugMode: false,
log: (message) => { log: (message) => {
print(`[LOG] ${message}`); print(`[LOG] ${message}`);
}, },
chatManager: initializeChatManager(),
}; };
// 5. Create and export CLI handler // 6. Create and export CLI handler
const cli = createCli(rootCommand, { globalContext: appContext }); const cli = createCli(rootCommand, { globalContext: appContext });
const args = [...$vararg]; const args = [...$vararg];
cli(args); cli(args);
@@ -215,11 +593,24 @@ cli(['greet', '-n', 'World', '-t', '3']); // Output: Hello, World! (3 times)
cli(['config', 'show']); // Shows current config cli(['config', 'show']); // Shows current config
cli(['config', 'set', 'theme', 'dark']); // Sets config cli(['config', 'set', 'theme', 'dark']); // Sets config
// Chat management (requires chatbox peripherals)
cli(['chat', 'status']); // Shows ChatManager status
cli(['chat', 'send', 'Hello World!']); // Sends global message (queued)
cli(['chat', 'send', 'Hi there!', '--player', 'Steve']); // Private message (queued)
cli(['chat', 'send-immediate', 'Urgent!', '--player', 'Admin']); // Immediate send
cli(['chat', 'toast', 'Steve', 'Alert', 'Server restart in 5 minutes']); // Toast notification
cli(['chat', 'receive', '--count', '5']); // Check for received messages
cli(['chat', 'clear', '--queues']); // Clear pending queues
cli(['chat', 'clear', '--buffer']); // Clear received buffer
cli(['chat', 'stop']); // Stop ChatManager
// Help examples // Help examples
cli(['--help']); // Shows root help cli(['--help']); // Shows root help
cli(['math', '--help']); // Shows math command help cli(['math', '--help']); // Shows math command help
cli(['config', 'set', '--help']); // Shows config set help cli(['chat', '--help']); // Shows chat command help
cli(['chat', 'send', '--help']); // Shows chat send help
// Debug mode // Debug mode
cli(['--debug', 'math', 'add', '1', '2']); // Enables debug logging cli(['--debug', 'math', 'add', '1', '2']); // Enables debug logging
cli(['--debug', 'chat', 'status']); // Debug mode with chat status
*/ */

657
src/lib/ChatManager.ts Normal file
View File

@@ -0,0 +1,657 @@
import { Queue } from "./datatype/Queue";
import { ChatBoxEvent, pullEventAs } from "./event";
import { Result, Ok, Err } from "./thirdparty/ts-result-es";
import { gTimerManager } from "./TimerManager";
/**
* Chat manager error types
*/
export interface ChatManagerError {
kind: "ChatManager";
reason: string;
chatboxIndex?: number;
}
export interface NoIdleChatboxError {
kind: "NoIdleChatbox";
reason: "All chatboxes are busy";
}
export interface SendFailureError {
kind: "SendFailure";
reason: string;
chatboxIndex: number;
}
export interface EmptyBufferError {
kind: "EmptyBuffer";
reason: "No messages in buffer";
}
export type ChatError =
| ChatManagerError
| NoIdleChatboxError
| SendFailureError
| EmptyBufferError;
/**
* Base interface for chat messages and toasts
*/
interface ChatBasicMessage {
message: string | MinecraftTextComponent | MinecraftTextComponent[];
prefix?: string;
brackets?: string;
bracketColor?: string;
range?: number;
utf8Support?: boolean;
}
/**
* Interface for chat toast notifications
*/
export interface ChatToast extends ChatBasicMessage {
/** Target player username to send the toast to */
targetPlayer: string;
/** Title of the toast notification */
title: string | MinecraftTextComponent | MinecraftTextComponent[];
}
/**
* Interface for regular chat messages
*/
export interface ChatMessage extends ChatBasicMessage {
/** Optional target player username for private messages */
targetPlayer?: string;
}
/**
* ChatManager class for managing multiple ChatBox peripherals
* Handles message queuing, sending with cooldown management, and event receiving
* Uses Result types for robust error handling
*/
export class ChatManager {
/** Array of all available ChatBox peripherals */
private chatboxes: ChatBoxPeripheral[];
/** Queue for pending chat messages */
private messageQueue = new Queue<ChatMessage>();
/** Queue for pending toast notifications */
private toastQueue = new Queue<ChatToast>();
/** Buffer for received chat events */
private chatBuffer = new Queue<ChatBoxEvent>();
/** Array tracking which chatboxes are currently idle (not in cooldown) */
private idleChatboxes: boolean[];
/** Flag
to control the running state of loops */
private isRunning = false;
/** Lua thread for managing chat operations */
private thread?: LuaThread;
/**
* Constructor - initializes the ChatManager with available ChatBox peripherals
* @param peripherals Array of ChatBox peripherals to manage
*/
constructor(peripherals: ChatBoxPeripheral[]) {
if (peripherals.length === 0) {
throw new Error("ChatManager requires at least one ChatBox peripheral");
}
this.chatboxes = peripherals;
// Initially all chatboxes are idle
this.idleChatboxes = peripherals.map(() => true);
}
/**
* Adds a chat message to the sending queue
* @param message The chat message to send
* @returns Result indicating success or failure
*/
public sendMessage(message: ChatMessage): Result<void, ChatManagerError> {
try {
this.messageQueue.enqueue(message);
return new Ok(undefined);
} catch (error) {
return new Err({
kind: "ChatManager",
reason: `Failed to enqueue message: ${String(error)}`,
});
}
}
/**
* Adds a toast notification to the sending queue
* @param toast The toast notification to send
* @returns Result indicating success or failure
*/
public sendToast(toast: ChatToast): Result<void, ChatManagerError> {
try {
this.toastQueue.enqueue(toast);
return new Ok(undefined);
} catch (error) {
return new Err({
kind: "ChatManager",
reason: `Failed to enqueue toast: ${String(error)}`,
});
}
}
/**
* Retrieves and removes the next received chat event from the buffer
* @returns Result containing the chat event or an error if buffer is empty
*/
public getReceivedMessage(): Result<ChatBoxEvent, EmptyBufferError> {
const event = this.chatBuffer.dequeue();
if (event === undefined) {
return new Err({
kind: "EmptyBuffer",
reason: "No messages in buffer",
});
}
return new Ok(event);
}
/**
* Finds the first available (idle) chatbox
* @returns Result containing chatbox index or error if none available
*/
private findIdleChatbox(): Result<number, NoIdleChatboxError> {
for (let i = 0; i < this.idleChatboxes.length; i++) {
if (this.idleChatboxes[i]) {
return new Ok(i);
}
}
return new Err({
kind: "NoIdleChatbox",
reason: "All chatboxes are busy",
});
}
/**
* Marks a chatbox as busy and sets up a timer to mark it as idle after cooldown
* @param chatboxIndex Index of the chatbox to mark as busy
* @returns Result indicating success or failure
*/
private setChatboxBusy(chatboxIndex: number): Result<void, ChatManagerError> {
if (chatboxIndex < 0 || chatboxIndex >= this.idleChatboxes.length) {
return new Err({
kind: "ChatManager",
reason: "Invalid chatbox index",
chatboxIndex,
});
}
this.idleChatboxes[chatboxIndex] = false;
if (!gTimerManager.status()) {
return new Err({
kind: "ChatManager",
reason: "TimerManager is not running",
});
}
gTimerManager.setTimeOut(1, () => {
this.idleChatboxes[chatboxIndex] = true;
});
return Ok.EMPTY;
}
/**
* Attempts to send a chat message using an available chatbox
* @param message The message to send
* @returns Result indicating success or failure with error details
*/
private trySendMessage(message: ChatMessage): Result<void, ChatError> {
const chatboxResult = this.findIdleChatbox();
if (chatboxResult.isErr()) {
return chatboxResult;
}
const chatboxIndex = chatboxResult.value;
const chatbox = this.chatboxes[chatboxIndex];
try {
let success: boolean;
let errorMsg: string | undefined;
// Determine the appropriate sending method based on message properties
if (message.targetPlayer !== undefined) {
// Send private message to specific player
if (typeof message.message === "string") {
[success, errorMsg] = chatbox.sendMessageToPlayer(
textutils.serialiseJSON(message.message, {
unicode_strings: message.utf8Support,
}),
message.targetPlayer,
textutils.serialiseJSON(message.prefix ?? "AP", {
unicode_strings: message.utf8Support,
}),
message.brackets,
message.bracketColor,
message.range,
message.utf8Support,
);
} else {
// Handle MinecraftTextComponent for private message
[success, errorMsg] = chatbox.sendFormattedMessageToPlayer(
textutils.serialiseJSON(message.message, {
unicode_strings: message.utf8Support,
allow_repetitions: true,
}),
message.targetPlayer,
textutils.serialiseJSON(message.prefix ?? "AP", {
unicode_strings: message.utf8Support,
}),
message.brackets,
message.bracketColor,
message.range,
message.utf8Support,
);
}
} else {
// Send global message
if (typeof message.message === "string") {
[success, errorMsg] = chatbox.sendMessage(
textutils.serialiseJSON(message.message, {
unicode_strings: message.utf8Support,
}),
textutils.serialiseJSON(message.prefix ?? "AP", {
unicode_strings: message.utf8Support,
}),
message.brackets,
message.bracketColor,
message.range,
message.utf8Support,
);
} else {
// Handle MinecraftTextComponent for global message
[success, errorMsg] = chatbox.sendFormattedMessage(
textutils.serialiseJSON(message.message, {
unicode_strings: message.utf8Support,
allow_repetitions: true,
}),
textutils.serialiseJSON(message.prefix ?? "AP", {
unicode_strings: message.utf8Support,
}),
message.brackets,
message.bracketColor,
message.range,
message.utf8Support,
);
}
}
if (success) {
// Mark chatbox as busy for cooldown period
const busyResult = this.setChatboxBusy(chatboxIndex);
if (busyResult.isErr()) {
return busyResult;
}
return new Ok(undefined);
} else {
return new Err({
kind: "SendFailure",
reason: errorMsg ?? "Unknown send failure",
chatboxIndex,
});
}
} catch (error) {
return new Err({
kind: "SendFailure",
reason: `Exception during send: ${String(error)}`,
chatboxIndex,
});
}
}
/**
* Attempts to send a toast notification using an available chatbox
* @param toast The toast to send
* @returns Result indicating success or failure with error details
*/
private trySendToast(toast: ChatToast): Result<void, ChatError> {
const chatboxResult = this.findIdleChatbox();
if (chatboxResult.isErr()) {
return chatboxResult;
}
const chatboxIndex = chatboxResult.value;
const chatbox = this.chatboxes[chatboxIndex];
try {
let success: boolean;
let errorMsg: string | undefined;
// Send toast notification
if (
typeof toast.message === "string" &&
typeof toast.title === "string"
) {
[success, errorMsg] = chatbox.sendToastToPlayer(
textutils.serialiseJSON(toast.message, {
unicode_strings: toast.utf8Support,
}),
textutils.serialiseJSON(toast.title, {
unicode_strings: toast.utf8Support,
}),
toast.targetPlayer,
textutils.serialiseJSON(toast.prefix ?? "AP", {
unicode_strings: toast.utf8Support,
}),
toast.brackets,
toast.bracketColor,
toast.range,
toast.utf8Support,
);
} else {
// Handle MinecraftTextComponent for toast
const messageJson =
typeof toast.message === "string"
? toast.message
: textutils.serialiseJSON(toast.message, {
unicode_strings: true,
allow_repetitions: toast.utf8Support,
});
const titleJson =
typeof toast.title === "string"
? toast.title
: textutils.serialiseJSON(toast.title, {
unicode_strings: true,
allow_repetitions: toast.utf8Support,
});
[success, errorMsg] = chatbox.sendFormattedToastToPlayer(
messageJson,
titleJson,
toast.targetPlayer,
textutils.serialiseJSON(toast.prefix ?? "AP", {
unicode_strings: toast.utf8Support,
}),
toast.brackets,
toast.bracketColor,
toast.range,
toast.utf8Support,
);
}
if (success) {
// Mark chatbox as busy for cooldown period
const busyResult = this.setChatboxBusy(chatboxIndex);
if (busyResult.isErr()) {
return busyResult;
}
return new Ok(undefined);
} else {
return new Err({
kind: "SendFailure",
reason: errorMsg ?? "Unknown toast send failure",
chatboxIndex,
});
}
} catch (error) {
return new Err({
kind: "SendFailure",
reason: `Exception during toast send: ${String(error)}`,
chatboxIndex,
});
}
}
/**
* Main sending loop - continuously processes message and toast queues
* Runs in a separate coroutine to handle sending with proper timing
*/
private sendLoop(): void {
while (this.isRunning) {
let sentSomething = false;
// Try to send a message if queue is not empty
if (this.messageQueue.size() > 0) {
const message = this.messageQueue.peek();
if (message) {
const result = this.trySendMessage(message);
if (result.isOk()) {
this.messageQueue.dequeue(); // Remove from queue only if successfully sent
sentSomething = true;
} else if (result.error.kind === "SendFailure") {
// Log send failures but keep trying
print(`Failed to send message: ${result.error.reason}`);
this.messageQueue.dequeue(); // Remove failed message to prevent infinite retry
}
// For NoIdleChatbox errors, we keep the message in queue and try again later
}
}
// Try to send a toast if queue is not empty
if (this.toastQueue.size() > 0) {
const toast = this.toastQueue.peek();
if (toast) {
const result = this.trySendToast(toast);
if (result.isOk()) {
this.toastQueue.dequeue(); // Remove from queue only if successfully sent
sentSomething = true;
} else if (result.error.kind === "SendFailure") {
// Log send failures but keep trying
print(`Failed to send toast: ${result.error.reason}`);
this.toastQueue.dequeue(); // Remove failed toast to prevent infinite retry
}
// For NoIdleChatbox errors, we keep the toast in queue and try again later
}
}
// Small sleep to prevent busy waiting and allow other coroutines to run
if (!sentSomething) {
sleep(0.1);
}
}
}
/**
* Main receiving loop - continuously listens for chat events
* Runs in a separate coroutine to handle incoming messages
*/
private receiveLoop(): void {
while (this.isRunning) {
try {
// Listen for chatbox_message events
const event = pullEventAs(ChatBoxEvent, "chat");
if (event) {
// Store received event in buffer for user processing
this.chatBuffer.enqueue(event);
}
} catch (error) {
// Log receive errors but continue running
print(`Error in receive loop: ${String(error)}`);
sleep(0.1); // Brief pause before retrying
}
}
}
/**
* Starts the ChatManager's main operation loops
* Launches both sending and receiving coroutines in parallel
* @returns Result indicating success or failure of startup
*/
public run(): Result<void, ChatManagerError> {
if (this.isRunning) {
return new Err({
kind: "ChatManager",
reason: "ChatManager is already running",
});
}
try {
this.isRunning = true;
// Start both send and receive loops in parallel
parallel.waitForAll(
() => this.sendLoop(),
() => this.receiveLoop(),
);
return new Ok(undefined);
} catch (error) {
this.isRunning = false;
return new Err({
kind: "ChatManager",
reason: `Failed to start ChatManager: ${String(error)}`,
});
}
}
/**
* Starts the ChatManager asynchronously without blocking
* Useful when you need to run other code alongside the ChatManager
* @returns Result indicating success or failure of async startup
*/
public runAsync(): Result<LuaThread, ChatManagerError> {
if (this.isRunning) {
return new Err({
kind: "ChatManager",
reason: "ChatManager is already running",
});
}
try {
this.isRunning = true;
this.thread = coroutine.create(() => {
const result = this.run();
if (result.isErr()) {
print(`ChatManager async error: ${result.error.reason}`);
}
});
// Start the run method in a separate coroutine
coroutine.resume(this.thread);
return new Ok(this.thread);
} catch (error) {
this.isRunning = false;
return new Err({
kind: "ChatManager",
reason: `Failed to start ChatManager async: ${String(error)}`,
});
}
}
/**
* Stops the ChatManager loops gracefully
* @returns Result indicating success or failure of shutdown
*/
public stop(): Result<void, ChatManagerError> {
if (!this.isRunning) {
return new Err({
kind: "ChatManager",
reason: "ChatManager is not running",
});
}
try {
this.isRunning = false;
return new Ok(undefined);
} catch (error) {
return new Err({
kind: "ChatManager",
reason: `Failed to stop ChatManager: ${String(error)}`,
});
}
}
/**
* Gets the number of pending messages in the queue
* @returns Number of pending messages
*/
public getPendingMessageCount(): number {
return this.messageQueue.size();
}
/**
* Gets the number of pending toasts in the queue
* @returns Number of pending toasts
*/
public getPendingToastCount(): number {
return this.toastQueue.size();
}
/**
* Gets the number of received messages in the buffer
* @returns Number of buffered received messages
*/
public getBufferedMessageCount(): number {
return this.chatBuffer.size();
}
/**
* Gets the current status of all chatboxes
* @returns Array of boolean values indicating which chatboxes are idle
*/
public getChatboxStatus(): boolean[] {
return [...this.idleChatboxes]; // Return a copy to prevent external modification
}
/**
* Gets the running state of the ChatManager
* @returns true if ChatManager is currently running
*/
public isManagerRunning(): boolean {
return this.isRunning;
}
/**
* Clears all pending messages and toasts from queues
* Does not affect the received message buffer
* @returns Result indicating success or failure
*/
public clearQueues(): Result<void, ChatManagerError> {
try {
this.messageQueue.clear();
this.toastQueue.clear();
return new Ok(undefined);
} catch (error) {
return new Err({
kind: "ChatManager",
reason: `Failed to clear queues: ${String(error)}`,
});
}
}
/**
* Clears the received message buffer
* @returns Result indicating success or failure
*/
public clearBuffer(): Result<void, ChatManagerError> {
try {
this.chatBuffer.clear();
return new Ok(undefined);
} catch (error) {
return new Err({
kind: "ChatManager",
reason: `Failed to clear buffer: ${String(error)}`,
});
}
}
/**
* Tries to send a message immediately, bypassing the queue
* @param message The message to send immediately
* @returns Result indicating success or failure with error details
*/
public sendMessageImmediate(message: ChatMessage): Result<void, ChatError> {
return this.trySendMessage(message);
}
/**
* Tries to send a toast immediately, bypassing the queue
* @param toast The toast to send immediately
* @returns Result indicating success or failure with error details
*/
public sendToastImmediate(toast: ChatToast): Result<void, ChatError> {
return this.trySendToast(toast);
}
}

36
src/lib/TimerManager.ts Normal file
View File

@@ -0,0 +1,36 @@
import { pullEventAs, TimerEvent } from "./event";
import { Result, Ok, Err, Option, Some, None } from "./thirdparty/ts-result-es";
class TimerManager {
private isRunning = false;
private timerTaskMap = new Map<number, () => void>();
// Don't put heavy logic on callback function
public setTimeOut(delay: number, callback: () => void): void {
const timerId = os.startTimer(delay);
this.timerTaskMap.set(timerId, callback);
}
public run() {
this.isRunning = true;
while (this.isRunning) {
const event = pullEventAs(TimerEvent, "timer");
if (event === undefined) continue;
const task = this.timerTaskMap.get(event.id);
if (task === undefined) continue;
task();
}
}
public stop() {
this.isRunning = false;
}
public status(): boolean {
return this.isRunning;
}
}
export const gTimerManager = new TimerManager();

View File

@@ -94,30 +94,36 @@ function processAndExecute<TContext extends object>(
): Result<void, CliError> { ): Result<void, CliError> {
const { command, commandPath, options, remaining } = parseResult; const { command, commandPath, options, remaining } = parseResult;
// Handle requests for help on a specific command. // Unified Help Check:
if (shouldShowHelp([...remaining, ...Object.keys(options)])) { // A command should show its help page if:
writer(generateHelp(command, commandPath)); // 1. A help flag is explicitly passed (`--help` or `-h`). This has the highest priority.
return Ok.EMPTY; // 2. It's a command group that was called without a subcommand (i.e., it has no action).
} const isHelpFlagPassed = shouldShowHelp([
...remaining,
// If a command has subcommands but no action, show its help page. ...Object.keys(options),
if ( ]);
command.subcommands && const isCommandGroupWithoutAction =
command.subcommands !== undefined &&
command.subcommands.size > 0 && command.subcommands.size > 0 &&
command.action === undefined command.action === undefined;
) {
if (isHelpFlagPassed || isCommandGroupWithoutAction) {
writer(generateHelp(command, commandPath)); writer(generateHelp(command, commandPath));
return Ok.EMPTY; return Ok.EMPTY;
} }
// A command that is meant to be executed must have an action. // If we are here, it's a runnable command. It must have an action.
if (command.action === undefined) { if (command.action === undefined) {
// This case should ideally not be reached if the parser and the logic above are correct.
// It would mean a command has no action and no subcommands, which is a configuration error.
return new Err({ return new Err({
kind: "NoAction", kind: "NoAction",
commandPath: [...commandPath, command.name], commandPath: [...commandPath, command.name],
}); });
} }
// Now we know it's a runnable command, and no help flag was passed.
// We can now safely process the remaining items as arguments.
return processArguments(command.args ?? [], remaining) return processArguments(command.args ?? [], remaining)
.andThen((args) => { .andThen((args) => {
return processOptions( return processOptions(

View File

@@ -103,5 +103,5 @@ export function generateCommandList<TContext extends object>(
* @returns `true` if a help flag is found, otherwise `false`. * @returns `true` if a help flag is found, otherwise `false`.
*/ */
export function shouldShowHelp(argv: string[]): boolean { export function shouldShowHelp(argv: string[]): boolean {
return argv.includes("--help") || argv.includes("-h"); return argv.includes("help") || argv.includes("h");
} }

View File

@@ -87,22 +87,49 @@ export function parseArguments<TContext extends object>(
}; };
let currentCommand = rootCommand; let currentCommand = rootCommand;
let i = 0;
let inOptions = false; let inOptions = false;
const optionMapCache = new OptionMapCache(); const optionMapCache = new OptionMapCache();
const getCurrentOptionMaps = () =>
getOptionMaps(optionMapCache, currentCommand);
while (i < argv.length) { // Cache option maps for current command - only updated when command changes
let currentOptionMaps = getOptionMaps(optionMapCache, currentCommand);
// Helper function to update command context and refresh option maps
const updateCommand = (
newCommand: Command<TContext>,
commandName: string,
) => {
currentCommand = newCommand;
result.command = currentCommand;
result.commandPath.push(commandName);
currentOptionMaps = getOptionMaps(optionMapCache, currentCommand);
};
// Helper function to process option value
const processOption = (optionName: string, i: number): number => {
const optionDef = currentOptionMaps.optionMap.get(optionName);
const nextArg = argv[i + 1];
const isKnownBooleanOption =
optionDef !== undefined && optionDef.defaultValue === undefined;
const nextArgLooksLikeValue =
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
result.options[optionName] = nextArg;
return i + 1; // Skip the value argument
} else {
result.options[optionName] = true;
return i;
}
};
// Single pass through argv
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === undefined || arg === null) { // Skip null/undefined arguments
i++; if (!arg) continue;
continue;
}
// Handle double dash (--) - everything after is treated as a remaining argument // Handle double dash (--) - everything after is treated as remaining
if (arg === "--") { if (arg === "--") {
result.remaining.push(...argv.slice(i + 1)); result.remaining.push(...argv.slice(i + 1));
break; break;
@@ -121,70 +148,33 @@ export function parseArguments<TContext extends object>(
} else { } else {
// --option [value] format // --option [value] format
const optionName = arg.slice(2); const optionName = arg.slice(2);
const optionDef = getCurrentOptionMaps().optionMap.get(optionName); i = processOption(optionName, i);
// Check if this is a known boolean option or if next arg looks like a value
const nextArg = argv[i + 1];
const isKnownBooleanOption =
optionDef !== undefined && optionDef.defaultValue === undefined;
const nextArgLooksLikeValue =
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
result.options[optionName] = nextArg;
i++; // Skip the value argument
} else {
// Boolean flag or no value available
result.options[optionName] = true;
}
} }
} }
// Handle short options (-o or -o value) // Handle short options (-o or -o value)
else if (arg.startsWith("-") && arg.length > 1) { else if (arg.startsWith("-") && arg.length > 1) {
inOptions = true; inOptions = true;
const shortName = arg.slice(1); const shortName = arg.slice(1);
const optionName =
// Get option maps for the new command (lazy loaded and cached) currentOptionMaps.shortNameMap.get(shortName) ?? shortName;
const maps = getCurrentOptionMaps(); i = processOption(optionName, i);
const optionName = maps.shortNameMap.get(shortName) ?? shortName;
const optionDef = maps.optionMap.get(optionName);
// Check if this is a known boolean option or if next arg looks like a value
const nextArg = argv[i + 1];
const isKnownBooleanOption =
optionDef !== undefined && optionDef.defaultValue === undefined;
const nextArgLooksLikeValue =
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
result.options[optionName] = nextArg;
i++; // Skip the value argument
} else {
// Boolean flag or no value available
result.options[optionName] = true;
}
} }
// Handle positional arguments and command resolution // Handle positional arguments and command resolution
else { else {
if (!inOptions) { if (!inOptions) {
// Try to find this as a subcommand of the current command // Try to find this as a subcommand of the current command
const subcommand = currentCommand.subcommands?.get(arg); const subcommand = currentCommand.subcommands?.get(arg);
if (subcommand !== undefined) { if (subcommand) {
// Found a subcommand, move deeper updateCommand(subcommand, arg);
currentCommand = subcommand;
result.command = currentCommand;
result.commandPath.push(arg);
} else { } else {
// Not a subcommand, treat as remaining argument // Not a subcommand, treat as remaining argument
result.remaining.push(arg); result.remaining.push(arg);
} }
} else { } else {
// After options have started, treat as a remaining argument // After options have started, treat as remaining argument
result.remaining.push(arg); result.remaining.push(arg);
} }
} }
i++;
} }
return new Ok(result); return new Ok(result);

View File

@@ -30,7 +30,7 @@ declare type MinecraftColor =
| "light_purple" | "light_purple"
| "yellow" | "yellow"
| "white" | "white"
| "reset"; // RGB color in #RRGGBB format | `#${string}`;
declare type MinecraftFont = declare type MinecraftFont =
| "minecraft:default" | "minecraft:default"

View File

@@ -925,10 +925,22 @@ declare namespace textutils {
function pagedTabulate(...args: (LuaTable | Object | Color)[]): void; function pagedTabulate(...args: (LuaTable | Object | Color)[]): void;
function serialize(tab: object, options?: SerializeOptions): string; function serialize(tab: object, options?: SerializeOptions): string;
function serialise(tab: object, options?: SerializeOptions): string; function serialise(tab: object, options?: SerializeOptions): string;
function serializeJSON(tab: object, nbtStyle?: boolean): string; function serializeJSON(
function serializeJSON(tab: object, options: SerializeJSONOptions): string; tab: object | string | number | boolean,
function serialiseJSON(tab: object, nbtStyle?: boolean): string; nbtStyle?: boolean,
function serialiseJSON(tab: object, options: SerializeJSONOptions): string; ): string;
function serializeJSON(
tab: object | string | number | boolean,
options: SerializeJSONOptions,
): string;
function serialiseJSON(
tab: object | string | number | boolean,
nbtStyle?: boolean,
): string;
function serialiseJSON(
tab: object | string | number | boolean,
options: SerializeJSONOptions,
): string;
function unserialize(str: string): unknown; function unserialize(str: string): unknown;
function unserialise(str: string): unknown; function unserialise(str: string): unknown;
function unserializeJSON( function unserializeJSON(