Compare commits

...

10 Commits

Author SHA1 Message Date
SikongJueluo
a4e74dcfa0 docs: ccCLI framework and ChatManager 2025-11-03 22:32:18 +08:00
SikongJueluo
2f57d9ab3d feature: accesscontrol welcome message 2025-11-03 13:20:21 +08:00
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
SikongJueluo
e680ef0263 reconstruct: project compile 2025-10-27 22:33:27 +08:00
SikongJueluo
1891259ee7 fix: cli framework
fix:
- cli command path wrong
- help output nil
2025-10-27 22:02:53 +08:00
7a17ca7fbf reconstruct: cli framework 2025-10-27 16:50:04 +08:00
27 changed files with 2586 additions and 891 deletions

View File

@@ -5,21 +5,21 @@ sync-path := if os_family() == "windows" { "/cygdrive/c/Users/sikongjueluo/AppDa
build: build-autocraft build-accesscontrol build-test build-example sync
build-autocraft:
pnpm tstl -p ./tsconfig.autocraft.json
pnpm tstl -p ./targets/tsconfig.autocraft.json
build-accesscontrol:
pnpm tstl -p ./tsconfig.accesscontrol.json
pnpm tstl -p ./targets/tsconfig.accesscontrol.json
build-test:
pnpm tstl -p ./tsconfig.test.json
pnpm tstl -p ./targets/tsconfig.test.json
build-example: build-tuiExample build-cliExample
build-tuiExample:
pnpm tstl -p ./tsconfig.tuiExample.json
pnpm tstl -p ./targets/tsconfig.tuiExample.json
build-cliExample:
pnpm tstl -p ./tsconfig.cliExample.json
pnpm tstl -p ./targets/tsconfig.cliExample.json
sync:
rsync --delete -r "./build/" "{{ sync-path }}"

View File

@@ -30,7 +30,17 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
- **Control Flow:** Includes `<For>` and `<Show>` components for conditional and list-based rendering.
- **Component-Based:** Structure your UI into reusable components. See `src/tuiExample/main.ts` for a demo.
### 4. Core Libraries
### 4. ccCLI Framework
A lightweight, functional-style framework for building command-line interfaces (CLIs) within CC:Tweaked. It supports nested commands, arguments, options, and automatic help generation. See the [ccCLI Documentation](docs/ccCLI.md) for more details.
- **Declarative API:** Define commands, arguments, and options using a simple, object-based structure.
- **Nested Commands:** Organize complex applications with subcommands (e.g., `mycli command subcommand`).
- **Automatic Help:** Generates detailed help messages for commands and subcommands.
- **Global Context:** Inject shared state or services into command actions.
- **Type-Safe:** Built with TypeScript for robust development.
### 5. Core Libraries
- **`ChatManager`:** A powerful manager for `chatBox` peripherals that handles message queuing, cooldowns, and asynchronous sending/receiving. See the [ChatManager Documentation](docs/ChatManager.md) for more details.
- **`ccLog`:** A robust logging library with automatic, time-based log file rotation.
- **`PeripheralManager`:** A utility for easily finding and requiring peripherals by name or type.
- **`CraftManager`:** A library for parsing and executing crafting recipes from Create mod packages.

235
docs/ChatManager.md Normal file
View File

@@ -0,0 +1,235 @@
# ChatManager Documentation
## Introduction
`ChatManager` is a powerful utility for managing interactions with one or more `chatBox` peripherals in ComputerCraft. It simplifies the process of sending and receiving chat messages by handling complexities like peripheral cooldowns, message queuing, and asynchronous operations.
It is designed for applications that need to reliably send a high volume of messages or toasts without getting bogged down by peripheral limitations, or for applications that need to listen for commands or messages from players.
## Features
* **Multi-Peripheral Management:** Seamlessly manages one or more `chatBox` peripherals.
* **Message Queuing:** Automatically queues messages and toasts, sending them as chatboxes become available.
* **Cooldown Handling:** Respects the 1-second cooldown of chatboxes to prevent message loss.
* **Asynchronous Operation:** Can run in the background (`runAsync`) without blocking your main program loop.
* **Message Buffering:** Receives and buffers incoming chat messages for your application to process.
* **Queued and Immediate Sending:** Supports both adding messages to a queue and sending them immediately (if a chatbox is available).
* **Rich Content Support:** Send simple strings or complex formatted messages using `MinecraftTextComponent`.
* **Robust Error Handling:** Uses a `Result`-based API to make error handling explicit and reliable.
* **Comprehensive API:** Provides methods for sending global messages, private messages, and toast notifications.
## Tutorial: Getting Started with ChatManager
Heres how to integrate `ChatManager` into your project.
### 1. Initialization
First, find your available `chatBox` peripherals and create a `ChatManager` instance.
```typescript
import { ChatManager } from '@/lib/ChatManager';
// 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) {
print("Error: No chatbox peripherals found.");
return;
}
// Create the manager instance
const chatManager = new ChatManager(chatboxPeripherals);
```
### 2. Running the Manager
To start the sending and receiving loops, you must run the manager. For most use cases, running it asynchronously is best.
```typescript
// Start ChatManager in the background so it doesn't block the main program
const runResult = chatManager.runAsync();
if (runResult.isErr()) {
print(`Warning: Failed to start ChatManager: ${runResult.error.reason}`);
} else {
print("ChatManager started successfully!");
}
// Your main program logic can continue here...
```
**Important:** `ChatManager` relies on `gTimerManager` to handle cooldowns. Ensure you are also running the global timer manager in your application.
```typescript
import { gTimerManager } from "@/lib/TimerManager";
// In your main parallel loop
parallel.waitForAll(
() => yourMainLoop(),
() => chatManager.run(), // if you choose the blocking run
() => gTimerManager.run()
);
```
### 3. Sending a Message (Queued)
Use `sendMessage` to add a message to the queue. `ChatManager` will send it as soon as a chatbox is free.
```typescript
// Send a global message
chatManager.sendMessage({
message: "Hello, world!",
prefix: "MySystem",
});
// Send a private message
chatManager.sendMessage({
message: "This is a secret.",
targetPlayer: "Steve",
prefix: "Whisper",
});
```
### 4. Sending a Toast (Queued)
Similarly, use `sendToast` to queue a toast notification.
```typescript
chatManager.sendToast({
title: "Server Alert",
message: "Restart in 5 minutes!",
targetPlayer: "Steve",
prefix: "Admin",
});
```
### 5. Receiving Messages
Use `getReceivedMessage` to pull incoming chat events from the buffer. It's best to do this in a loop.
```typescript
function myCliLoop() {
while (true) {
const result = chatManager.getReceivedMessage();
if (result.isOk()) {
const event = result.value;
print(`[${event.username}]: ${event.message}`);
// Process the command or message...
} else {
// Buffer is empty, wait a bit before checking again
sleep(0.5);
}
}
}
```
## Advanced Topics
### Immediate Sending
If you need to send a message right away and bypass the queue, use the `Immediate` methods. These will fail if no chatbox is currently available.
```typescript
const result = chatManager.sendMessageImmediate({
message: "URGENT!",
targetPlayer: "Admin",
});
if (result.isErr()) {
if (result.error.kind === "NoIdleChatbox") {
print("Could not send immediately: all chatboxes are busy.");
} else {
print(`Failed to send message: ${result.error.reason}`);
}
}
```
### Rich Text Messages (`MinecraftTextComponent`)
You can send fully formatted messages by providing a `MinecraftTextComponent` object instead of a string.
```typescript
const richMessage: MinecraftTextComponent = {
text: "This is ",
color: "gold",
extra: [
{ text: "important!", color: "red", bold: true }
],
};
chatManager.sendMessage({
message: richMessage,
targetPlayer: "AllPlayers",
utf8Support: true, // Recommended for complex components
});
```
### Error Handling
Methods return a `Result` object (`Ok` or `Err`). Always check the result to handle potential failures gracefully.
```typescript
const result = chatManager.sendMessage(message);
if (result.isErr()) {
logger.error(`Failed to queue message: ${result.error.reason}`);
}
```
The possible error `kind`s are:
* `ChatManagerError`: General errors, e.g., failure to enqueue.
* `NoIdleChatboxError`: Returned by `Immediate` methods when no chatbox is free.
* `SendFailureError`: A hardware or permission error occurred during sending.
* `EmptyBufferError`: Returned by `getReceivedMessage` when the buffer is empty.
### Status and Management
You can inspect and control the `ChatManager` at runtime.
```typescript
// Get the number of items waiting to be sent
const pending = chatManager.getPendingMessageCount();
print(`Messages in queue: ${pending}`);
// Get the number of received messages waiting to be processed
const buffered = chatManager.getBufferedMessageCount();
print(`Received messages in buffer: ${buffered}`);
// Get the status of each chatbox (true = idle, false = busy)
const statuses = chatManager.getChatboxStatus();
// Clear the sending queues
chatManager.clearQueues();
// Clear the received message buffer
chatManager.clearBuffer();
// Stop the manager's background loops
chatManager.stop();
```
## API Reference
### Core Class
* `ChatManager(peripherals: ChatBoxPeripheral[])`
### Primary Methods
* `run(): Result<void, ChatManagerError>`: Starts the manager (blocking).
* `runAsync(): Result<LuaThread, ChatManagerError>`: Starts the manager in the background.
* `stop(): Result<void, ChatManagerError>`: Stops the background loops.
* `sendMessage(message: ChatMessage): Result<void, ChatManagerError>`: Queues a chat message.
* `sendToast(toast: ChatToast): Result<void, ChatManagerError>`: Queues a toast.
* `getReceivedMessage(): Result<ChatBoxEvent, EmptyBufferError>`: Retrieves a message from the receive buffer.
* `sendMessageImmediate(message: ChatMessage): Result<void, ChatError>`: Sends a message immediately.
* `sendToastImmediate(toast: ChatToast): Result<void, ChatError>`: Sends a toast immediately.
### Interfaces
* `ChatMessage`
* `ChatToast`
* `ChatError` (union of all possible error types)

248
docs/ccCLI.md Normal file
View File

@@ -0,0 +1,248 @@
# ccCLI Framework Documentation
## Introduction
`ccCLI` is a lightweight, functional-style framework for building command-line interfaces (CLIs) within the CC:Tweaked environment using TSTL (TypeScriptToLua). It provides a declarative and type-safe way to define commands, arguments, and options, with built-in support for nested commands, automatic help generation, and robust error handling.
Its design is inspired by modern CLI libraries and emphasizes simplicity and ease of use, allowing developers to quickly structure complex command-based applications.
## Features
* **Declarative API:** Define commands as simple objects.
* **Type-Safe:** Leverage TypeScript for defining commands, arguments, options, and context.
* **Nested Commands:** Easily create command groups and subcommands (e.g., `git remote add`).
* **Automatic Help Generation:** Generates `--help` messages for the root command and all subcommands.
* **Flexible Argument & Option Parsing:** Supports long names (`--verbose`), short names (`-v`), value assignment (`--file=path.txt`), and boolean flags.
* **Global Context Injection:** Share state, services, or configuration across all commands.
* **Result-Based Error Handling:** Command actions return a `Result` type, ensuring that errors are handled explicitly.
* **No Dependencies:** Written in pure TypeScript with no external runtime dependencies.
## Core Concepts
The framework is built around a few key interfaces:
* `Command<TContext>`: The central piece. It defines a command's name, description, arguments, options, subcommands, and the action to perform.
* `Argument`: Defines a positional argument for a command. It can be marked as required.
* `Option`: Defines a named option (flag). It can have a long name, a short name, a default value, and be marked as required.
* `ActionContext<TContext>`: The object passed to every command's `action` function. It contains the parsed `args`, `options`, and the shared `context` object.
## Tutorial: Creating a Simple Calculator CLI
Let's build a simple calculator to see how `ccCLI` works.
### 1. Define the Global Context (Optional)
The global context is a powerful feature for sharing data or services. Let's define a context for our app.
```typescript
// src/cliExample/main.ts
interface AppContext {
appName: string;
log: (message: string) => void;
debugMode: boolean;
}
```
### 2. Define Commands
Commands are just JavaScript objects. The logic goes into the `action` function.
```typescript
// src/cliExample/main.ts
import { Command, CliError } from "../lib/ccCLI/index";
import { Ok, Result } from "../lib/thirdparty/ts-result-es";
const addCommand: Command<AppContext> = {
name: "add",
description: "Adds two numbers together",
args: [
{ name: "a", description: "The first number", required: true },
{ name: "b", description: "The second number", required: true },
],
action: ({ args, context }): Result<void, CliError> => {
context.log(`Executing 'add' command in '${context.appName}'`);
const a = tonumber(args.a as string);
const b = tonumber(args.b as string);
if (a === undefined || b === undefined) {
print("Error: Arguments must be numbers.");
return Ok.EMPTY;
}
const result = a + b;
print(`${a} + ${b} = ${result}`);
return Ok.EMPTY;
},
};
```
### 3. Create Nested Commands
You can group commands under a parent command using the `subcommands` property.
```typescript
// src/cliExample/main.ts
// (addCommand is defined above, subtractCommand would be similar)
const mathCommand: Command<AppContext> = {
name: "math",
description: "Mathematical operations",
subcommands: new Map([
["add", addCommand],
["subtract", subtractCommand], // Assuming subtractCommand is defined
]),
};
```
If a command with subcommands is called without an action, it will automatically display its help page.
### 4. Define the Root Command
The root command is the entry point for your entire application. It contains all top-level commands and global options.
```typescript
// src/cliExample/main.ts
const rootCommand: Command<AppContext> = {
name: "calculator",
description: "A feature-rich calculator program",
options: new Map([
[
"debug",
{
name: "debug",
shortName: "d",
description: "Enable debug mode",
defaultValue: false,
},
],
]),
subcommands: new Map([
["math", mathCommand],
// other commands...
]),
action: ({ context }) => {
print(`Welcome to ${context.appName}!`);
print("Use --help to see available commands");
return Ok.EMPTY;
},
};
```
### 5. Create and Run the CLI
Finally, create the context instance and pass it along with the root command to `createCli`. This returns a handler function that you can call with the program's arguments.
```typescript
// src/cliExample/main.ts
import { createCli } from "../lib/ccCLI/index";
// Create global context instance
const appContext: AppContext = {
appName: "MyAwesome Calculator",
debugMode: false,
log: (message) => {
if (appContext.debugMode) {
print(`[LOG] ${message}`);
}
},
};
// Create the CLI handler
const cli = createCli(rootCommand, { globalContext: appContext });
// Get arguments and run
const args = [...$vararg];
cli(args);
```
### Usage
You can now run your CLI from the ComputerCraft terminal:
```sh
> lua program.lua math add 5 7
12
> lua program.lua --debug math add 5 7
[LOG] Executing 'add' command in 'MyAwesome Calculator'
12
> lua program.lua math --help
# Displays help for the 'math' command
```
## Advanced Topics
### Arguments
Arguments are positional values passed after a command. They are defined in an array.
```typescript
args: [
{ name: "a", description: "The first number", required: true },
{ name: "b", description: "The second number" }, // optional
],
```
### Options
Options are named values (flags) that can appear anywhere. They are defined in a `Map`.
```typescript
options: new Map([
[
"name", // The key in the map must match the option's name
{
name: "name",
shortName: "n",
description: "The name to greet",
defaultValue: "World",
},
],
[
"force",
{
name: "force",
description: "Force the operation",
defaultValue: false, // For boolean flags
},
],
]),
```
They can be used like this:
* `--name "John"` or `-n "John"`
* `--name="John"`
* `--force` (sets the value to `true`)
### Error Handling
The `action` function must return a `Result<void, CliError>`.
* Return `Ok.EMPTY` on success.
* The framework automatically handles parsing errors like missing arguments or unknown commands. You can return your own errors from within an action if needed, though this is less common. The primary mechanism is simply printing an error message and returning `Ok.EMPTY`.
## API Reference
The public API is exposed through `src/lib/ccCLI/index.ts`.
### Core Function
* `createCli<TContext>(rootCommand, options)`: Creates the main CLI handler function.
* `rootCommand`: The top-level command of your application.
* `options.globalContext`: The context object to be injected into all actions.
* `options.writer`: An optional function to handle output (defaults to `textutils.pagedPrint`).
### Core Types
* `Command<TContext>`
* `Argument`
* `Option`
* `ActionContext<TContext>`
* `CliError`
This documentation provides a comprehensive overview of the `ccCLI` framework. By following the tutorial and referencing the examples, you can build powerful and well-structured command-line tools for CC:Tweaked.

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 {
AccessConfig,
@@ -5,574 +7,394 @@ import {
loadConfig,
saveConfig,
} from "./config";
import { ChatBoxEvent, pullEventAs } from "@/lib/event";
import { parseBoolean } from "@/lib/common";
// CLI命令接口
interface CLICommand {
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 {
// 1. Define AppContext
export interface AppContext {
configFilepath: string;
reloadConfig: () => void;
log: CCLog;
chatBox: ChatBoxPeripheral;
logger: CCLog;
print: (
message: string | MinecraftTextComponent | MinecraftTextComponent[],
) => void;
}
function getGroupNames(config: AccessConfig) {
return config.usersGroups.flatMap((value) => value.groupName);
return config.usersGroups.map((value) => value.groupName);
}
// 基础命令处理器
class CLICommandProcessor {
private commands = new Map<string, CLICommand>();
private context: CLIContext;
// 2. Define Commands
constructor(context: CLIContext) {
this.context = context;
this.initializeCommands();
}
private initializeCommands() {
// 注册所有命令
this.registerCommand(new AddCommand());
this.registerCommand(new DelCommand());
this.registerCommand(new ListCommand());
this.registerCommand(new SetCommand());
this.registerCommand(new EditCommand());
this.registerCommand(new ShowConfigCommand());
this.registerCommand(new HelpCommand());
}
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 addCommand: Command<AppContext> = {
name: "add",
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,
];
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 listUserCommand: Command<AppContext> = {
name: "user",
description: "列出所有玩家及其所在的用户组",
action: ({ context }) => {
const config = loadConfig(context.configFilepath)!;
let message = `管理员 : [ ${config.adminGroupConfig.groupUsers.join(
", ",
)} ]\n`;
for (const groupConfig of config.usersGroups) {
const users = groupConfig.groupUsers ?? [];
message += `${groupConfig.groupName} : [ ${users.join(", ")} ]\n`;
}
context.print({ text: message.trim() });
return Ok.EMPTY;
},
};
return {
success: true,
message: message.trim(),
};
}
}
// 设置命令
class SetCommand implements CLICommand {
name = "set";
description = "Config access control settings";
usage = "set <option> <value>";
execute(args: string[], _executor: string, context: CLIContext): CLIResult {
if (args.length !== 2) {
return {
success: false,
message: `Usage: ${this.usage}\nOptions: warnInterval, detectInterval, detectRange`,
};
}
const [option, valueStr] = args;
const value = parseInt(valueStr);
if (isNaN(value)) {
return {
success: false,
message: `Invalid value: ${valueStr}. Must be a number.`,
};
}
const config: AccessConfig = loadConfig(context.configFilepath)!;
switch (option) {
case "warnInterval":
config.watchInterval = value;
return {
success: true,
message: `Set warn interval to ${config.watchInterval}`,
shouldSaveConfig: true,
config,
};
case "detectInterval":
config.detectInterval = value;
return {
success: true,
message: `Set detect interval to ${config.detectInterval}`,
shouldSaveConfig: true,
config,
};
case "detectRange":
config.detectRange = value;
return {
success: true,
message: `Set detect range to ${config.detectRange}`,
shouldSaveConfig: true,
config,
};
default:
return {
success: false,
message: `Unknown option: ${option}. Available options: warnInterval, detectInterval, detectRange`,
};
}
}
}
// 帮助命令
class HelpCommand implements CLICommand {
name = "help";
description = "Show command help";
usage = "help";
execute(_args: string[], _executor: string, context: CLIContext): CLIResult {
const listGroupCommand: Command<AppContext> = {
name: "group",
description: "显示详细的用户组配置信息",
action: ({ context }) => {
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
`;
let groupsMessage = `管理员组: ${config.adminGroupConfig.groupName}\n`;
groupsMessage += ` 用户: [${config.adminGroupConfig.groupUsers.join(
", ",
)}]\n`;
groupsMessage += ` 允许: ${config.adminGroupConfig.isAllowed}\n`;
groupsMessage += ` 通知: ${config.adminGroupConfig.isNotice}\n\n`;
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`,
};
for (const group of config.usersGroups) {
groupsMessage += `用户组: ${group.groupName}\n`;
groupsMessage += ` 用户: [${(group.groupUsers ?? []).join(", ")}]\n`;
groupsMessage += ` 允许: ${group.isAllowed}\n`;
groupsMessage += ` 通知: ${group.isNotice}\n`;
groupsMessage += "\n";
}
context.print({ text: groupsMessage.trim() });
return Ok.EMPTY;
},
};
const [target, ...rest] = args;
const listToastCommand: Command<AppContext> = {
name: "toast",
description: "显示 Toast 配置信息",
action: ({ context }) => {
const config = loadConfig(context.configFilepath)!;
let toastMessage = "默认 Toast 配置:\n";
toastMessage += ` 标题: ${config.welcomeToastConfig.title.text}\n`;
toastMessage += ` 消息: ${config.welcomeToastConfig.msg.text}\n`;
toastMessage += ` 前缀: ${config.welcomeToastConfig.prefix ?? "none"}\n`;
toastMessage += ` 括号: ${config.welcomeToastConfig.brackets ?? "none"}\n`;
toastMessage += ` 括号颜色: ${
config.welcomeToastConfig.bracketColor ?? "none"
}\n\n`;
switch (target) {
case "group":
return this.editGroup(rest, context);
default:
return {
success: false,
message: `Unknown target: ${target}. Available: group`,
};
}
}
toastMessage += "警告 Toast 配置:\n";
toastMessage += ` 标题: ${config.warnToastConfig.title.text}\n`;
toastMessage += ` 消息: ${config.warnToastConfig.msg.text}\n`;
toastMessage += ` 前缀: ${config.warnToastConfig.prefix ?? "none"}\n`;
toastMessage += ` 括号: ${config.warnToastConfig.brackets ?? "none"}\n`;
toastMessage += ` 括号颜色: ${
config.warnToastConfig.bracketColor ?? "none"
}`;
context.print({ text: toastMessage });
return Ok.EMPTY;
},
};
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 listAllCommand: Command<AppContext> = {
name: "all",
description: "显示基本配置信息概览",
action: ({ context }) => {
const config = loadConfig(context.configFilepath)!;
let allMessage = `检测范围: ${config.detectRange}\n`;
allMessage += `检测间隔: ${config.detectInterval}\n`;
allMessage += `警告间隔: ${config.watchInterval}\n`;
allMessage += `通知次数: ${config.noticeTimes}\n`;
allMessage += `全局欢迎功能: ${config.isWelcome}\n`;
allMessage += `全局警告功能: ${config.isWarn}\n\n`;
allMessage += "使用 'list group' 或 'list toast' 查看详细信息";
context.print({ text: allMessage });
return Ok.EMPTY;
},
};
const [groupName, property, valueStr] = args;
const config: AccessConfig = loadConfig(context.configFilepath)!;
const listCommand: Command<AppContext> = {
name: "list",
description: "列出玩家、组信息或配置",
subcommands: new Map([
["user", listUserCommand],
["group", listGroupCommand],
["toast", listToastCommand],
["all", listAllCommand],
]),
action: ({ context }) => {
const config = loadConfig(context.configFilepath)!;
let allMessage = `检测范围: ${config.detectRange}\n`;
allMessage += `检测间隔: ${config.detectInterval}\n`;
allMessage += `警告间隔: ${config.watchInterval}\n`;
allMessage += `通知次数: ${config.noticeTimes}\n`;
allMessage += `全局欢迎功能: ${config.isWelcome}\n`;
allMessage += `全局警告功能: ${config.isWarn}\n\n`;
allMessage += "使用 'list group' 或 'list toast' 查看详细信息";
context.print({ text: allMessage });
return Ok.EMPTY;
},
};
let groupConfig: UserGroupConfig | undefined;
const configCommand: Command<AppContext> = {
name: "config",
description: "配置访问控制设置",
args: [
{
name: "option",
description:
"要设置的选项 (warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn) 或用户组属性 (<groupName>.isAllowed, <groupName>.isNotice, <groupName>.isWelcome)",
required: true,
},
{ name: "value", description: "要设置的值", required: true },
],
action: ({ args, context }) => {
const [option, valueStr] = [args.option as string, args.value as string];
const config = loadConfig(context.configFilepath)!;
if (groupName === "admin") {
groupConfig = config.adminGroupConfig;
// Check if it's a group property (contains a dot)
if (option.includes(".")) {
const dotIndex = option.indexOf(".");
const groupName = option.substring(0, dotIndex);
const property = option.substring(dotIndex + 1);
let groupConfig: UserGroupConfig | undefined;
if (groupName === "admin") {
groupConfig = config.adminGroupConfig;
} else {
groupConfig = config.usersGroups.find((g) => g.groupName === groupName);
}
if (!groupConfig) {
context.print({ text: `用户组 ${groupName} 未找到` });
return Ok.EMPTY;
}
const boolValue = parseBoolean(valueStr);
if (boolValue === undefined) {
context.print({
text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`,
});
return Ok.EMPTY;
}
let message = "";
switch (property) {
case "isAllowed":
groupConfig.isAllowed = boolValue;
message = `已设置 ${groupName}.isAllowed 为 ${boolValue}`;
break;
case "isNotice":
groupConfig.isNotice = boolValue;
message = `已设置 ${groupName}.isNotice 为 ${boolValue}`;
break;
case "isWelcome":
groupConfig.isWelcome = boolValue;
message = `已设置 ${groupName}.isWelcome 为 ${boolValue}`;
break;
default:
context.print({
text: `未知属性: ${property}. 可用属性: isAllowed, isNotice, isWelcome`,
});
return Ok.EMPTY;
}
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: message });
return Ok.EMPTY;
} else {
groupConfig = config.usersGroups.find(
(group) => group.groupName === groupName,
);
}
// Handle basic configuration options
let message = "";
if (!groupConfig) {
return {
success: false,
message: `Group ${groupName} not found`,
};
}
// Check if it's a boolean option
if (option === "isWelcome" || option === "isWarn") {
const boolValue = parseBoolean(valueStr);
if (boolValue === undefined) {
context.print({
text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`,
});
return Ok.EMPTY;
}
switch (property) {
case "isAllowed": {
const val = parseBoolean(valueStr);
if (val != undefined) {
groupConfig.isAllowed = val;
return {
success: true,
message: `Set ${groupName}.isAllowed to ${groupConfig.isAllowed}`,
shouldSaveConfig: true,
config,
};
} else {
return {
success: false,
message: `Set ${groupName}.isAllowed failed`,
shouldSaveConfig: false,
};
switch (option) {
case "isWelcome":
config.isWelcome = boolValue;
message = `已设置全局欢迎功能为 ${boolValue}`;
break;
case "isWarn":
config.isWarn = boolValue;
message = `已设置全局警告功能为 ${boolValue}`;
break;
}
} else {
// Handle numeric options
const value = parseInt(valueStr);
if (isNaN(value)) {
context.print({ text: `无效的值: ${valueStr}. 必须是一个数字。` });
return Ok.EMPTY;
}
switch (option) {
case "warnInterval":
config.watchInterval = value;
message = `已设置警告间隔为 ${value}`;
break;
case "detectInterval":
config.detectInterval = value;
message = `已设置检测间隔为 ${value}`;
break;
case "detectRange":
config.detectRange = value;
message = `已设置检测范围为 ${value}`;
break;
case "noticeTimes":
config.noticeTimes = value;
message = `已设置通知次数为 ${value}`;
break;
default:
context.print({
text: `未知选项: ${option}. 可用选项: warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn 或 <groupName>.isAllowed, <groupName>.isNotice, <groupName>.isWelcome`,
});
return Ok.EMPTY;
}
}
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:
return {
success: false,
message: `Unknown property: ${property}. Available: isAllowed, isNotice`,
};
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: message });
return Ok.EMPTY;
}
}
}
// 显示配置命令
class ShowConfigCommand implements CLICommand {
name = "showconfig";
description = "Show configuration";
usage = "showconfig [type]";
execute(args: string[], _executor: string, context: CLIContext): CLIResult {
const type = args[0] || "all";
const config = loadConfig(context.configFilepath)!;
switch (type) {
case "groups": {
let groupsMessage = `Admin Group: ${config.adminGroupConfig.groupName}\n`;
groupsMessage += ` Users: [${config.adminGroupConfig.groupUsers.join(", ")}]\n`;
groupsMessage += ` Allowed: ${config.adminGroupConfig.isAllowed}\n`;
groupsMessage += ` notice: ${config.adminGroupConfig.isNotice}\n\n`;
for (const group of config.usersGroups) {
groupsMessage += `Group: ${group.groupName}\n`;
groupsMessage += ` Users: [${(group.groupUsers ?? []).join(", ")}]\n`;
groupsMessage += ` Allowed: ${group.isAllowed}\n`;
groupsMessage += ` Notice: ${group.isNotice}\n`;
groupsMessage += "\n";
}
return {
success: true,
message: groupsMessage.trim(),
};
}
case "toast": {
let toastMessage = "Default Toast Config:\n";
toastMessage += ` Title: ${config.welcomeToastConfig.title.text}\n`;
toastMessage += ` Message: ${config.welcomeToastConfig.msg.text}\n`;
toastMessage += ` Prefix: ${config.welcomeToastConfig.prefix ?? "none"}\n`;
toastMessage += ` Brackets: ${config.welcomeToastConfig.brackets ?? "none"}\n`;
toastMessage += ` Bracket Color: ${config.welcomeToastConfig.bracketColor ?? "none"}\n\n`;
toastMessage += "Warn Toast Config:\n";
toastMessage += ` Title: ${config.warnToastConfig.title.text}\n`;
toastMessage += ` Message: ${config.warnToastConfig.msg.text}\n`;
toastMessage += ` Prefix: ${config.warnToastConfig.prefix ?? "none"}\n`;
toastMessage += ` Brackets: ${config.warnToastConfig.brackets ?? "none"}\n`;
toastMessage += ` Bracket Color: ${config.warnToastConfig.bracketColor ?? "none"}`;
return {
success: true,
message: toastMessage,
};
}
case "all": {
let allMessage = `Detect Range: ${config.detectRange}\n`;
allMessage += `Detect Interval: ${config.detectInterval}\n`;
allMessage += `Warn Interval: ${config.watchInterval}\n\n`;
allMessage +=
"Use 'showconfig groups' or 'showconfig toast' for detailed view";
return {
success: true,
message: allMessage,
};
}
default:
return {
success: false,
message: `Invalid type: ${type}. Available: groups, toast, all`,
};
}
}
}
// CLI循环处理器
export class AccessControlCLI {
private processor: CLICommandProcessor;
private context: CLIContext;
constructor(context: CLIContext) {
this.context = context;
this.processor = new CLICommandProcessor(context);
}
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);
},
};
// Root command
const rootCommand: Command<AppContext> = {
name: "@AC",
description: "访问控制命令行界面",
subcommands: new Map([
["add", addCommand],
["del", delCommand],
["list", listCommand],
["config", configCommand],
]),
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;
},
};
export function createAccessControlCli(context: AppContext) {
return createCli(rootCommand, {
globalContext: context,
writer: (msg) => context.print({ text: msg }),
});
}

View File

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

View File

@@ -1,10 +1,12 @@
import { CCLog, DAY, LogLevel } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
import { createAccessControlCLI } from "./cli";
import { createAccessControlCli } from "./cli";
import { launchAccessControlTUI } from "./tui";
import * as peripheralManager from "../lib/PeripheralManager";
import { deepCopy } from "@/lib/common";
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];
@@ -23,16 +25,20 @@ logger.info("Load config successfully!");
logger.debug(textutils.serialise(config, { allow_repetitions: true }));
// Peripheral
const playerDetector = peripheralManager.findByNameRequired("playerDetector");
const chatBox = peripheralManager.findByNameRequired("chatBox");
const playerDetector = peripheral.find(
"playerDetector",
)[0] as PlayerDetectorPeripheral;
const chatBox = peripheral.find("chatBox")[0] as ChatBoxPeripheral;
const chatManager: ChatManager = new ChatManager([chatBox]);
// Global
let inRangePlayers: string[] = [];
let watchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
let gInRangePlayers: string[] = [];
let gWatchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
let gIsRunning = true;
interface ParseParams {
name?: string;
group?: string;
playerName?: string;
groupName?: string;
info?: PlayerInfo;
}
@@ -44,8 +50,8 @@ function reloadConfig() {
}
config = loadConfig(configFilepath)!;
inRangePlayers = [];
watchPlayersInfo = [];
gInRangePlayers = [];
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
}
@@ -53,7 +59,7 @@ function reloadConfig() {
function safeParseTextComponent(
component: MinecraftTextComponent,
params?: ParseParams,
): string {
): MinecraftTextComponent {
const newComponent = deepCopy(component);
if (newComponent.text == undefined) {
@@ -61,11 +67,11 @@ function safeParseTextComponent(
} else if (newComponent.text.includes("%")) {
newComponent.text = newComponent.text.replace(
"%playerName%",
params?.name ?? "UnknowPlayer",
params?.playerName ?? "UnknowPlayer",
);
newComponent.text = newComponent.text.replace(
"%groupName%",
params?.group ?? "UnknowGroup",
params?.groupName ?? "UnknowGroup",
);
newComponent.text = newComponent.text.replace(
"%playerPosX%",
@@ -80,7 +86,34 @@ function safeParseTextComponent(
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(
@@ -94,22 +127,22 @@ function sendToast(
releaser = configLock.tryAcquireRead();
}
chatBox.sendFormattedToastToPlayer(
safeParseTextComponent(
chatManager.sendToast({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
safeParseTextComponent(
title: safeParseTextComponent(
toastConfig.title ?? config.welcomeToastConfig.title,
params,
),
targetPlayer,
toastConfig.prefix ?? config.welcomeToastConfig.prefix,
toastConfig.brackets ?? config.welcomeToastConfig.brackets,
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
undefined,
true,
);
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
targetPlayer: targetPlayer,
utf8Support: true,
});
releaser.release();
}
@@ -131,7 +164,7 @@ function sendNotice(player: string, playerInfo?: PlayerInfo) {
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(config.noticeToastConfig, targetPlayer, {
name: player,
playerName: player,
info: playerInfo,
});
sleep(1);
@@ -149,32 +182,32 @@ function sendWarn(player: string) {
releaser = configLock.tryAcquireRead();
}
sendToast(config.warnToastConfig, player, { name: player });
chatBox.sendFormattedMessageToPlayer(
safeParseTextComponent(config.warnToastConfig.msg, { name: player }),
player,
"AccessControl",
"[]",
undefined,
undefined,
true,
);
sendToast(config.warnToastConfig, player, { playerName: player });
chatManager.sendMessage({
message: safeParseTextComponent(config.warnToastConfig.msg, {
playerName: player,
}),
targetPlayer: player,
prefix: "AccessControl",
brackets: "[]",
utf8Support: true,
});
releaser.release();
}
function watchLoop() {
while (true) {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
const watchPlayerNames = watchPlayersInfo.flatMap((value) => value.name);
const watchPlayerNames = gWatchPlayersInfo.flatMap((value) => value.name);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of watchPlayersInfo) {
for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name);
if (inRangePlayers.includes(player.name)) {
if (gInRangePlayers.includes(player.name)) {
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
@@ -190,7 +223,7 @@ function watchLoop() {
);
} else {
// Get rid of player from list
watchPlayersInfo = watchPlayersInfo.filter(
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
@@ -206,7 +239,7 @@ function watchLoop() {
}
function mainLoop() {
while (true) {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(0.1);
@@ -218,21 +251,34 @@ function mainLoop() {
logger.debug(`Detected ${players.length} players: ${playersList}`);
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)) {
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;
}
// New player appear
const playerInfo = playerDetector.getPlayerPos(player);
let groupConfig: UserGroupConfig = {
groupName: "Unfamiliar",
groupUsers: [],
isAllowed: false,
isNotice: false,
isWelcome: false,
};
// Get user group config
for (const userGroupConfig of config.usersGroups) {
if (userGroupConfig.groupUsers == undefined) continue;
if (!userGroupConfig.groupUsers.includes(player)) continue;
@@ -241,31 +287,46 @@ function mainLoop() {
logger.info(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (userGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
break;
}
if (groupConfig.isAllowed) continue;
logger.warn(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
if (config.isWarn) sendWarn(player);
watchPlayersInfo = [
...watchPlayersInfo,
gWatchPlayersInfo = [
...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 },
];
}
inRangePlayers = players;
gInRangePlayers = players;
releaser.release();
os.sleep(config.detectInterval);
}
}
function keyboardLoop() {
while (true) {
const [eventType, key] = os.pullEvent("key");
if (eventType === "key" && key === keys.c) {
while (gIsRunning) {
const event = pullEventAs(KeyEvent, "key");
if (event === undefined) continue;
if (event.key === keys.c) {
logger.info("Launching Access Control TUI...");
try {
logger.setInTerminal(false);
@@ -277,7 +338,66 @@ function keyboardLoop() {
logger.setInTerminal(true);
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 +405,18 @@ function main(args: string[]) {
logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) {
if (args[0] == "start") {
// 创建CLI处理器
const cli = createAccessControlCLI({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
log: logger,
chatBox: chatBox,
});
print(
"Access Control System started. Press 'c' to open configuration TUI.",
);
const tutorial: string[] = [];
tutorial.push("Access Control System started.");
tutorial.push("\tPress 'c' to open configuration TUI.");
tutorial.push("\tPress 'r' to reload configuration.");
print(tutorial.join("\n"));
parallel.waitForAll(
() => {
mainLoop();
},
() => {
cli.startConfigLoop();
},
() => {
watchLoop();
},
() => {
keyboardLoop();
},
() => mainLoop(),
() => gTimerManager.run(),
() => cliLoop(),
() => watchLoop(),
() => keyboardLoop(),
() => chatManager.run(),
);
return;

View File

@@ -323,6 +323,15 @@ const AccessControlTUI = () => {
onChange: (checked) => setConfig("isWarn", checked),
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Welcome:"),
input({
type: "checkbox",
checked: () => config().isWelcome ?? false,
onChange: (checked) => setConfig("isWelcome", checked),
}),
),
);
};
@@ -355,6 +364,34 @@ const AccessControlTUI = () => {
{ class: "flex flex-col ml-2" },
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(
{ class: "flex flex-row" },
label({}, "Is Allowed:"),
@@ -541,7 +578,12 @@ const AccessControlTUI = () => {
label({}, "Prefix:"),
input({
type: "text",
value: () => getTempToastConfig().prefix,
value: () => {
const str = textutils.serialiseJSON(getTempToastConfig().prefix, {
unicode_strings: true,
});
return str.substring(1, str.length - 1);
},
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), prefix: value }),
onFocusChanged: () => {

View File

@@ -1,34 +1,37 @@
/**
* Example CLI application demonstrating the ccCLI framework
* 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 { Ok, Result } from "../lib/thirdparty/ts-result-es";
import { ChatManager, ChatMessage, ChatToast } from "../lib/ChatManager";
// 1. Define global context type
interface AppContext {
appName: string;
log: (message: string) => void;
debugMode: boolean;
chatManager?: ChatManager;
}
// 2. Define individual commands
const addCommand: Command<AppContext> = {
name: "add",
description: "将两个数字相加",
description: "Adds two numbers together",
args: [
{ name: "a", description: "第一个数字", required: true },
{ name: "b", description: "第二个数字", required: true },
{ name: "a", description: "The first number", required: true },
{ name: "b", description: "The second number", required: true },
],
action: ({ args, context }): Result<void, CliError> => {
context.log(` '${context.appName}' 中执行 'add' 命令`);
context.log(`Executing 'add' command in '${context.appName}'`);
const a = tonumber(args.a as string);
const b = tonumber(args.b as string);
if (a === undefined || b === undefined) {
print("错误: 参数必须是数字。");
print("Error: Arguments must be numbers.");
return Ok.EMPTY;
}
@@ -36,7 +39,7 @@ const addCommand: Command<AppContext> = {
print(`${a} + ${b} = ${result}`);
if (context.debugMode) {
context.log(`计算结果: ${result}`);
context.log(`Calculation result: ${result}`);
}
return Ok.EMPTY;
},
@@ -44,19 +47,19 @@ const addCommand: Command<AppContext> = {
const subtractCommand: Command<AppContext> = {
name: "subtract",
description: "将第二个数字从第一个数字中减去",
description: "Subtracts the second number from the first",
args: [
{ name: "a", description: "被减数", required: true },
{ name: "b", description: "减数", required: true },
{ name: "a", description: "The minuend", required: true },
{ name: "b", description: "The subtrahend", required: true },
],
action: ({ args, context }): Result<void, CliError> => {
context.log(` '${context.appName}' 中执行 'subtract' 命令`);
context.log(`Executing 'subtract' command in '${context.appName}'`);
const a = tonumber(args.a as string);
const b = tonumber(args.b as string);
if (a === undefined || b === undefined) {
print("错误: 参数必须是数字。");
print("Error: Arguments must be numbers.");
return Ok.EMPTY;
}
@@ -68,18 +71,29 @@ const subtractCommand: Command<AppContext> = {
const greetCommand: Command<AppContext> = {
name: "greet",
description: "打印问候语",
options: [
{
name: "name",
shortName: "n",
description: "要问候的名字",
defaultValue: "World",
},
{ name: "times", shortName: "t", description: "重复次数", defaultValue: 1 },
],
description: "Prints a greeting message",
options: new Map([
[
"name",
{
name: "name",
shortName: "n",
description: "The name to greet",
defaultValue: "World",
},
],
[
"times",
{
name: "times",
shortName: "t",
description: "Number of times to repeat",
defaultValue: 1,
},
],
]),
action: ({ options, context }): Result<void, CliError> => {
context.log(` '${context.appName}' 中执行 'greet' 命令`);
context.log(`Executing 'greet' command in '${context.appName}'`);
const name = options.name as string;
const times = tonumber(options.times as string) ?? 1;
@@ -88,7 +102,7 @@ const greetCommand: Command<AppContext> = {
print(`Hello, ${name}!`);
if (context.debugMode && times > 1) {
context.log(`问候 ${i}/${times}`);
context.log(`Greeting ${i}/${times}`);
}
}
return Ok.EMPTY;
@@ -98,81 +112,469 @@ const greetCommand: Command<AppContext> = {
// Math subcommands group
const mathCommand: Command<AppContext> = {
name: "math",
description: "数学运算命令",
subcommands: [addCommand, subtractCommand],
description: "Mathematical operations",
subcommands: new Map([
["add", addCommand],
["subtract", subtractCommand],
]),
};
// Config command with nested subcommands
const configShowCommand: Command<AppContext> = {
name: "show",
description: "显示当前配置",
description: "Show current configuration",
action: ({ context }): Result<void, CliError> => {
print(`应用名称: ${context.appName}`);
print(`调试模式: ${context.debugMode ? "开启" : "关闭"}`);
print(`App Name: ${context.appName}`);
print(`Debug Mode: ${context.debugMode ? "on" : "off"}`);
return Ok.EMPTY;
},
};
const configSetCommand: Command<AppContext> = {
name: "set",
description: "设置配置项",
description: "Set a configuration item",
args: [
{ name: "key", description: "配置键", required: true },
{ name: "value", description: "配置值", required: true },
{ name: "key", description: "The configuration key", required: true },
{ name: "value", description: "The configuration value", required: true },
],
action: ({ args, context }): Result<void, CliError> => {
const key = args.key as string;
const value = args.value as string;
context.log(`设置配置: ${key} = ${value}`);
print(`配置 '${key}' 已设置为 '${value}'`);
context.log(`Setting config: ${key} = ${value}`);
print(`Config '${key}' has been set to '${value}'`);
return Ok.EMPTY;
},
};
const configCommand: Command<AppContext> = {
name: "config",
description: "配置管理命令",
subcommands: [configShowCommand, configSetCommand],
description: "Configuration management commands",
subcommands: new Map([
["show", configShowCommand],
["set", configSetCommand],
]),
};
// 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
const rootCommand: Command<AppContext> = {
name: "calculator",
description: "一个功能丰富的计算器程序",
options: [
{
name: "debug",
shortName: "d",
description: "启用调试模式",
defaultValue: false,
},
],
subcommands: [mathCommand, greetCommand, configCommand],
description: "A feature-rich calculator and chat management program",
options: new Map([
[
"debug",
{
name: "debug",
shortName: "d",
description: "Enable debug mode",
defaultValue: false,
},
],
]),
subcommands: new Map([
["math", mathCommand],
["greet", greetCommand],
["config", configCommand],
["chat", chatCommand],
]),
action: ({ options, context }): Result<void, CliError> => {
// Update debug mode from command line option
const debugFromOption = options.debug as boolean;
if (debugFromOption) {
context.debugMode = true;
context.log("调试模式已启用");
context.log("Debug mode enabled");
}
print(`Welcome to ${context.appName}!`);
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");
}
print(`欢迎使用 ${context.appName}!`);
print("使用 --help 查看可用命令");
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 = {
appName: "MyAwesomeCalculator",
appName: "MyAwesome Calculator & Chat Manager",
debugMode: false,
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 args = [...$vararg];
cli(args);
@@ -191,11 +593,24 @@ cli(['greet', '-n', 'World', '-t', '3']); // Output: Hello, World! (3 times)
cli(['config', 'show']); // Shows current 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
cli(['--help']); // Shows root 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
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

@@ -5,8 +5,7 @@ import {
Argument,
Option,
CliError,
ParsedInput,
CommandResolution,
ParseResult,
} from "./types";
import {
parseArguments,
@@ -23,7 +22,9 @@ import { generateHelp, shouldShowHelp, generateCommandList } from "./help";
export interface CreateCliOptions<TContext extends object> {
/** An optional global context object to be made available in all command actions. */
globalContext?: TContext;
/** An optional function to handle output. Defaults to the global `print` function. */
/** An optional function to handle output.
* Default: textutils.pagedPrint(msg, term.getCursorPos()[1] - 2)
**/
writer?: (message: string) => void;
}
@@ -37,96 +38,105 @@ export function createCli<TContext extends object>(
rootCommand: Command<TContext>,
options: CreateCliOptions<TContext> = {},
): (argv: string[]) => void {
const { globalContext, writer = print } = options;
const {
globalContext,
writer = (msg) => textutils.pagedPrint(msg, term.getCursorPos()[1] - 2),
} = options;
return (argv: string[]): void => {
// Check for top-level help flags before any parsing.
if (shouldShowHelp(argv)) {
writer(generateHelp(rootCommand));
if (argv[0]?.startsWith("--help") || argv[0]?.startsWith("-h")) {
writer(generateHelp(rootCommand, [rootCommand.name]));
return;
}
const parsedInput = parseArguments(argv);
const executionResult = findCommand(
rootCommand,
parsedInput.commandPath,
).andThen((resolution) =>
processAndExecute(resolution, parsedInput, globalContext, (msg: string) =>
writer(msg),
),
const parseResult = parseArguments(argv, rootCommand);
if (parseResult.isErr()) {
const error = parseResult.error;
writer(formatError(error, rootCommand));
// If it was an unknown command, suggest alternatives.
if (error.kind === "UnknownCommand") {
// Find parent command to suggest alternatives
const parentResult = parseArguments(argv.slice(0, -1), rootCommand);
if (parentResult.isOk() && parentResult.value.command.subcommands) {
writer(generateCommandList(parentResult.value.command.subcommands));
}
}
return;
}
const executionResult = processAndExecute(
parseResult.value,
globalContext,
(msg: string) => writer(msg),
);
if (executionResult.isErr()) {
const error = executionResult.error;
writer(formatError(error, rootCommand));
// If it was an unknown command, suggest alternatives.
if (error.kind === "UnknownCommand") {
const parent = findCommand(
rootCommand,
parsedInput.commandPath.slice(0, -1),
);
if (parent.isOk() && parent.value.command.subcommands) {
writer(generateCommandList(parent.value.command.subcommands));
}
}
}
};
}
/**
* Processes the parsed input and executes the resolved command.
* @param resolution The resolved command and its context.
* @param parsedInput The raw parsed command-line input.
* @param parseResult The result from parsing with integrated command resolution.
* @param globalContext The global context for the CLI.
* @param writer Function to output messages.
* @returns A `Result` indicating the success or failure of the execution.
*/
function processAndExecute<TContext extends object>(
resolution: CommandResolution<TContext>,
parsedInput: ParsedInput,
parseResult: ParseResult<TContext>,
globalContext: TContext | undefined,
writer: (message: string) => void,
): Result<void, CliError> {
const { command, commandPath, remainingArgs } = resolution;
const { command, commandPath, options, remaining } = parseResult;
// Handle requests for help on a specific command.
if (shouldShowHelp([...remainingArgs, ...Object.keys(parsedInput.options)])) {
// Unified Help Check:
// A command should show its help page if:
// 1. A help flag is explicitly passed (`--help` or `-h`). This has the highest priority.
// 2. It's a command group that was called without a subcommand (i.e., it has no action).
const isHelpFlagPassed = shouldShowHelp([
...remaining,
...Object.keys(options),
]);
const isCommandGroupWithoutAction =
command.subcommands !== undefined &&
command.subcommands.size > 0 &&
command.action === undefined;
if (isHelpFlagPassed || isCommandGroupWithoutAction) {
writer(generateHelp(command, commandPath));
return Ok.EMPTY;
}
// If a command has subcommands but no action, show its help page.
if (
command.subcommands &&
command.subcommands.length > 0 &&
command.action === undefined
) {
writer(generateHelp(command, commandPath));
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) {
// 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({
kind: "NoAction",
commandPath: [...commandPath, command.name],
});
}
return processArguments(
command.args ?? [],
remainingArgs,
parsedInput.remaining,
)
// 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)
.andThen((args) => {
return processOptions(command.options ?? [], parsedInput.options).map(
(options) => ({ args, options }),
);
return processOptions(
command.options !== undefined
? Array.from(command.options.values())
: [],
options,
).map((processedOptions) => ({ args, options: processedOptions }));
})
.andThen(({ args, options }) => {
.andThen(({ args, options: processedOptions }) => {
const context: ActionContext<TContext> = {
args,
options,
options: processedOptions,
context: globalContext!,
};
// Finally, execute the command's action.
@@ -134,60 +144,22 @@ function processAndExecute<TContext extends object>(
});
}
/**
* Finds the target command based on a given path.
* @param rootCommand The command to start searching from.
* @param commandPath An array of strings representing the path to the command.
* @returns A `Result` containing the `CommandResolution` or an `UnknownCommandError`.
*/
function findCommand<TContext extends object>(
rootCommand: Command<TContext>,
commandPath: string[],
): Result<CommandResolution<TContext>, CliError> {
let currentCommand = rootCommand;
const resolvedPath: string[] = [];
let i = 0;
for (const name of commandPath) {
const subcommand = currentCommand.subcommands?.find(
(cmd) => cmd.name === name,
);
if (!subcommand) {
// Part of the path was not a valid command, so the rest are arguments.
return new Err({ kind: "UnknownCommand", commandName: name });
}
currentCommand = subcommand;
resolvedPath.push(name);
i++;
}
const remainingArgs = commandPath.slice(i);
return new Ok({
command: currentCommand,
commandPath: resolvedPath,
remainingArgs,
});
}
/**
* Processes and validates command arguments from the raw input.
* @param argDefs The argument definitions for the command.
* @param remainingArgs The positional arguments captured during command resolution.
* @param additionalArgs Any extra arguments parsed after options.
* @param remainingArgs The remaining positional arguments.
* @returns A `Result` with the processed arguments record or a `MissingArgumentError`.
*/
function processArguments(
argDefs: Argument[],
remainingArgs: string[],
additionalArgs: string[],
): Result<Record<string, unknown>, CliError> {
const args: Record<string, unknown> = {};
const allArgs = [...remainingArgs, ...additionalArgs];
for (let i = 0; i < argDefs.length; i++) {
const argDef = argDefs[i];
if (i < allArgs.length) {
args[argDef.name] = allArgs[i];
if (i < remainingArgs.length) {
args[argDef.name] = remainingArgs[i];
}
}

View File

@@ -11,7 +11,7 @@ export function generateHelp<TContext extends object>(
commandPath: string[] = [],
): string {
const lines: string[] = [];
const fullCommandName = [...commandPath, command.name].join(" ");
const fullCommandName = commandPath.join(" ");
// Description
if (command.description !== undefined) {
@@ -20,10 +20,10 @@ export function generateHelp<TContext extends object>(
// Usage
const usageParts: string[] = ["Usage:", fullCommandName];
if (command.options && command.options.length > 0) {
if (command.options && command.options.size > 0) {
usageParts.push("[OPTIONS]");
}
if (command.subcommands && command.subcommands.length > 0) {
if (command.subcommands && command.subcommands.size > 0) {
usageParts.push("<COMMAND>");
}
if (command.args && command.args.length > 0) {
@@ -45,9 +45,9 @@ export function generateHelp<TContext extends object>(
}
// Options
if (command.options && command.options.length > 0) {
if (command.options && command.options.size > 0) {
lines.push("\nOptions:");
for (const option of command.options) {
for (const option of command.options.values()) {
const short =
option.shortName !== undefined ? `-${option.shortName}, ` : " ";
const long = `--${option.name}`;
@@ -64,9 +64,9 @@ export function generateHelp<TContext extends object>(
}
// Subcommands
if (command.subcommands && command.subcommands.length > 0) {
if (command.subcommands && command.subcommands.size > 0) {
lines.push("\nCommands:");
for (const subcommand of command.subcommands) {
for (const subcommand of command.subcommands.values()) {
lines.push(` ${subcommand.name.padEnd(20)} ${subcommand.description}`);
}
lines.push(
@@ -83,14 +83,14 @@ export function generateHelp<TContext extends object>(
* @returns A formatted string listing the available commands.
*/
export function generateCommandList<TContext extends object>(
commands: Command<TContext>[],
commands: Map<string, Command<TContext>>,
): string {
if (commands.length === 0) {
if (commands.size === 0) {
return "No commands available.";
}
const lines: string[] = ["Available commands:"];
for (const command of commands) {
for (const command of commands.values()) {
lines.push(` ${command.name.padEnd(20)} ${command.description}`);
}
@@ -103,5 +103,5 @@ export function generateCommandList<TContext extends object>(
* @returns `true` if a help flag is found, otherwise `false`.
*/
export function shouldShowHelp(argv: string[]): boolean {
return argv.includes("--help") || argv.includes("-h");
return argv.includes("help") || argv.includes("h");
}

View File

@@ -1,31 +1,135 @@
import { Ok, Err, Result } from "../thirdparty/ts-result-es";
import { ParsedInput, MissingArgumentError, MissingOptionError } from "./types";
import {
ParseResult,
MissingArgumentError,
MissingOptionError,
Command,
Option,
CliError,
CommandResolution,
} from "./types";
// Cache class to handle option maps with proper typing
class OptionMapCache {
private cache = new WeakMap<
object,
{
optionMap: Map<string, Option>;
shortNameMap: Map<string, string>;
}
>();
get<TContext extends object>(command: Command<TContext>) {
return this.cache.get(command);
}
set<TContext extends object>(
command: Command<TContext>,
value: {
optionMap: Map<string, Option>;
shortNameMap: Map<string, string>;
},
) {
this.cache.set(command, value);
}
}
// Lazy option map builder with global caching
function getOptionMaps<TContext extends object>(
optionCache: OptionMapCache,
command: Command<TContext>,
) {
// Quick check: if command has no options, return empty maps
if (!command.options || command.options.size === 0) {
return {
optionMap: new Map<string, Option>(),
shortNameMap: new Map<string, string>(),
};
}
let cached = optionCache.get(command);
if (cached !== undefined) {
return cached;
}
const optionMap = new Map<string, Option>();
const shortNameMap = new Map<string, string>();
for (const [optionName, option] of command.options) {
optionMap.set(optionName, option);
if (option.shortName !== undefined && option.shortName !== null) {
shortNameMap.set(option.shortName, optionName);
}
}
cached = { optionMap, shortNameMap };
optionCache.set(command, cached);
return cached;
}
/**
* Parses command line arguments into a structured format.
* This function does not validate arguments or options, it only parses the raw input.
* @param argv Array of command line arguments (e.g., from `os.pullEvent`).
* @returns A `ParsedInput` object containing the command path, options, and remaining args.
* Parses command line arguments with integrated command resolution.
* This function dynamically finds the target command during parsing and uses
* the command's option definitions for intelligent option handling.
* @param argv Array of command line arguments.
* @param rootCommand The root command to start parsing from.
* @returns A `Result` containing the `ParseResult` or a `CliError`.
*/
export function parseArguments(argv: string[]): ParsedInput {
const result: ParsedInput = {
commandPath: [],
export function parseArguments<TContext extends object>(
argv: string[],
rootCommand: Command<TContext>,
): Result<ParseResult<TContext>, CliError> {
const result: ParseResult<TContext> = {
command: rootCommand,
commandPath: [rootCommand.name],
options: {},
remaining: [],
};
let i = 0;
let currentCommand = rootCommand;
let inOptions = false;
const optionMapCache = new OptionMapCache();
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];
if (arg === undefined) {
i++;
continue;
}
// Skip null/undefined arguments
if (!arg) continue;
// Handle double dash (--) - everything after is treated as a remaining argument.
// Handle double dash (--) - everything after is treated as remaining
if (arg === "--") {
result.remaining.push(...argv.slice(i + 1));
break;
@@ -44,51 +148,69 @@ export function parseArguments(argv: string[]): ParsedInput {
} else {
// --option [value] format
const optionName = arg.slice(2);
if (
i + 1 < argv.length &&
argv[i + 1] !== undefined &&
!argv[i + 1].startsWith("-")
) {
result.options[optionName] = argv[i + 1];
i++; // Skip the value argument
} else {
// Boolean flag
result.options[optionName] = true;
}
i = processOption(optionName, i);
}
}
// Handle short options (-o or -o value)
else if (arg.startsWith("-") && arg.length > 1) {
inOptions = true;
const optionName = arg.slice(1);
if (
i + 1 < argv.length &&
argv[i + 1] !== undefined &&
!argv[i + 1].startsWith("-")
) {
result.options[optionName] = argv[i + 1];
i++; // Skip the value argument
} else {
// Boolean flag
result.options[optionName] = true;
}
const shortName = arg.slice(1);
const optionName =
currentOptionMaps.shortNameMap.get(shortName) ?? shortName;
i = processOption(optionName, i);
}
// Handle positional arguments and commands
// Handle positional arguments and command resolution
else {
if (!inOptions) {
// Before any options, treat as part of the command path
result.commandPath.push(arg);
// Try to find this as a subcommand of the current command
const subcommand = currentCommand.subcommands?.get(arg);
if (subcommand) {
updateCommand(subcommand, arg);
} else {
// Not a subcommand, treat as remaining argument
result.remaining.push(arg);
}
} else {
// After options have started, treat as a remaining argument
// After options have started, treat as remaining argument
result.remaining.push(arg);
}
}
}
return new Ok(result);
}
/**
* Finds the target command based on a given path.
* @param rootCommand The command to start searching from.
* @param commandPath An array of strings representing the path to the command.
* @returns A `Result` containing the `CommandResolution` or an `UnknownCommandError`.
*/
export function findCommand<TContext extends object>(
rootCommand: Command<TContext>,
commandPath: string[],
): Result<CommandResolution<TContext>, CliError> {
let currentCommand = rootCommand;
const resolvedPath: string[] = [];
let i = 0;
for (const name of commandPath) {
const subcommand = currentCommand.subcommands?.get(name);
if (!subcommand) {
// Part of the path was not a valid command, so the rest are arguments.
return new Err({ kind: "UnknownCommand", commandName: name });
}
currentCommand = subcommand;
resolvedPath.push(name);
i++;
}
return result;
const remainingArgs = commandPath.slice(i);
return new Ok({
command: currentCommand,
commandPath: resolvedPath,
remainingArgs,
});
}
/**

View File

@@ -104,30 +104,32 @@ export interface Command<TContext extends object> {
name: string;
/** A brief description of the command, shown in help messages. */
description: string;
/** An array of argument definitions for the command. */
/** A map of argument definitions for the command, keyed by argument name. */
args?: Argument[];
/** An array of option definitions for the command. */
options?: Option[];
/** A map of option definitions for the command, keyed by option name. */
options?: Map<string, Option>;
/**
* The function to execute when the command is run.
* It receives an `ActionContext` object.
* Should return a `Result` to indicate success or failure.
*/
action?: (context: ActionContext<TContext>) => Result<void, CliError>;
/** An array of subcommands, allowing for nested command structures. */
subcommands?: Command<TContext>[];
/** A map of subcommands, allowing for nested command structures, keyed by command name. */
subcommands?: Map<string, Command<TContext>>;
}
// --- Parsing and Execution Internals ---
/**
* @interface ParsedInput
* @description The raw output from the initial argument parsing stage.
* @interface ParseResult
* @description Enhanced parsing result that includes command resolution.
*/
export interface ParsedInput {
/** The identified command path from the arguments. */
export interface ParseResult<TContext extends object> {
/** The resolved command found during parsing. */
command: Command<TContext>;
/** The path to the resolved command. */
commandPath: string[];
/** A record of raw option values. */
/** A record of parsed option values. */
options: Record<string, unknown>;
/** Any remaining arguments that were not parsed as part of the command path or options. */
remaining: string[];

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "../tsconfig.json",
"tstl": {
"luaBundle": "../build/accesscontrol.lua",
"luaBundleEntry": "../src/accesscontrol/main.ts"
},
"include": ["../src/accesscontrol/*.ts"]
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "../tsconfig.json",
"tstl": {
"luaBundle": "../build/autocraft.lua",
"luaBundleEntry": "../src/autocraft/main.ts"
},
"include": ["../src/autocraft/*.ts"]
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "../tsconfig.json",
"tstl": {
"luaBundle": "../build/cliExample.lua",
"luaBundleEntry": "../src/cliExample/main.ts"
},
"include": ["../src/cliExample/*.ts", "../src/lib/ccCLI/*.ts"]
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "../tsconfig.json",
"tstl": {
"luaBundle": "../build/test.lua",
"luaBundleEntry": "../src/test/main.ts"
},
"include": ["../src/test/*.ts"]
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "../tsconfig.json",
"tstl": {
"luaBundle": "../build/tuiExample.lua",
"luaBundleEntry": "../src/tuiExample/main.ts"
},
"include": ["../src/tuiExample/*.ts", "../src/lib/ccTUI/*.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "./tsconfig.json",
"tstl": {
"luaBundle": "build/accesscontrol.lua",
"luaBundleEntry": "src/accesscontrol/main.ts"
},
"include": ["src/accesscontrol/*.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "./tsconfig.json",
"tstl": {
"luaBundle": "build/autocraft.lua",
"luaBundleEntry": "src/autocraft/main.ts"
},
"include": ["src/autocraft/*.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "./tsconfig.json",
"tstl": {
"luaBundle": "build/cliExample.lua",
"luaBundleEntry": "src/cliExample/main.ts"
},
"include": ["src/cliExample/*.ts", "src/lib/ccCLI/*.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "./tsconfig.json",
"tstl": {
"luaBundle": "build/test.lua",
"luaBundleEntry": "src/test/main.ts"
},
"include": ["src/test/*.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "./tsconfig.json",
"tstl": {
"luaBundle": "build/tuiExample.lua",
"luaBundleEntry": "src/tuiExample/main.ts"
},
"include": ["src/tuiExample/*.ts", "src/lib/ccTUI/*.ts"]
}

View File

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

View File

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