mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-05 03:37:50 +08:00
Compare commits
15 Commits
4e71fbffc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e74dcfa0 | ||
|
|
2f57d9ab3d | ||
|
|
7e03d960bd | ||
|
|
f76a3666b1 | ||
|
|
d6971fb22f | ||
|
|
796bf1c2dc | ||
|
|
959ec0c424 | ||
|
|
e680ef0263 | ||
|
|
1891259ee7 | ||
| 7a17ca7fbf | |||
|
|
f7167576cd | ||
|
|
2ab091d939 | ||
|
|
119bc1997a | ||
|
|
ac70e1acd3 | ||
|
|
d90574e514 |
15
.justfile
15
.justfile
@@ -5,16 +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:
|
||||
pnpm tstl -p ./tsconfig.tuiExample.json
|
||||
build-example: build-tuiExample build-cliExample
|
||||
|
||||
build-tuiExample:
|
||||
pnpm tstl -p ./targets/tsconfig.tuiExample.json
|
||||
|
||||
build-cliExample:
|
||||
pnpm tstl -p ./targets/tsconfig.cliExample.json
|
||||
|
||||
sync:
|
||||
rsync --delete -r "./build/" "{{ sync-path }}"
|
||||
|
||||
12
README.md
12
README.md
@@ -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
235
docs/ChatManager.md
Normal 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
|
||||
|
||||
Here’s 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
248
docs/ccCLI.md
Normal 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.
|
||||
@@ -1,558 +1,400 @@
|
||||
import { Command, createCli } from "@/lib/ccCLI";
|
||||
import { Ok } from "@/lib/thirdparty/ts-result-es";
|
||||
import { CCLog } from "@/lib/ccLog";
|
||||
import { AccessConfig, UserGroupConfig, saveConfig } from "./config";
|
||||
import { ChatBoxEvent, pullEventAs } from "@/lib/event";
|
||||
import {
|
||||
AccessConfig,
|
||||
UserGroupConfig,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
} from "./config";
|
||||
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;
|
||||
}
|
||||
|
||||
// CLI上下文
|
||||
interface CLIContext {
|
||||
config: AccessConfig;
|
||||
// 1. Define AppContext
|
||||
export interface AppContext {
|
||||
configFilepath: string;
|
||||
reloadConfig: () => void;
|
||||
log: CCLog;
|
||||
chatBox: ChatBoxPeripheral;
|
||||
logger: CCLog;
|
||||
print: (
|
||||
message: string | MinecraftTextComponent | MinecraftTextComponent[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function getGroupNames(context: CLIContext) {
|
||||
return context.config.usersGroups.flatMap((value) => value.groupName);
|
||||
function getGroupNames(config: AccessConfig) {
|
||||
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);
|
||||
if (ret.success) {
|
||||
this.context.reloadConfig();
|
||||
return ret;
|
||||
}
|
||||
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(this.context.config, this.context.configFilepath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加用户命令
|
||||
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 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)!;
|
||||
|
||||
if (groupName === "admin") {
|
||||
context.config.adminGroupConfig.groupUsers.push(playerName);
|
||||
return {
|
||||
success: true,
|
||||
message: `Add player ${playerName} to admin`,
|
||||
shouldSaveConfig: true,
|
||||
};
|
||||
}
|
||||
|
||||
const groupNames = getGroupNames(context);
|
||||
|
||||
if (!groupNames.includes(groupName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid group: ${groupName}. Available groups: ${groupNames.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const groupConfig = context.config.usersGroups.find(
|
||||
(value) => value.groupName === groupName,
|
||||
);
|
||||
|
||||
if (!groupConfig) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Group ${groupName} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
if (groupConfig.groupUsers === undefined) {
|
||||
groupConfig.groupUsers = [playerName];
|
||||
if (!config.adminGroupConfig.groupUsers.includes(playerName)) {
|
||||
config.adminGroupConfig.groupUsers.push(playerName);
|
||||
}
|
||||
} else {
|
||||
groupConfig.groupUsers.push(playerName);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Add player ${playerName} to ${groupConfig.groupName}`,
|
||||
shouldSaveConfig: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
saveConfig(config, context.configFilepath);
|
||||
context.reloadConfig();
|
||||
context.print({ text: `已添加玩家 ${playerName} 到 ${groupName}` });
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
// 删除用户命令
|
||||
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;
|
||||
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") {
|
||||
return {
|
||||
success: false,
|
||||
message: "Could't delete admin, please edit config",
|
||||
};
|
||||
context.print({ text: "无法删除管理员, 请直接编辑配置文件。" });
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const groupNames = getGroupNames(context);
|
||||
const config = loadConfig(context.configFilepath)!;
|
||||
const group = config.usersGroups.find((g) => g.groupName === groupName);
|
||||
|
||||
if (!groupNames.includes(groupName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid group: ${groupName}. Available groups: ${groupNames.join(", ")}`,
|
||||
};
|
||||
if (!group) {
|
||||
const groupNames = getGroupNames(config);
|
||||
context.print({
|
||||
text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join(
|
||||
", ",
|
||||
)}`,
|
||||
});
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const groupConfig = context.config.usersGroups.find(
|
||||
(value) => value.groupName === groupName,
|
||||
);
|
||||
|
||||
if (!groupConfig) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Group ${groupName} not found`,
|
||||
};
|
||||
if (group.groupUsers !== undefined) {
|
||||
group.groupUsers = group.groupUsers.filter((user) => user !== playerName);
|
||||
}
|
||||
|
||||
if (groupConfig.groupUsers === undefined) {
|
||||
groupConfig.groupUsers = [];
|
||||
} else {
|
||||
groupConfig.groupUsers = groupConfig.groupUsers.filter(
|
||||
(user) => user !== playerName,
|
||||
);
|
||||
}
|
||||
saveConfig(config, context.configFilepath);
|
||||
context.reloadConfig();
|
||||
context.print({ text: `已从 ${groupName} 中删除玩家 ${playerName}` });
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Delete ${groupConfig.groupName} ${playerName}`,
|
||||
shouldSaveConfig: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 列表命令
|
||||
class ListCommand implements CLICommand {
|
||||
name = "list";
|
||||
description = "List all players with their groups";
|
||||
usage = "list";
|
||||
|
||||
execute(_args: string[], _executor: string, context: CLIContext): CLIResult {
|
||||
let message = `Admins : [ ${context.config.adminGroupConfig.groupUsers.join(", ")} ]\n`;
|
||||
|
||||
for (const groupConfig of context.config.usersGroups) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
const listGroupCommand: Command<AppContext> = {
|
||||
name: "group",
|
||||
description: "显示详细的用户组配置信息",
|
||||
action: ({ context }) => {
|
||||
const config = loadConfig(context.configFilepath)!;
|
||||
let groupsMessage = `管理员组: ${config.adminGroupConfig.groupName}\n`;
|
||||
groupsMessage += ` 用户: [${config.adminGroupConfig.groupUsers.join(
|
||||
", ",
|
||||
)}]\n`;
|
||||
groupsMessage += ` 允许: ${config.adminGroupConfig.isAllowed}\n`;
|
||||
groupsMessage += ` 通知: ${config.adminGroupConfig.isNotice}\n\n`;
|
||||
|
||||
// 设置命令
|
||||
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`,
|
||||
};
|
||||
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 [option, valueStr] = args;
|
||||
const value = parseInt(valueStr);
|
||||
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`;
|
||||
|
||||
if (isNaN(value)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid value: ${valueStr}. Must be a number.`,
|
||||
};
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
switch (option) {
|
||||
case "warnInterval":
|
||||
context.config.watchInterval = value;
|
||||
return {
|
||||
success: true,
|
||||
message: `Set warn interval to ${context.config.watchInterval}`,
|
||||
shouldSaveConfig: true,
|
||||
};
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
case "detectInterval":
|
||||
context.config.detectInterval = value;
|
||||
return {
|
||||
success: true,
|
||||
message: `Set detect interval to ${context.config.detectInterval}`,
|
||||
shouldSaveConfig: true,
|
||||
};
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
case "detectRange":
|
||||
context.config.detectRange = value;
|
||||
return {
|
||||
success: true,
|
||||
message: `Set detect range to ${context.config.detectRange}`,
|
||||
shouldSaveConfig: true,
|
||||
};
|
||||
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)!;
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown option: ${option}. Available options: warnInterval, detectInterval, detectRange`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
|
||||
// 帮助命令
|
||||
class HelpCommand implements CLICommand {
|
||||
name = "help";
|
||||
description = "Show command help";
|
||||
usage = "help";
|
||||
let groupConfig: UserGroupConfig | undefined;
|
||||
if (groupName === "admin") {
|
||||
groupConfig = config.adminGroupConfig;
|
||||
} else {
|
||||
groupConfig = config.usersGroups.find((g) => g.groupName === groupName);
|
||||
}
|
||||
|
||||
execute(_args: string[], _executor: string, context: CLIContext): CLIResult {
|
||||
const groupNames = getGroupNames(context);
|
||||
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
|
||||
`;
|
||||
if (!groupConfig) {
|
||||
context.print({ text: `用户组 ${groupName} 未找到` });
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: helpMessage.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
const boolValue = parseBoolean(valueStr);
|
||||
if (boolValue === undefined) {
|
||||
context.print({
|
||||
text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`,
|
||||
});
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
// 统一编辑命令
|
||||
class EditCommand implements CLICommand {
|
||||
name = "edit";
|
||||
description = "Edit various configurations (only group now)";
|
||||
usage = "edit <target> [args]";
|
||||
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;
|
||||
}
|
||||
|
||||
execute(args: string[], _executor: string, context: CLIContext): CLIResult {
|
||||
if (args.length < 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Usage: ${this.usage}\nTargets: group`,
|
||||
};
|
||||
}
|
||||
|
||||
const [target, ...rest] = args;
|
||||
|
||||
switch (target) {
|
||||
case "group":
|
||||
return this.editGroup(rest, context);
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown target: ${target}. Available: group`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private editGroup(args: string[], context: CLIContext): CLIResult {
|
||||
if (args.length !== 3) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Usage: edit group <groupName> <property> <value>\nProperties: isAllowed, isNotice`,
|
||||
};
|
||||
}
|
||||
|
||||
const [groupName, property, valueStr] = args;
|
||||
|
||||
let groupConfig: UserGroupConfig | undefined;
|
||||
|
||||
if (groupName === "admin") {
|
||||
groupConfig = context.config.adminGroupConfig;
|
||||
saveConfig(config, context.configFilepath);
|
||||
context.reloadConfig();
|
||||
context.print({ text: message });
|
||||
return Ok.EMPTY;
|
||||
} else {
|
||||
groupConfig = context.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,
|
||||
};
|
||||
} 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,
|
||||
};
|
||||
} 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";
|
||||
|
||||
switch (type) {
|
||||
case "groups": {
|
||||
let groupsMessage = `Admin Group: ${context.config.adminGroupConfig.groupName}\n`;
|
||||
groupsMessage += ` Users: [${context.config.adminGroupConfig.groupUsers.join(", ")}]\n`;
|
||||
groupsMessage += ` Allowed: ${context.config.adminGroupConfig.isAllowed}\n`;
|
||||
groupsMessage += ` notice: ${context.config.adminGroupConfig.isNotice}\n\n`;
|
||||
|
||||
for (const group of context.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: ${context.config.welcomeToastConfig.title.text}\n`;
|
||||
toastMessage += ` Message: ${context.config.welcomeToastConfig.msg.text}\n`;
|
||||
toastMessage += ` Prefix: ${context.config.welcomeToastConfig.prefix ?? "none"}\n`;
|
||||
toastMessage += ` Brackets: ${context.config.welcomeToastConfig.brackets ?? "none"}\n`;
|
||||
toastMessage += ` Bracket Color: ${context.config.welcomeToastConfig.bracketColor ?? "none"}\n\n`;
|
||||
|
||||
toastMessage += "Warn Toast Config:\n";
|
||||
toastMessage += ` Title: ${context.config.warnToastConfig.title.text}\n`;
|
||||
toastMessage += ` Message: ${context.config.warnToastConfig.msg.text}\n`;
|
||||
toastMessage += ` Prefix: ${context.config.warnToastConfig.prefix ?? "none"}\n`;
|
||||
toastMessage += ` Brackets: ${context.config.warnToastConfig.brackets ?? "none"}\n`;
|
||||
toastMessage += ` Bracket Color: ${context.config.warnToastConfig.bracketColor ?? "none"}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: toastMessage,
|
||||
};
|
||||
}
|
||||
|
||||
case "all": {
|
||||
let allMessage = `Detect Range: ${context.config.detectRange}\n`;
|
||||
allMessage += `Detect Interval: ${context.config.detectInterval}\n`;
|
||||
allMessage += `Warn Interval: ${context.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;
|
||||
if (
|
||||
!this.context.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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,64 +50,78 @@ 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: "",
|
||||
},
|
||||
};
|
||||
|
||||
function loadConfig(filepath: string): AccessConfig {
|
||||
function loadConfig(
|
||||
filepath: string,
|
||||
useDefault = true,
|
||||
): AccessConfig | undefined {
|
||||
const [fp] = io.open(filepath, "r");
|
||||
if (fp == undefined) {
|
||||
if (useDefault === false) return undefined;
|
||||
print("Failed to open config file " + filepath);
|
||||
print("Use default config");
|
||||
saveConfig(defaultConfig, filepath);
|
||||
@@ -112,6 +130,7 @@ function loadConfig(filepath: string): AccessConfig {
|
||||
|
||||
const configJson = fp.read("*a");
|
||||
if (configJson == undefined) {
|
||||
if (useDefault === false) return undefined;
|
||||
print("Failed to read config file");
|
||||
print("Use default config");
|
||||
saveConfig(defaultConfig, filepath);
|
||||
|
||||
@@ -1,9 +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];
|
||||
|
||||
@@ -16,36 +19,47 @@ const logger = new CCLog("accesscontrol.log", {
|
||||
|
||||
// Load Config
|
||||
const configFilepath = `${shell.dir()}/access.config.json`;
|
||||
let config = loadConfig(configFilepath);
|
||||
let config = loadConfig(configFilepath)!;
|
||||
const configLock = new ReadWriteLock();
|
||||
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 noticeTargetPlayers: string[];
|
||||
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;
|
||||
}
|
||||
|
||||
function reloadConfig() {
|
||||
config = loadConfig(configFilepath);
|
||||
inRangePlayers = [];
|
||||
watchPlayersInfo = [];
|
||||
let releaser = configLock.tryAcquireWrite();
|
||||
while (releaser === undefined) {
|
||||
sleep(1);
|
||||
releaser = configLock.tryAcquireWrite();
|
||||
}
|
||||
|
||||
config = loadConfig(configFilepath)!;
|
||||
gInRangePlayers = [];
|
||||
gWatchPlayersInfo = [];
|
||||
releaser.release();
|
||||
logger.info("Reload config successfully!");
|
||||
}
|
||||
|
||||
function safeParseTextComponent(
|
||||
component: MinecraftTextComponent,
|
||||
params?: ParseParams,
|
||||
): string {
|
||||
): MinecraftTextComponent {
|
||||
const newComponent = deepCopy(component);
|
||||
|
||||
if (newComponent.text == undefined) {
|
||||
@@ -53,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%",
|
||||
@@ -72,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(
|
||||
@@ -80,65 +121,93 @@ function sendToast(
|
||||
targetPlayer: string,
|
||||
params: ParseParams,
|
||||
) {
|
||||
return chatBox.sendFormattedToastToPlayer(
|
||||
safeParseTextComponent(
|
||||
let releaser = configLock.tryAcquireRead();
|
||||
while (releaser === undefined) {
|
||||
sleep(0.1);
|
||||
releaser = configLock.tryAcquireRead();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function sendNotice(player: string, playerInfo?: PlayerInfo) {
|
||||
let releaser = configLock.tryAcquireRead();
|
||||
while (releaser === undefined) {
|
||||
sleep(0.1);
|
||||
releaser = configLock.tryAcquireRead();
|
||||
}
|
||||
|
||||
const onlinePlayers = playerDetector.getOnlinePlayers();
|
||||
noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
|
||||
const noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
|
||||
config.usersGroups
|
||||
.filter((value) => value.isNotice)
|
||||
.map((value) => value.groupUsers ?? [])
|
||||
.flat(),
|
||||
.flatMap((value) => value.groupUsers ?? []),
|
||||
);
|
||||
logger.debug(`noticeTargetPlayers: ${noticeTargetPlayers.join(", ")}`);
|
||||
|
||||
for (const targetPlayer of noticeTargetPlayers) {
|
||||
if (!onlinePlayers.includes(targetPlayer)) continue;
|
||||
sendToast(config.noticeToastConfig, targetPlayer, {
|
||||
name: player,
|
||||
playerName: player,
|
||||
info: playerInfo,
|
||||
});
|
||||
sleep(1);
|
||||
}
|
||||
releaser.release();
|
||||
}
|
||||
|
||||
function sendWarn(player: string) {
|
||||
const warnMsg = `Not Allowed Player ${player} Break in Home `;
|
||||
logger.warn(warnMsg);
|
||||
|
||||
sendToast(config.warnToastConfig, player, { name: player });
|
||||
chatBox.sendFormattedMessageToPlayer(
|
||||
safeParseTextComponent(config.warnToastConfig.msg, { name: player }),
|
||||
player,
|
||||
"AccessControl",
|
||||
"[]",
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
let releaser = configLock.tryAcquireRead();
|
||||
while (releaser === undefined) {
|
||||
sleep(0.1);
|
||||
releaser = configLock.tryAcquireRead();
|
||||
}
|
||||
|
||||
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) {
|
||||
const watchPlayerNames = watchPlayersInfo.flatMap((value) => value.name);
|
||||
while (gIsRunning) {
|
||||
const releaser = configLock.tryAcquireRead();
|
||||
if (releaser === undefined) {
|
||||
os.sleep(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -154,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(
|
||||
@@ -164,32 +233,52 @@ function watchLoop() {
|
||||
os.sleep(1);
|
||||
}
|
||||
|
||||
releaser.release();
|
||||
os.sleep(config.watchInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function mainLoop() {
|
||||
while (true) {
|
||||
while (gIsRunning) {
|
||||
const releaser = configLock.tryAcquireRead();
|
||||
if (releaser === undefined) {
|
||||
os.sleep(0.1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const players = playerDetector.getPlayersInRange(config.detectRange);
|
||||
const playersList = "[ " + players.join(",") + " ]";
|
||||
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;
|
||||
@@ -198,30 +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);
|
||||
@@ -232,9 +337,67 @@ function keyboardLoop() {
|
||||
} finally {
|
||||
logger.setInTerminal(true);
|
||||
reloadConfig();
|
||||
logger.info("Reload config successfully!");
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,32 +405,20 @@ 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({
|
||||
config: config,
|
||||
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();
|
||||
},
|
||||
() => {
|
||||
void cli.startConfigLoop();
|
||||
},
|
||||
() => {
|
||||
watchLoop();
|
||||
},
|
||||
() => {
|
||||
keyboardLoop();
|
||||
},
|
||||
() => mainLoop(),
|
||||
() => gTimerManager.run(),
|
||||
() => cliLoop(),
|
||||
() => watchLoop(),
|
||||
() => keyboardLoop(),
|
||||
() => chatManager.run(),
|
||||
);
|
||||
|
||||
return;
|
||||
} else if (args[0] == "config") {
|
||||
logger.info("Launching Access Control TUI...");
|
||||
|
||||
@@ -49,7 +49,7 @@ interface ErrorState {
|
||||
const AccessControlTUI = () => {
|
||||
// Load configuration on initialization
|
||||
const configFilepath = `${shell.dir()}/access.config.json`;
|
||||
const loadedConfig = loadConfig(configFilepath);
|
||||
const loadedConfig = loadConfig(configFilepath)!;
|
||||
// Configuration state
|
||||
const [config, setConfig] = createStore<AccessConfig>(loadedConfig);
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -1,120 +1,215 @@
|
||||
import { CraftManager } from "@/lib/CraftManager";
|
||||
import * as peripheralManager from "../lib/PeripheralManager";
|
||||
import { CCLog } from "@/lib/ccLog";
|
||||
import {
|
||||
CraftManager,
|
||||
CraftRecipe,
|
||||
CreatePackageTag,
|
||||
} from "@/lib/CraftManager";
|
||||
import { CCLog, LogLevel } from "@/lib/ccLog";
|
||||
import { Queue } from "@/lib/datatype/Queue";
|
||||
|
||||
const log = new CCLog("autocraft.log");
|
||||
const logger = new CCLog("autocraft.log", { outputMinLevel: LogLevel.Info });
|
||||
|
||||
const peripheralsRelativeSides = {
|
||||
packagesContainer: "minecraft:chest_10",
|
||||
itemsContainer: "minecraft:chest_9",
|
||||
packageExtractor: "create:packager_1",
|
||||
blockReader: "front",
|
||||
wiredModem: "back",
|
||||
const peripheralsNames = {
|
||||
// packsInventory: "minecraft:chest_14",
|
||||
// itemsInventory: "minecraft:chest_15",
|
||||
// packageExtractor: "create:packager_3",
|
||||
blockReader: "bottom",
|
||||
wiredModem: "right",
|
||||
redstone: "left",
|
||||
packsInventory: "minecraft:chest_1121",
|
||||
itemsInventory: "minecraft:chest_1120",
|
||||
packageExtractor: "create:packager_0",
|
||||
};
|
||||
|
||||
const packsInventory = peripheral.wrap(
|
||||
peripheralsNames.packsInventory,
|
||||
) as InventoryPeripheral;
|
||||
const itemsInventory = peripheral.wrap(
|
||||
peripheralsNames.itemsInventory,
|
||||
) as InventoryPeripheral;
|
||||
const packageExtractor = peripheral.wrap(
|
||||
peripheralsNames.packageExtractor,
|
||||
) as InventoryPeripheral;
|
||||
const blockReader = peripheral.wrap(
|
||||
peripheralsNames.blockReader,
|
||||
) as BlockReaderPeripheral;
|
||||
const wiredModem = peripheral.wrap(
|
||||
peripheralsNames.wiredModem,
|
||||
) as WiredModemPeripheral;
|
||||
const turtleLocalName = wiredModem.getNameLocal();
|
||||
|
||||
enum State {
|
||||
IDLE,
|
||||
READ_RECIPE,
|
||||
CRAFT_OUTPUT,
|
||||
}
|
||||
|
||||
function main() {
|
||||
const packagesContainer = peripheralManager.findByNameRequired(
|
||||
"inventory",
|
||||
peripheralsRelativeSides.packagesContainer,
|
||||
);
|
||||
const craftManager = new CraftManager(turtleLocalName, itemsInventory);
|
||||
const recipesQueue = new Queue<CraftRecipe>();
|
||||
const recipesWaitingMap = new Map<number, CraftRecipe[] | CraftRecipe>();
|
||||
let currentState = State.IDLE;
|
||||
let nextState = State.IDLE;
|
||||
let hasPackage = redstone.getInput(peripheralsNames.redstone);
|
||||
while (hasPackage) {
|
||||
hasPackage = redstone.getInput(peripheralsNames.redstone);
|
||||
logger.warn("redstone activated when init, please clear inventory");
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
const itemsContainer = peripheralManager.findByNameRequired(
|
||||
"inventory",
|
||||
peripheralsRelativeSides.itemsContainer,
|
||||
);
|
||||
|
||||
const packageExtractor = peripheralManager.findByNameRequired(
|
||||
"inventory",
|
||||
peripheralsRelativeSides.packageExtractor,
|
||||
);
|
||||
|
||||
const blockReader = peripheralManager.findByNameRequired(
|
||||
"blockReader",
|
||||
peripheralsRelativeSides.blockReader,
|
||||
);
|
||||
|
||||
const wiredModem = peripheralManager.findByNameRequired(
|
||||
"wiredModem",
|
||||
peripheralsRelativeSides.wiredModem,
|
||||
);
|
||||
const turtleLocalName = wiredModem.getNameLocal();
|
||||
|
||||
const craftManager = new CraftManager(turtleLocalName);
|
||||
|
||||
let hasPackage = redstone.getInput("front");
|
||||
logger.info("AutoCraft init finished...");
|
||||
while (true) {
|
||||
if (!hasPackage) os.pullEvent("redstone");
|
||||
hasPackage = redstone.getInput("front");
|
||||
if (!hasPackage) {
|
||||
continue;
|
||||
// Switch state
|
||||
switch (currentState) {
|
||||
case State.IDLE: {
|
||||
nextState = hasPackage ? State.READ_RECIPE : State.IDLE;
|
||||
break;
|
||||
}
|
||||
case State.READ_RECIPE: {
|
||||
nextState = hasPackage ? State.READ_RECIPE : State.CRAFT_OUTPUT;
|
||||
break;
|
||||
}
|
||||
case State.CRAFT_OUTPUT: {
|
||||
nextState =
|
||||
recipesQueue.size() > 0
|
||||
? State.CRAFT_OUTPUT
|
||||
: hasPackage
|
||||
? State.READ_RECIPE
|
||||
: State.IDLE;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
logger.error(`Unknown state`);
|
||||
nextState = hasPackage ? State.READ_RECIPE : State.IDLE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
log.info(`Package detected`);
|
||||
|
||||
const itemsInfo = packagesContainer.list();
|
||||
for (const key in itemsInfo) {
|
||||
const slot = parseInt(key);
|
||||
const item = itemsInfo[slot];
|
||||
log.info(`${item.count}x ${item.name} in slot ${key}`);
|
||||
|
||||
// Get package NBT
|
||||
packagesContainer.pushItems(turtleLocalName, slot);
|
||||
const packageInfo = blockReader.getBlockData()!.Items[1];
|
||||
// log.info(textutils.serialise(packageInfo));
|
||||
|
||||
// Get recipe
|
||||
const packageRecipes = CraftManager.getPackageRecipe(packageInfo);
|
||||
|
||||
// No recipe, just extract package
|
||||
if (packageRecipes == undefined) {
|
||||
packageExtractor.pullItems(turtleLocalName, 1);
|
||||
log.info(`No recipe, just pass`);
|
||||
continue;
|
||||
// State logic
|
||||
switch (currentState) {
|
||||
case State.IDLE: {
|
||||
if (!hasPackage) os.pullEvent("redstone");
|
||||
hasPackage = redstone.getInput(peripheralsNames.redstone);
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract package
|
||||
// log.info(`Get recipe ${textutils.serialise(recipe)}`);
|
||||
packageExtractor.pullItems(turtleLocalName, 1);
|
||||
case State.READ_RECIPE: {
|
||||
logger.info(`Package detected`);
|
||||
const packagesInfoRecord = packsInventory.list();
|
||||
for (const key in packagesInfoRecord) {
|
||||
const slotNum = parseInt(key);
|
||||
packsInventory.pushItems(turtleLocalName, slotNum);
|
||||
|
||||
// Get package NBT
|
||||
logger.debug(
|
||||
`Turtle:\n${textutils.serialise(blockReader.getBlockData()!, { allow_repetitions: true })}`,
|
||||
);
|
||||
const packageDetailInfo = blockReader.getBlockData()?.Items[1];
|
||||
if (packageDetailInfo === undefined) {
|
||||
logger.error(`Package detail info not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get OrderId and isFinal
|
||||
const packageOrderId = (packageDetailInfo.tag as CreatePackageTag)
|
||||
.Fragment.OrderId;
|
||||
const packageIsFinal =
|
||||
(packageDetailInfo.tag as CreatePackageTag).Fragment.IsFinal > 0
|
||||
? true
|
||||
: false;
|
||||
|
||||
// Get recipe
|
||||
const packageRecipes =
|
||||
CraftManager.getPackageRecipe(packageDetailInfo);
|
||||
if (packageRecipes.isSome()) {
|
||||
if (packageIsFinal) recipesQueue.enqueue(packageRecipes.value);
|
||||
else recipesWaitingMap.set(packageOrderId, packageRecipes.value);
|
||||
} else {
|
||||
if (packageIsFinal && recipesWaitingMap.has(packageOrderId)) {
|
||||
recipesQueue.enqueue(recipesWaitingMap.get(packageOrderId)!);
|
||||
recipesWaitingMap.delete(packageOrderId);
|
||||
} else {
|
||||
logger.debug(`No recipe, just pass`);
|
||||
}
|
||||
}
|
||||
packageExtractor.pullItems(turtleLocalName, 1);
|
||||
}
|
||||
|
||||
if (
|
||||
currentState === State.READ_RECIPE &&
|
||||
nextState === State.CRAFT_OUTPUT
|
||||
) {
|
||||
craftManager.initItemsMap();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case State.CRAFT_OUTPUT: {
|
||||
// Check recipe
|
||||
const recipe = recipesQueue.dequeue();
|
||||
if (recipe === undefined) break;
|
||||
|
||||
// Pull and craft multi recipe
|
||||
for (const recipe of packageRecipes) {
|
||||
let craftOutputItem: BlockItemDetailData | undefined = undefined;
|
||||
let restCraftCnt = recipe.Count;
|
||||
let maxSignleCraftCnt = restCraftCnt;
|
||||
|
||||
let craftItemDetail: ItemDetail | undefined = undefined;
|
||||
do {
|
||||
// Clear workbench
|
||||
craftManager.pushAll(itemsContainer);
|
||||
craftManager.clearTurtle();
|
||||
|
||||
logger.info(`Pull items according to a recipe`);
|
||||
const craftCnt = craftManager
|
||||
.pullItemsWithRecipe(recipe, maxSignleCraftCnt)
|
||||
.unwrapOrElse((error) => {
|
||||
logger.error(error.message);
|
||||
return 0;
|
||||
});
|
||||
|
||||
log.info(`Pull items according to a recipe`);
|
||||
const craftCnt = craftManager.pullItems(
|
||||
recipe,
|
||||
itemsContainer,
|
||||
restCraftCnt,
|
||||
);
|
||||
if (craftCnt == 0) break;
|
||||
craftManager.craft();
|
||||
log.info(`Craft ${craftCnt} times`);
|
||||
if (craftCnt < maxSignleCraftCnt) maxSignleCraftCnt = craftCnt;
|
||||
const craftRet = craftManager.craft(maxSignleCraftCnt);
|
||||
craftItemDetail ??= craftRet;
|
||||
logger.info(`Craft ${craftCnt} times`);
|
||||
restCraftCnt -= craftCnt;
|
||||
|
||||
// Get output item
|
||||
craftOutputItem ??= blockReader.getBlockData()!.Items[1];
|
||||
} while (restCraftCnt > 0);
|
||||
|
||||
// Finally output
|
||||
if (restCraftCnt > 0) {
|
||||
log.warn(`Only craft ${recipe.Count - restCraftCnt} times`);
|
||||
logger.warn(
|
||||
`Only craft ${recipe.Count - restCraftCnt}x ${craftItemDetail?.name ?? "UnknownItem"}`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Finish craft ${recipe.Count}x ${craftOutputItem?.id}`);
|
||||
logger.info(
|
||||
`Finish craft ${recipe.Count}x ${craftItemDetail?.name ?? "UnknownItem"}`,
|
||||
);
|
||||
}
|
||||
craftManager.pushAll(itemsContainer);
|
||||
|
||||
// Clear workbench and inventory
|
||||
const turtleItemSlots = Object.values(
|
||||
blockReader.getBlockData()!.Items,
|
||||
).map((val) => val.Slot + 1);
|
||||
craftManager.clearTurtle(turtleItemSlots);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
sleep(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check packages
|
||||
hasPackage = redstone.getInput(peripheralsNames.redstone);
|
||||
// State update
|
||||
currentState = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error: unknown) {
|
||||
log.error(textutils.serialise(error as object));
|
||||
logger.error(textutils.serialise(error as object));
|
||||
} finally {
|
||||
log.close();
|
||||
logger.close();
|
||||
}
|
||||
|
||||
616
src/cliExample/main.ts
Normal file
616
src/cliExample/main.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 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: "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}`);
|
||||
|
||||
if (context.debugMode) {
|
||||
context.log(`Calculation result: ${result}`);
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const subtractCommand: Command<AppContext> = {
|
||||
name: "subtract",
|
||||
description: "Subtracts the second number from the first",
|
||||
args: [
|
||||
{ name: "a", description: "The minuend", required: true },
|
||||
{ name: "b", description: "The subtrahend", required: true },
|
||||
],
|
||||
action: ({ args, context }): Result<void, CliError> => {
|
||||
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("Error: Arguments must be numbers.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const result = a - b;
|
||||
print(`${a} - ${b} = ${result}`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const greetCommand: Command<AppContext> = {
|
||||
name: "greet",
|
||||
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(`Executing 'greet' command in '${context.appName}'`);
|
||||
|
||||
const name = options.name as string;
|
||||
const times = tonumber(options.times as string) ?? 1;
|
||||
|
||||
for (let i = 1; i <= times; i++) {
|
||||
print(`Hello, ${name}!`);
|
||||
|
||||
if (context.debugMode && times > 1) {
|
||||
context.log(`Greeting ${i}/${times}`);
|
||||
}
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
// Math subcommands group
|
||||
const mathCommand: Command<AppContext> = {
|
||||
name: "math",
|
||||
description: "Mathematical operations",
|
||||
subcommands: new Map([
|
||||
["add", addCommand],
|
||||
["subtract", subtractCommand],
|
||||
]),
|
||||
};
|
||||
|
||||
// Config command with nested subcommands
|
||||
const configShowCommand: Command<AppContext> = {
|
||||
name: "show",
|
||||
description: "Show current configuration",
|
||||
action: ({ context }): Result<void, CliError> => {
|
||||
print(`App Name: ${context.appName}`);
|
||||
print(`Debug Mode: ${context.debugMode ? "on" : "off"}`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const configSetCommand: Command<AppContext> = {
|
||||
name: "set",
|
||||
description: "Set a configuration item",
|
||||
args: [
|
||||
{ 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(`Setting config: ${key} = ${value}`);
|
||||
print(`Config '${key}' has been set to '${value}'`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const configCommand: Command<AppContext> = {
|
||||
name: "config",
|
||||
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: "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("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");
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
// 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: "MyAwesome Calculator & Chat Manager",
|
||||
debugMode: false,
|
||||
log: (message) => {
|
||||
print(`[LOG] ${message}`);
|
||||
},
|
||||
chatManager: initializeChatManager(),
|
||||
};
|
||||
|
||||
// 6. Create and export CLI handler
|
||||
const cli = createCli(rootCommand, { globalContext: appContext });
|
||||
const args = [...$vararg];
|
||||
cli(args);
|
||||
|
||||
// Example usage (uncomment to test):
|
||||
/*
|
||||
// Simple math operations
|
||||
cli(['math', 'add', '5', '7']); // Output: 12
|
||||
cli(['math', 'subtract', '10', '3']); // Output: 7
|
||||
|
||||
// Greet with options
|
||||
cli(['greet', '--name', 'TypeScript']); // Output: Hello, TypeScript!
|
||||
cli(['greet', '-n', 'World', '-t', '3']); // Output: Hello, World! (3 times)
|
||||
|
||||
// Config management
|
||||
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(['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
657
src/lib/ChatManager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CCLog } from "./ccLog";
|
||||
|
||||
const log = new CCLog("CraftManager.log");
|
||||
import { Queue } from "./datatype/Queue";
|
||||
import { Result, Ok, Err, Option, Some, None } from "./thirdparty/ts-result-es";
|
||||
|
||||
// ComputerCraft Turtle inventory layout:
|
||||
// 1, 2, 3, 4
|
||||
@@ -9,23 +8,52 @@ const log = new CCLog("CraftManager.log");
|
||||
// 13, 14, 15, 16
|
||||
|
||||
const TURTLE_SIZE = 16;
|
||||
const CRAFT_OUTPUT_SLOT = 4;
|
||||
// const CRAFT_SLOT_CNT = 9;
|
||||
const CRAFT_SLOT_TABLE: number[] = [1, 2, 3, 5, 6, 7, 9, 10, 11];
|
||||
// const REST_SLOT_CNT = 7;
|
||||
// const REST_SLOT_TABLE: number[] = [4, 8, 12, 13, 14, 15, 16];
|
||||
|
||||
/**
|
||||
* Represents the NBT data of a Create mod package. This data is used for managing crafting and logistics,
|
||||
* especially in the context of multi-step crafting orders.
|
||||
* The structure is inspired by the logic in Create's own packaging and repackaging helpers.
|
||||
* @see https://github.com/Creators-of-Create/Create/blob/mc1.21.1/dev/src/main/java/com/simibubi/create/content/logistics/packager/repackager/PackageRepackageHelper.java
|
||||
*/
|
||||
interface CreatePackageTag {
|
||||
/**
|
||||
* The items contained within this package.
|
||||
*/
|
||||
Items: {
|
||||
/**
|
||||
* A list of the items stored in the package.
|
||||
*/
|
||||
Items: {
|
||||
id: string;
|
||||
Count: number;
|
||||
Slot: number;
|
||||
}[];
|
||||
/**
|
||||
* The number of slots in the package's inventory.
|
||||
*/
|
||||
Size: number;
|
||||
};
|
||||
/**
|
||||
* Information about this package's role as a fragment of a larger crafting order.
|
||||
* This is used to track progress and manage dependencies in a distributed crafting system.
|
||||
*/
|
||||
Fragment: {
|
||||
/**
|
||||
* The index of this fragment within the larger order.
|
||||
*/
|
||||
Index: number;
|
||||
/**
|
||||
* The context of the overall order this fragment belongs to.
|
||||
*/
|
||||
OrderContext: {
|
||||
/**
|
||||
* A list of crafting recipes required for the order.
|
||||
*/
|
||||
OrderedCrafts: {
|
||||
Pattern: {
|
||||
Entries: {
|
||||
@@ -39,6 +67,9 @@ interface CreatePackageTag {
|
||||
};
|
||||
Count: number;
|
||||
}[];
|
||||
/**
|
||||
* A list of pre-existing item stacks required for the order.
|
||||
*/
|
||||
OrderedStacks: {
|
||||
Entries: {
|
||||
Item: {
|
||||
@@ -49,11 +80,26 @@ interface CreatePackageTag {
|
||||
}[];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Whether this is the final fragment in the sequence for this specific part of the order.
|
||||
*/
|
||||
IsFinal: number;
|
||||
/**
|
||||
* The unique identifier for the overall order.
|
||||
*/
|
||||
OrderId: number;
|
||||
/**
|
||||
* The index of this package in a linked list of packages for the same order.
|
||||
*/
|
||||
LinkIndex: number;
|
||||
/**
|
||||
* Whether this is the last package in the linked list.
|
||||
*/
|
||||
IsFinalLink: number;
|
||||
};
|
||||
/**
|
||||
* The destination address for this package.
|
||||
*/
|
||||
Address: string;
|
||||
}
|
||||
|
||||
@@ -70,12 +116,27 @@ interface CraftRecipe {
|
||||
Count: number;
|
||||
}
|
||||
|
||||
interface InventorySlotInfo {
|
||||
name: string;
|
||||
slotCountQueue: Queue<{
|
||||
slotNum: number;
|
||||
count: number;
|
||||
}>;
|
||||
maxCount: number;
|
||||
}
|
||||
|
||||
type CraftMode = "keep" | "keepProduct" | "keepIngredient";
|
||||
|
||||
class CraftManager {
|
||||
private localName: string;
|
||||
private inventory: InventoryPeripheral;
|
||||
|
||||
constructor(modem: WiredModemPeripheral | string) {
|
||||
private inventoryItemsMap = new Map<string, InventorySlotInfo>();
|
||||
|
||||
constructor(
|
||||
modem: WiredModemPeripheral | string,
|
||||
srcInventory: InventoryPeripheral,
|
||||
) {
|
||||
if (turtle == undefined) {
|
||||
throw new Error("Script must be run in a turtle computer");
|
||||
}
|
||||
@@ -99,98 +160,207 @@ class CraftManager {
|
||||
}
|
||||
this.localName = name;
|
||||
// log.info(`Get turtle name : ${name}`);
|
||||
}
|
||||
|
||||
public pushAll(outputInventory: InventoryPeripheral): void {
|
||||
for (let i = 1; i <= TURTLE_SIZE; i++) {
|
||||
outputInventory.pullItems(this.localName, i);
|
||||
}
|
||||
}
|
||||
|
||||
public craft(dstInventory?: InventoryPeripheral, limit?: number): void {
|
||||
turtle.craft(limit);
|
||||
|
||||
if (dstInventory != undefined) {
|
||||
dstInventory.pullItems(this.localName, 1, limit);
|
||||
}
|
||||
// Inventory
|
||||
this.inventory = srcInventory;
|
||||
}
|
||||
|
||||
public static getPackageRecipe(
|
||||
item: BlockItemDetailData,
|
||||
): CraftRecipe[] | undefined {
|
||||
): Option<CraftRecipe[]> {
|
||||
if (
|
||||
!item.id.includes("create:cardboard_package") ||
|
||||
(item.tag as CreatePackageTag)?.Fragment?.OrderContext
|
||||
?.OrderedCrafts?.[0] == undefined
|
||||
) {
|
||||
return undefined;
|
||||
return None;
|
||||
}
|
||||
|
||||
const orderedCraft = (item.tag as CreatePackageTag).Fragment.OrderContext
|
||||
.OrderedCrafts;
|
||||
return orderedCraft.map((value, _) => ({
|
||||
PatternEntries: value.Pattern.Entries,
|
||||
Count: value.Count,
|
||||
}));
|
||||
return new Some(
|
||||
orderedCraft.map((value, _) => ({
|
||||
PatternEntries: value.Pattern.Entries,
|
||||
Count: value.Count,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
public pullItems(
|
||||
recipe: CraftRecipe,
|
||||
inventory: InventoryPeripheral,
|
||||
limit: number,
|
||||
): number {
|
||||
let maxCraftCount = limit;
|
||||
public initItemsMap() {
|
||||
const ingredientList = this.inventory.list();
|
||||
for (const key in ingredientList) {
|
||||
const slotNum = parseInt(key);
|
||||
const item = this.inventory.getItemDetail(slotNum)!;
|
||||
|
||||
if (this.inventoryItemsMap.has(item.name)) {
|
||||
this.inventoryItemsMap.get(item.name)!.slotCountQueue.enqueue({
|
||||
slotNum: slotNum,
|
||||
count: item.count,
|
||||
});
|
||||
} else {
|
||||
this.inventoryItemsMap.set(item.name, {
|
||||
name: item.name,
|
||||
maxCount: item.maxCount,
|
||||
slotCountQueue: new Queue<{ slotNum: number; count: number }>([
|
||||
{ slotNum: slotNum, count: item.count },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public pullFromInventory(
|
||||
itemId: string,
|
||||
count?: number,
|
||||
toSlot?: number,
|
||||
): Result<number> {
|
||||
const item = this.inventoryItemsMap.get(itemId);
|
||||
if (item === undefined || item.slotCountQueue.size() === 0)
|
||||
return new Err(Error(`No item match ${itemId}`));
|
||||
|
||||
if (count === undefined) {
|
||||
const itemSlot = item.slotCountQueue.dequeue()!;
|
||||
const pullItemsCnt = this.inventory.pushItems(
|
||||
this.localName,
|
||||
itemSlot.slotNum,
|
||||
itemSlot.count,
|
||||
toSlot,
|
||||
);
|
||||
return new Ok(pullItemsCnt);
|
||||
}
|
||||
|
||||
let restCount = count;
|
||||
while (restCount > 0 && item.slotCountQueue.size() > 0) {
|
||||
const itemSlot = item.slotCountQueue.dequeue()!;
|
||||
const pullItemsCnt = this.inventory.pushItems(
|
||||
this.localName,
|
||||
itemSlot.slotNum,
|
||||
Math.min(restCount, itemSlot.count),
|
||||
toSlot,
|
||||
);
|
||||
if (pullItemsCnt < itemSlot.count) {
|
||||
item.slotCountQueue.enqueue({
|
||||
slotNum: itemSlot.slotNum,
|
||||
count: itemSlot.count - pullItemsCnt,
|
||||
});
|
||||
}
|
||||
restCount -= pullItemsCnt;
|
||||
}
|
||||
|
||||
return new Ok(count - restCount);
|
||||
}
|
||||
|
||||
public pushToInventoryEmpty(
|
||||
fromSlot: number,
|
||||
count?: number,
|
||||
): Result<number> {
|
||||
let emptySlot = 0;
|
||||
for (let i = this.inventory.size(); i > 0; i--) {
|
||||
const isEmpty = this.inventory.getItemDetail(i) === undefined;
|
||||
if (isEmpty) {
|
||||
emptySlot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (emptySlot <= 0) return new Err(Error("No empty slot found"));
|
||||
|
||||
return new Ok(
|
||||
this.inventory.pullItems(this.localName, fromSlot, count, emptySlot),
|
||||
);
|
||||
}
|
||||
|
||||
public pushToInventory(fromSlot: number): Result<number> {
|
||||
const itemInfoDetail = turtle.getItemDetail(fromSlot) as
|
||||
| SlotDetail
|
||||
| undefined;
|
||||
if (itemInfoDetail === undefined) return new Ok(0);
|
||||
const inventoryItemInfo = this.inventoryItemsMap.get(itemInfoDetail.name);
|
||||
|
||||
if (inventoryItemInfo === undefined) {
|
||||
return this.pushToInventoryEmpty(fromSlot, itemInfoDetail.count);
|
||||
}
|
||||
|
||||
let restItemsCount = itemInfoDetail.count;
|
||||
for (const slotInfo of inventoryItemInfo.slotCountQueue) {
|
||||
const pullItemsCount = inventoryItemInfo.maxCount - slotInfo.count;
|
||||
if (pullItemsCount > 0) {
|
||||
this.inventory.pullItems(
|
||||
this.localName,
|
||||
fromSlot,
|
||||
pullItemsCount,
|
||||
slotInfo.slotNum,
|
||||
);
|
||||
restItemsCount -= pullItemsCount;
|
||||
if (restItemsCount <= 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (restItemsCount > 0) {
|
||||
const pushRet = this.pushToInventoryEmpty(fromSlot, restItemsCount);
|
||||
if (pushRet.isErr()) return pushRet;
|
||||
}
|
||||
|
||||
return new Ok(itemInfoDetail.count);
|
||||
}
|
||||
|
||||
public clearTurtle(slots?: number[]): void {
|
||||
if (slots !== undefined) {
|
||||
for (const slotNum of slots) {
|
||||
this.pushToInventory(slotNum);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= TURTLE_SIZE; i++) {
|
||||
this.pushToInventory(i);
|
||||
}
|
||||
}
|
||||
|
||||
public craft(limit?: number, outputSlot = CRAFT_OUTPUT_SLOT): ItemDetail {
|
||||
turtle.select(outputSlot);
|
||||
turtle.craft(limit);
|
||||
const craftItemDetail = turtle.getItemDetail(
|
||||
outputSlot,
|
||||
true,
|
||||
) as ItemDetail;
|
||||
|
||||
return craftItemDetail;
|
||||
}
|
||||
|
||||
public pullItemsWithRecipe(
|
||||
recipe: CraftRecipe,
|
||||
craftCnt: number,
|
||||
): Result<number> {
|
||||
let maxCraftCnt = craftCnt;
|
||||
for (const index in recipe.PatternEntries) {
|
||||
const entry = recipe.PatternEntries[index];
|
||||
if (entry.Item.Count == 0 || entry.Item.id == "minecraft:air") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ingredientList = inventory.list();
|
||||
let restCount = maxCraftCount;
|
||||
for (const key in ingredientList) {
|
||||
// Get item detail and check max count
|
||||
const slot = parseInt(key);
|
||||
const ingredient = inventory.getItemDetail(slot)!;
|
||||
if (entry.Item.id != ingredient.name) {
|
||||
continue;
|
||||
}
|
||||
const ingredient = this.inventoryItemsMap.get(entry.Item.id);
|
||||
if (ingredient === undefined)
|
||||
return new Err(Error(`No ingredient match ${entry.Item.id}`));
|
||||
|
||||
const ingredientMaxCount = ingredient.maxCount;
|
||||
if (maxCraftCount > ingredientMaxCount) {
|
||||
maxCraftCount = ingredientMaxCount;
|
||||
restCount = maxCraftCount;
|
||||
}
|
||||
log.info(
|
||||
`Slot ${slot} ${ingredient.name} max count: ${ingredientMaxCount}`,
|
||||
);
|
||||
|
||||
// TODO: Process multi count entry item
|
||||
if (ingredient.count >= restCount) {
|
||||
inventory.pushItems(
|
||||
this.localName,
|
||||
slot,
|
||||
restCount,
|
||||
CRAFT_SLOT_TABLE[parseInt(index) - 1],
|
||||
);
|
||||
restCount = 0;
|
||||
break;
|
||||
} else {
|
||||
inventory.pushItems(
|
||||
this.localName,
|
||||
slot,
|
||||
ingredient.count,
|
||||
CRAFT_SLOT_TABLE[parseInt(index) - 1],
|
||||
);
|
||||
restCount -= ingredient.count;
|
||||
}
|
||||
// Check item max stack count
|
||||
if (ingredient.maxCount < maxCraftCnt) {
|
||||
maxCraftCnt = ingredient.maxCount;
|
||||
}
|
||||
|
||||
if (restCount > 0) return 0;
|
||||
// Pull items
|
||||
const pullItemsCnt = this.pullFromInventory(
|
||||
ingredient.name,
|
||||
maxCraftCnt,
|
||||
CRAFT_SLOT_TABLE[index],
|
||||
);
|
||||
if (pullItemsCnt.isErr()) return pullItemsCnt;
|
||||
|
||||
if (pullItemsCnt.value < maxCraftCnt)
|
||||
return new Err(Error("Not enough items in inventory"));
|
||||
}
|
||||
|
||||
return maxCraftCount;
|
||||
return new Ok(maxCraftCnt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
src/lib/TimerManager.ts
Normal file
36
src/lib/TimerManager.ts
Normal 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();
|
||||
226
src/lib/ccCLI/cli.ts
Normal file
226
src/lib/ccCLI/cli.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Ok, Err, Result } from "../thirdparty/ts-result-es";
|
||||
import {
|
||||
Command,
|
||||
ActionContext,
|
||||
Argument,
|
||||
Option,
|
||||
CliError,
|
||||
ParseResult,
|
||||
} from "./types";
|
||||
import {
|
||||
parseArguments,
|
||||
validateRequiredArgs,
|
||||
validateRequiredOptions,
|
||||
normalizeOptions,
|
||||
} from "./parser";
|
||||
import { generateHelp, shouldShowHelp, generateCommandList } from "./help";
|
||||
|
||||
/**
|
||||
* @interface CreateCliOptions
|
||||
* @description Optional configuration for the CLI handler.
|
||||
*/
|
||||
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.
|
||||
* Default: textutils.pagedPrint(msg, term.getCursorPos()[1] - 2)
|
||||
**/
|
||||
writer?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CLI handler function from a root command definition.
|
||||
* @param rootCommand The root command for the entire CLI application.
|
||||
* @param globalContext An optional global context object to be made available in all command actions.
|
||||
* @returns A function that takes command-line arguments and executes the appropriate command.
|
||||
*/
|
||||
export function createCli<TContext extends object>(
|
||||
rootCommand: Command<TContext>,
|
||||
options: CreateCliOptions<TContext> = {},
|
||||
): (argv: string[]) => void {
|
||||
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 (argv[0]?.startsWith("--help") || argv[0]?.startsWith("-h")) {
|
||||
writer(generateHelp(rootCommand, [rootCommand.name]));
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the parsed input and executes the resolved command.
|
||||
* @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>(
|
||||
parseResult: ParseResult<TContext>,
|
||||
globalContext: TContext | undefined,
|
||||
writer: (message: string) => void,
|
||||
): Result<void, CliError> {
|
||||
const { command, commandPath, options, remaining } = parseResult;
|
||||
|
||||
// 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 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],
|
||||
});
|
||||
}
|
||||
|
||||
// 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 !== undefined
|
||||
? Array.from(command.options.values())
|
||||
: [],
|
||||
options,
|
||||
).map((processedOptions) => ({ args, options: processedOptions }));
|
||||
})
|
||||
.andThen(({ args, options: processedOptions }) => {
|
||||
const context: ActionContext<TContext> = {
|
||||
args,
|
||||
options: processedOptions,
|
||||
context: globalContext!,
|
||||
};
|
||||
// Finally, execute the command's action.
|
||||
return command.action!(context);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and validates command arguments from the raw input.
|
||||
* @param argDefs The argument definitions for the command.
|
||||
* @param remainingArgs The remaining positional arguments.
|
||||
* @returns A `Result` with the processed arguments record or a `MissingArgumentError`.
|
||||
*/
|
||||
function processArguments(
|
||||
argDefs: Argument[],
|
||||
remainingArgs: string[],
|
||||
): Result<Record<string, unknown>, CliError> {
|
||||
const args: Record<string, unknown> = {};
|
||||
|
||||
for (let i = 0; i < argDefs.length; i++) {
|
||||
const argDef = argDefs[i];
|
||||
if (i < remainingArgs.length) {
|
||||
args[argDef.name] = remainingArgs[i];
|
||||
}
|
||||
}
|
||||
|
||||
const requiredArgs = argDefs
|
||||
.filter((arg) => arg.required ?? false)
|
||||
.map((arg) => arg.name);
|
||||
return validateRequiredArgs(args, requiredArgs).map(() => args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and validates command options from the raw input.
|
||||
* @param optionDefs The option definitions for the command.
|
||||
* @param rawOptions The raw options parsed from the command line.
|
||||
* @returns A `Result` with the processed options record or a `MissingOptionError`.
|
||||
*/
|
||||
function processOptions(
|
||||
optionDefs: Option[],
|
||||
rawOptions: Record<string, unknown>,
|
||||
): Result<Record<string, unknown>, CliError> {
|
||||
const shortToLongMap: Record<string, string> = {};
|
||||
const defaultValues: Record<string, unknown> = {};
|
||||
|
||||
for (const optionDef of optionDefs) {
|
||||
if (optionDef.shortName !== undefined) {
|
||||
shortToLongMap[optionDef.shortName] = optionDef.name;
|
||||
}
|
||||
if (optionDef.defaultValue !== undefined) {
|
||||
defaultValues[optionDef.name] = optionDef.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedOptions = normalizeOptions(rawOptions, shortToLongMap);
|
||||
const options = { ...defaultValues, ...normalizedOptions };
|
||||
|
||||
const requiredOptions = optionDefs
|
||||
.filter((opt) => opt.required ?? false)
|
||||
.map((opt) => opt.name);
|
||||
return validateRequiredOptions(options, requiredOptions).map(() => options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a `CliError` into a user-friendly string.
|
||||
* @param error The `CliError` object.
|
||||
* @param rootCommand The root command, used for context in some errors.
|
||||
* @returns A formatted error message string.
|
||||
*/
|
||||
function formatError<TContext extends object>(
|
||||
error: CliError,
|
||||
_rootCommand: Command<TContext>,
|
||||
): string {
|
||||
switch (error.kind) {
|
||||
case "UnknownCommand":
|
||||
return `Error: Unknown command "${error.commandName}".`;
|
||||
case "MissingArgument":
|
||||
return `Error: Missing required argument "${error.argName}".`;
|
||||
case "MissingOption":
|
||||
return `Error: Missing required option "--${error.optionName}".`;
|
||||
case "NoAction":
|
||||
return `Error: Command "${error.commandPath.join(" ")}" is not runnable.`;
|
||||
default:
|
||||
// This should be unreachable if all error kinds are handled.
|
||||
return "An unexpected error occurred.";
|
||||
}
|
||||
}
|
||||
107
src/lib/ccCLI/help.ts
Normal file
107
src/lib/ccCLI/help.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Command } from "./types";
|
||||
|
||||
/**
|
||||
* Generates a well-formatted help string for a given command.
|
||||
* @param command The command to generate help for.
|
||||
* @param commandPath The path to the command, used for showing the full command name.
|
||||
* @returns A formatted string containing the complete help message.
|
||||
*/
|
||||
export function generateHelp<TContext extends object>(
|
||||
command: Command<TContext>,
|
||||
commandPath: string[] = [],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const fullCommandName = commandPath.join(" ");
|
||||
|
||||
// Description
|
||||
if (command.description !== undefined) {
|
||||
lines.push(command.description);
|
||||
}
|
||||
|
||||
// Usage
|
||||
const usageParts: string[] = ["Usage:", fullCommandName];
|
||||
if (command.options && command.options.size > 0) {
|
||||
usageParts.push("[OPTIONS]");
|
||||
}
|
||||
if (command.subcommands && command.subcommands.size > 0) {
|
||||
usageParts.push("<COMMAND>");
|
||||
}
|
||||
if (command.args && command.args.length > 0) {
|
||||
for (const arg of command.args) {
|
||||
usageParts.push(
|
||||
arg.required === true ? `<${arg.name}>` : `[${arg.name}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("\n" + usageParts.join(" "));
|
||||
|
||||
// Arguments
|
||||
if (command.args && command.args.length > 0) {
|
||||
lines.push("\nArguments:");
|
||||
for (const arg of command.args) {
|
||||
const requiredText = arg.required === true ? " (required)" : "";
|
||||
lines.push(` ${arg.name.padEnd(20)} ${arg.description}${requiredText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Options
|
||||
if (command.options && command.options.size > 0) {
|
||||
lines.push("\nOptions:");
|
||||
for (const option of command.options.values()) {
|
||||
const short =
|
||||
option.shortName !== undefined ? `-${option.shortName}, ` : " ";
|
||||
const long = `--${option.name}`;
|
||||
const display = `${short}${long}`.padEnd(20);
|
||||
const requiredText = option.required === true ? " (required)" : "";
|
||||
const defaultText =
|
||||
option.defaultValue !== undefined
|
||||
? ` (default: ${textutils.serialise(option.defaultValue!)})`
|
||||
: "";
|
||||
lines.push(
|
||||
` ${display} ${option.description}${requiredText}${defaultText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Subcommands
|
||||
if (command.subcommands && command.subcommands.size > 0) {
|
||||
lines.push("\nCommands:");
|
||||
for (const subcommand of command.subcommands.values()) {
|
||||
lines.push(` ${subcommand.name.padEnd(20)} ${subcommand.description}`);
|
||||
}
|
||||
lines.push(
|
||||
`\nRun '${fullCommandName} <COMMAND> --help' for more information on a command.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple list of available commands, typically for error messages.
|
||||
* @param commands An array of command objects.
|
||||
* @returns A formatted string listing the available commands.
|
||||
*/
|
||||
export function generateCommandList<TContext extends object>(
|
||||
commands: Map<string, Command<TContext>>,
|
||||
): string {
|
||||
if (commands.size === 0) {
|
||||
return "No commands available.";
|
||||
}
|
||||
|
||||
const lines: string[] = ["Available commands:"];
|
||||
for (const command of commands.values()) {
|
||||
lines.push(` ${command.name.padEnd(20)} ${command.description}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the `--help` or `-h` flag is present in the arguments.
|
||||
* @param argv An array of command-line arguments.
|
||||
* @returns `true` if a help flag is found, otherwise `false`.
|
||||
*/
|
||||
export function shouldShowHelp(argv: string[]): boolean {
|
||||
return argv.includes("help") || argv.includes("h");
|
||||
}
|
||||
32
src/lib/ccCLI/index.ts
Normal file
32
src/lib/ccCLI/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* CC:Tweaked CLI Framework
|
||||
*
|
||||
* A functional-style CLI framework for CC:Tweaked and TSTL.
|
||||
* This framework provides a declarative way to define command-line interfaces with support
|
||||
* for nested commands, arguments, options, and Result-based error handling.
|
||||
*/
|
||||
|
||||
// --- Core public API ---
|
||||
export { createCli } from "./cli";
|
||||
|
||||
// --- Type definitions for creating commands ---
|
||||
export type {
|
||||
Command,
|
||||
Argument,
|
||||
Option,
|
||||
ActionContext,
|
||||
CliError,
|
||||
UnknownCommandError,
|
||||
MissingArgumentError,
|
||||
MissingOptionError,
|
||||
NoActionError,
|
||||
} from "./types";
|
||||
|
||||
// --- Utility functions for help generation and advanced parsing ---
|
||||
export { generateHelp, generateCommandList, shouldShowHelp } from "./help";
|
||||
export {
|
||||
parseArguments,
|
||||
validateRequiredArgs,
|
||||
validateRequiredOptions,
|
||||
normalizeOptions,
|
||||
} from "./parser";
|
||||
273
src/lib/ccCLI/parser.ts
Normal file
273
src/lib/ccCLI/parser.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Ok, Err, Result } from "../thirdparty/ts-result-es";
|
||||
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 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<TContext extends object>(
|
||||
argv: string[],
|
||||
rootCommand: Command<TContext>,
|
||||
): Result<ParseResult<TContext>, CliError> {
|
||||
const result: ParseResult<TContext> = {
|
||||
command: rootCommand,
|
||||
commandPath: [rootCommand.name],
|
||||
options: {},
|
||||
remaining: [],
|
||||
};
|
||||
|
||||
let currentCommand = rootCommand;
|
||||
let inOptions = false;
|
||||
const optionMapCache = new OptionMapCache();
|
||||
|
||||
// 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];
|
||||
|
||||
// Skip null/undefined arguments
|
||||
if (!arg) continue;
|
||||
|
||||
// Handle double dash (--) - everything after is treated as remaining
|
||||
if (arg === "--") {
|
||||
result.remaining.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle long options (--option or --option=value)
|
||||
if (arg.startsWith("--")) {
|
||||
inOptions = true;
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
|
||||
if (equalsIndex !== -1) {
|
||||
// --option=value format
|
||||
const optionName = arg.slice(2, equalsIndex);
|
||||
const optionValue = arg.slice(equalsIndex + 1);
|
||||
result.options[optionName] = optionValue;
|
||||
} else {
|
||||
// --option [value] format
|
||||
const optionName = arg.slice(2);
|
||||
i = processOption(optionName, i);
|
||||
}
|
||||
}
|
||||
// Handle short options (-o or -o value)
|
||||
else if (arg.startsWith("-") && arg.length > 1) {
|
||||
inOptions = true;
|
||||
const shortName = arg.slice(1);
|
||||
const optionName =
|
||||
currentOptionMaps.shortNameMap.get(shortName) ?? shortName;
|
||||
i = processOption(optionName, i);
|
||||
}
|
||||
// Handle positional arguments and command resolution
|
||||
else {
|
||||
if (!inOptions) {
|
||||
// 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 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++;
|
||||
}
|
||||
|
||||
const remainingArgs = commandPath.slice(i);
|
||||
return new Ok({
|
||||
command: currentCommand,
|
||||
commandPath: resolvedPath,
|
||||
remainingArgs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all required arguments are present in the parsed arguments.
|
||||
* @param parsedArgs A record of the arguments that were parsed.
|
||||
* @param requiredArgs An array of names of required arguments.
|
||||
* @returns An `Ok` result if validation passes, otherwise an `Err` with a `MissingArgumentError`.
|
||||
*/
|
||||
export function validateRequiredArgs(
|
||||
parsedArgs: Record<string, unknown>,
|
||||
requiredArgs: string[],
|
||||
): Result<void, MissingArgumentError> {
|
||||
for (const argName of requiredArgs) {
|
||||
if (!(argName in parsedArgs) || parsedArgs[argName] === undefined) {
|
||||
return new Err({ kind: "MissingArgument", argName });
|
||||
}
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all required options are present in the parsed options.
|
||||
* @param parsedOptions A record of the options that were parsed.
|
||||
* @param requiredOptions An array of names of required options.
|
||||
* @returns An `Ok` result if validation passes, otherwise an `Err` with a `MissingOptionError`.
|
||||
*/
|
||||
export function validateRequiredOptions(
|
||||
parsedOptions: Record<string, unknown>,
|
||||
requiredOptions: string[],
|
||||
): Result<void, MissingOptionError> {
|
||||
for (const optionName of requiredOptions) {
|
||||
if (
|
||||
!(optionName in parsedOptions) ||
|
||||
parsedOptions[optionName] === undefined
|
||||
) {
|
||||
return new Err({ kind: "MissingOption", optionName });
|
||||
}
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes option names by mapping short names to their corresponding long names.
|
||||
* @param options The raw parsed options record (may contain short names).
|
||||
* @param optionMapping A map from short option names to long option names.
|
||||
* @returns A new options record with all short names replaced by long names.
|
||||
*/
|
||||
export function normalizeOptions(
|
||||
options: Record<string, unknown>,
|
||||
optionMapping: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
const normalizedKey = optionMapping[key] ?? key;
|
||||
normalized[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
146
src/lib/ccCLI/types.ts
Normal file
146
src/lib/ccCLI/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Result } from "../thirdparty/ts-result-es";
|
||||
|
||||
// --- Error Types ---
|
||||
|
||||
/**
|
||||
* Represents an error when an unknown command is used.
|
||||
* @property commandName - The name of the command that was not found.
|
||||
*/
|
||||
export interface UnknownCommandError {
|
||||
kind: "UnknownCommand";
|
||||
commandName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error when a required argument is missing.
|
||||
* @property argName - The name of the missing argument.
|
||||
*/
|
||||
export interface MissingArgumentError {
|
||||
kind: "MissingArgument";
|
||||
argName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error when a required option is missing.
|
||||
* @property optionName - The name of the missing option.
|
||||
*/
|
||||
export interface MissingOptionError {
|
||||
kind: "MissingOption";
|
||||
optionName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error when a command that requires an action has none.
|
||||
* @property commandPath - The path to the command without an action.
|
||||
*/
|
||||
export interface NoActionError {
|
||||
kind: "NoAction";
|
||||
commandPath: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A union of all possible CLI-related errors.
|
||||
* This allows for exhaustive error handling using pattern matching on the `kind` property.
|
||||
*/
|
||||
export type CliError =
|
||||
| UnknownCommandError
|
||||
| MissingArgumentError
|
||||
| MissingOptionError
|
||||
| NoActionError;
|
||||
|
||||
// --- Core CLI Structures ---
|
||||
|
||||
/**
|
||||
* @interface Argument
|
||||
* @description Defines a command-line argument for a command.
|
||||
*/
|
||||
export interface Argument {
|
||||
/** The name of the argument, used to access its value. */
|
||||
name: string;
|
||||
/** A brief description of what the argument does, shown in help messages. */
|
||||
description: string;
|
||||
/** Whether the argument is required. Defaults to false. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface Option
|
||||
* @description Defines a command-line option (also known as a flag).
|
||||
*/
|
||||
export interface Option {
|
||||
/** The long name of the option (e.g., "verbose" for `--verbose`). */
|
||||
name: string;
|
||||
/** An optional short name for the option (e.g., "v" for `-v`). */
|
||||
shortName?: string;
|
||||
/** A brief description of what the option does, shown in help messages. */
|
||||
description: string;
|
||||
/** Whether the option is required. Defaults to false. */
|
||||
required?: boolean;
|
||||
/** The default value for the option if it's not provided. */
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface ActionContext
|
||||
* @description The context object passed to a command's action handler.
|
||||
* @template TContext - The type of the global context object.
|
||||
*/
|
||||
export interface ActionContext<TContext extends object> {
|
||||
/** A record of parsed argument values, keyed by argument name. */
|
||||
args: Record<string, unknown>;
|
||||
/** A record of parsed option values, keyed by option name. */
|
||||
options: Record<string, unknown>;
|
||||
/** The global context object, shared across all commands. */
|
||||
context: TContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface Command
|
||||
* @description Defines a CLI command, which can have its own arguments, options, and subcommands.
|
||||
* @template TContext - The type of the global context object.
|
||||
*/
|
||||
export interface Command<TContext extends object> {
|
||||
/** The name of the command. */
|
||||
name: string;
|
||||
/** A brief description of the command, shown in help messages. */
|
||||
description: string;
|
||||
/** A map of argument definitions for the command, keyed by argument name. */
|
||||
args?: Argument[];
|
||||
/** 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>;
|
||||
/** A map of subcommands, allowing for nested command structures, keyed by command name. */
|
||||
subcommands?: Map<string, Command<TContext>>;
|
||||
}
|
||||
|
||||
// --- Parsing and Execution Internals ---
|
||||
|
||||
/**
|
||||
* @interface ParseResult
|
||||
* @description Enhanced parsing result that includes command resolution.
|
||||
*/
|
||||
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 parsed option values. */
|
||||
options: Record<string, unknown>;
|
||||
/** Any remaining arguments that were not parsed as part of the command path or options. */
|
||||
remaining: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type CommandResolution
|
||||
* @description The result of resolving a command path to a specific command.
|
||||
*/
|
||||
export interface CommandResolution<TContext extends object> {
|
||||
command: Command<TContext>;
|
||||
commandPath: string[];
|
||||
remainingArgs: string[];
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function concatSentence(words: string[], length: number): string[] {
|
||||
* @see Source project, ts-deepcopy https://github.com/ykdr2017/ts-deepcopy
|
||||
* @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
|
||||
*/
|
||||
export const deepCopy = <T>(target: T): T => {
|
||||
export function deepCopy<T>(target: T): T {
|
||||
if (target === null) {
|
||||
return target;
|
||||
}
|
||||
@@ -48,4 +48,4 @@ export const deepCopy = <T>(target: T): T => {
|
||||
return cp as T;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export class Node<T> {
|
||||
public value: T;
|
||||
public next?: Node<T>
|
||||
public prev?: Node<T>
|
||||
public next?: Node<T>;
|
||||
public prev?: Node<T>;
|
||||
|
||||
constructor(value: T, next?: Node<T>, prev?: Node<T>) {
|
||||
this.value = value;
|
||||
@@ -15,13 +15,25 @@ export class Queue<T> {
|
||||
private _tail?: Node<T>;
|
||||
private _size: number;
|
||||
|
||||
constructor() {
|
||||
constructor(data?: T[]) {
|
||||
this._head = undefined;
|
||||
this._tail = undefined;
|
||||
this._size = 0;
|
||||
|
||||
if (data === undefined) return;
|
||||
for (const item of data) {
|
||||
this.enqueue(item);
|
||||
}
|
||||
}
|
||||
|
||||
public enqueue(data: T): void {
|
||||
public enqueue(data: T | T[]): void {
|
||||
if (Array.isArray(data)) {
|
||||
for (const val of data) {
|
||||
this.enqueue(val);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const node = new Node(data);
|
||||
|
||||
if (this._head === undefined) {
|
||||
@@ -37,11 +49,11 @@ export class Queue<T> {
|
||||
}
|
||||
|
||||
public dequeue(): T | undefined {
|
||||
if (this._head === undefined) return undefined;
|
||||
const node = this._head;
|
||||
if (node === undefined) return undefined;
|
||||
|
||||
this._head = this._head!.next;
|
||||
this._head!.prev = undefined;
|
||||
this._head = node.next;
|
||||
if (this._head !== undefined) this._head.prev = undefined;
|
||||
this._size--;
|
||||
|
||||
return node.value;
|
||||
@@ -68,8 +80,7 @@ export class Queue<T> {
|
||||
const array: T[] = [];
|
||||
let currentNode: Node<T> = this._head!;
|
||||
for (let i = 0; i < this._size; i++) {
|
||||
if (currentNode.value !== undefined)
|
||||
array.push(currentNode.value);
|
||||
if (currentNode.value !== undefined) array.push(currentNode.value);
|
||||
|
||||
currentNode = currentNode.next!;
|
||||
}
|
||||
@@ -82,13 +93,13 @@ export class Queue<T> {
|
||||
return {
|
||||
next(): IteratorResult<T> {
|
||||
if (currentNode === undefined) {
|
||||
return { value: undefined, done: true }
|
||||
return { value: undefined, done: true };
|
||||
} else {
|
||||
const data = currentNode.value;
|
||||
currentNode = currentNode.next;
|
||||
return { value: data, done: false }
|
||||
return { value: data, done: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ export class SortedArray<T> {
|
||||
return value?.data;
|
||||
}
|
||||
|
||||
public peek(): T | undefined {
|
||||
return this._data[0]?.data;
|
||||
}
|
||||
|
||||
public toArray(): T[] {
|
||||
return this._data.map(({ data }) => data);
|
||||
}
|
||||
191
src/lib/mutex/ReadWriteLock.ts
Normal file
191
src/lib/mutex/ReadWriteLock.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Semaphore } from "./Semaphore";
|
||||
|
||||
const E_CANCELED = new Error("Read-write lock canceled");
|
||||
|
||||
export interface ReadLockHandle {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface WriteLockHandle {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export class ReadWriteLock {
|
||||
private _semaphore: Semaphore;
|
||||
private _maxReaders: number;
|
||||
private _writerWeight: number;
|
||||
private _readerPriority: number;
|
||||
private _writerPriority: number;
|
||||
|
||||
constructor(
|
||||
maxReaders = 1000,
|
||||
readerPriority = 10,
|
||||
writerPriority = 0, // Lower number = higher priority
|
||||
cancelError: Error = E_CANCELED,
|
||||
) {
|
||||
if (maxReaders <= 0) {
|
||||
throw new Error("Max readers must be positive");
|
||||
}
|
||||
|
||||
this._maxReaders = maxReaders;
|
||||
this._writerWeight = maxReaders; // Writers need all capacity for exclusivity
|
||||
this._readerPriority = readerPriority;
|
||||
this._writerPriority = writerPriority;
|
||||
this._semaphore = new Semaphore(maxReaders, cancelError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a read lock. Multiple readers can hold the lock simultaneously.
|
||||
*/
|
||||
async acquireRead(): Promise<ReadLockHandle> {
|
||||
const [, release] = await this._semaphore.acquire(1, this._readerPriority);
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to acquire a read lock immediately. Returns null if not available.
|
||||
*/
|
||||
tryAcquireRead(): ReadLockHandle | undefined {
|
||||
const release = this._semaphore.tryAcquire(1);
|
||||
|
||||
if (release === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a write lock. Only one writer can hold the lock at a time,
|
||||
* and it has exclusive access (no readers can access simultaneously).
|
||||
*/
|
||||
async acquireWrite(): Promise<WriteLockHandle> {
|
||||
const [, release] = await this._semaphore.acquire(
|
||||
this._writerWeight,
|
||||
this._writerPriority,
|
||||
);
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to acquire a write lock immediately. Returns null if not available.
|
||||
*/
|
||||
tryAcquireWrite(): WriteLockHandle | undefined {
|
||||
const release = this._semaphore.tryAcquire(this._writerWeight);
|
||||
|
||||
if (release === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a callback with a read lock.
|
||||
*/
|
||||
async runWithReadLock<T>(callback: () => T | Promise<T>): Promise<T> {
|
||||
return this._semaphore.runExclusive(
|
||||
async () => await callback(),
|
||||
1,
|
||||
this._readerPriority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a callback with a write lock (exclusive access).
|
||||
*/
|
||||
async runWithWriteLock<T>(callback: () => T | Promise<T>): Promise<T> {
|
||||
return this._semaphore.runExclusive(
|
||||
async () => await callback(),
|
||||
this._writerWeight,
|
||||
this._writerPriority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until a read lock could be acquired (but doesn't acquire it).
|
||||
*/
|
||||
async waitForReadUnlock(): Promise<void> {
|
||||
return this._semaphore.waitForUnlock(1, this._readerPriority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until a write lock could be acquired (but doesn't acquire it).
|
||||
*/
|
||||
async waitForWriteUnlock(): Promise<void> {
|
||||
return this._semaphore.waitForUnlock(
|
||||
this._writerWeight,
|
||||
this._writerPriority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any locks are currently held.
|
||||
*/
|
||||
isLocked(): boolean {
|
||||
return this._semaphore.isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a write lock is currently held (exclusive access).
|
||||
*/
|
||||
isWriteLocked(): boolean {
|
||||
return this._semaphore.getValue() <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if only read locks are held (no write lock).
|
||||
*/
|
||||
isReadLocked(): boolean {
|
||||
const currentValue = this._semaphore.getValue();
|
||||
return currentValue < this._maxReaders && currentValue > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of available read slots.
|
||||
*/
|
||||
getAvailableReads(): number {
|
||||
return Math.max(0, this._semaphore.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current number of active readers (approximate).
|
||||
*/
|
||||
getActiveReaders(): number {
|
||||
const available = this._semaphore.getValue();
|
||||
if (available <= 0) {
|
||||
return 0; // Write lock is held
|
||||
}
|
||||
return this._maxReaders - available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all pending lock acquisitions.
|
||||
*/
|
||||
cancel(): void {
|
||||
this._semaphore.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum number of concurrent readers allowed.
|
||||
*/
|
||||
getMaxReaders(): number {
|
||||
return this._maxReaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of concurrent readers.
|
||||
* Note: This may affect currently waiting operations.
|
||||
*/
|
||||
setMaxReaders(maxReaders: number): void {
|
||||
if (maxReaders <= 0) {
|
||||
throw new Error("Max readers must be positive");
|
||||
}
|
||||
|
||||
this._maxReaders = maxReaders;
|
||||
this._writerWeight = maxReaders;
|
||||
this._semaphore.setValue(maxReaders);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SortedArray } from "./SortedArray";
|
||||
import { SortedArray } from "../datatype/SortedArray";
|
||||
|
||||
const E_CANCELED = new Error("Request canceled");
|
||||
// const E_INSUFFICIENT_RESOURCES = new Error("Insufficient resources");
|
||||
@@ -13,6 +13,8 @@ interface Waiter {
|
||||
resolve(): void;
|
||||
}
|
||||
|
||||
type Releaser = () => void;
|
||||
|
||||
export class Semaphore {
|
||||
private _value: number;
|
||||
private _cancelError: Error;
|
||||
@@ -27,7 +29,7 @@ export class Semaphore {
|
||||
this._cancelError = cancelError;
|
||||
}
|
||||
|
||||
acquire(weight = 1, priority = 0): Promise<[number, () => void]> {
|
||||
acquire(weight = 1, priority = 0): Promise<[number, Releaser]> {
|
||||
if (weight <= 0) {
|
||||
throw new Error(`invalid weight ${weight}: must be positive`);
|
||||
}
|
||||
@@ -43,13 +45,13 @@ export class Semaphore {
|
||||
});
|
||||
}
|
||||
|
||||
tryAcquire(weight = 1): (() => void) | null {
|
||||
tryAcquire(weight = 1): Releaser | undefined {
|
||||
if (weight <= 0) {
|
||||
throw new Error(`invalid weight ${weight}: must be positive`);
|
||||
}
|
||||
|
||||
if (weight > this._value || this._queue.toArray().length > 0) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._value -= weight;
|
||||
@@ -139,11 +141,10 @@ export class Semaphore {
|
||||
}
|
||||
|
||||
private _peek(): QueueEntry | undefined {
|
||||
const array = this._queue.toArray();
|
||||
return array.length > 0 ? array[0] : undefined;
|
||||
return this._queue.peek();
|
||||
}
|
||||
|
||||
private _newReleaser(weight: number): () => void {
|
||||
private _newReleaser(weight: number): Releaser {
|
||||
let called = false;
|
||||
return () => {
|
||||
if (called) return;
|
||||
21
src/lib/thirdparty/ts-result-es/LICENSE
vendored
Normal file
21
src/lib/thirdparty/ts-result-es/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 vultix
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2
src/lib/thirdparty/ts-result-es/index.ts
vendored
Normal file
2
src/lib/thirdparty/ts-result-es/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./result";
|
||||
export * from "./option";
|
||||
343
src/lib/thirdparty/ts-result-es/option.ts
vendored
Normal file
343
src/lib/thirdparty/ts-result-es/option.ts
vendored
Normal file
@@ -0,0 +1,343 @@
|
||||
import { toString } from "./utils";
|
||||
// import { Result, Ok, Err } from "./result";
|
||||
|
||||
interface BaseOption<T> extends Iterable<T> {
|
||||
/** `true` when the Option is Some */
|
||||
isSome(): this is SomeImpl<T>;
|
||||
|
||||
/** `true` when the Option is None */
|
||||
isNone(): this is None;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value, if exists. Throws an error if not.
|
||||
*
|
||||
* If you know you're dealing with `Some` and the compiler knows it too (because you tested
|
||||
* `isSome()` or `isNone()`) you should use `value` instead. While `Some`'s `expect()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* @param msg the message to throw if no Some value.
|
||||
*/
|
||||
expect(msg: string): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value.
|
||||
* Because this function may throw, its use is generally discouraged.
|
||||
* Instead, prefer to handle the `None` case explicitly.
|
||||
*
|
||||
* If you know you're dealing with `Some` and the compiler knows it too (because you tested
|
||||
* `isSome()` or `isNone()`) you should use `value` instead. While `Some`'s `unwrap()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* Throws if the value is `None`.
|
||||
*/
|
||||
unwrap(): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value or a provided default.
|
||||
*
|
||||
* (This is the `unwrap_or` in rust)
|
||||
*/
|
||||
unwrapOr<T2>(val: T2): T | T2;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value or computes a value with a provided function.
|
||||
*
|
||||
* The function is called at most one time, only if needed.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* Some('OK').unwrapOrElse(
|
||||
* () => { console.log('Called'); return 'UGH'; }
|
||||
* ) // => 'OK', nothing printed
|
||||
*
|
||||
* None.unwrapOrElse(() => 'UGH') // => 'UGH'
|
||||
* ```
|
||||
*/
|
||||
unwrapOrElse<T2>(f: () => T2): T | T2;
|
||||
|
||||
/**
|
||||
* Calls `mapper` if the Option is `Some`, otherwise returns `None`.
|
||||
* This function can be used for control flow based on `Option` values.
|
||||
*/
|
||||
andThen<T2>(mapper: (val: T) => Option<T2>): Option<T2>;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to `Option<U>` by applying a function to a contained `Some` value,
|
||||
* leaving a `None` value untouched.
|
||||
*
|
||||
* This function can be used to compose the Options of two functions.
|
||||
*/
|
||||
map<U>(mapper: (val: T) => U): Option<U>;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to `Option<U>` by either converting `T` to `U` using `mapper` (in case
|
||||
* of `Some`) or using the `default_` value (in case of `None`).
|
||||
*
|
||||
* If `default` is a result of a function call consider using `mapOrElse()` instead, it will
|
||||
* only evaluate the function when needed.
|
||||
*/
|
||||
mapOr<U>(default_: U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to `Option<U>` by either converting `T` to `U` using `mapper` (in case
|
||||
* of `Some`) or producing a default value using the `default` function (in case of `None`).
|
||||
*/
|
||||
mapOrElse<U>(default_: () => U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Returns `Some()` if we have a value, otherwise returns `other`.
|
||||
*
|
||||
* `other` is evaluated eagerly. If `other` is a result of a function
|
||||
* call try `orElse()` instead – it evaluates the parameter lazily.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Some(1).or(Some(2)) // => Some(1)
|
||||
* None.or(Some(2)) // => Some(2)
|
||||
*/
|
||||
or(other: Option<T>): Option<T>;
|
||||
|
||||
/**
|
||||
* Returns `Some()` if we have a value, otherwise returns the result
|
||||
* of calling `other()`.
|
||||
*
|
||||
* `other()` is called *only* when needed.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Some(1).orElse(() => Some(2)) // => Some(1)
|
||||
* None.orElse(() => Some(2)) // => Some(2)
|
||||
*/
|
||||
orElse(other: () => Option<T>): Option<T>;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to a `Result<T, E>`.
|
||||
*/
|
||||
// toResult<E>(error: E): Result<T, E>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the None value
|
||||
*/
|
||||
class NoneImpl implements BaseOption<never> {
|
||||
isSome(): this is SomeImpl<never> {
|
||||
return false;
|
||||
}
|
||||
|
||||
isNone(): this is NoneImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<never, never, unknown> {
|
||||
return {
|
||||
next(): IteratorResult<never, never> {
|
||||
return { done: true, value: undefined! };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
unwrapOr<T2>(val: T2): T2 {
|
||||
return val;
|
||||
}
|
||||
|
||||
unwrapOrElse<T2>(f: () => T2): T2 {
|
||||
return f();
|
||||
}
|
||||
|
||||
expect(msg: string): never {
|
||||
throw new Error(`${msg}`);
|
||||
}
|
||||
|
||||
unwrap(): never {
|
||||
throw new Error(`Tried to unwrap None`);
|
||||
}
|
||||
|
||||
map(_mapper: unknown): None {
|
||||
return this;
|
||||
}
|
||||
|
||||
mapOr<T2>(default_: T2, _mapper: unknown): T2 {
|
||||
return default_;
|
||||
}
|
||||
|
||||
mapOrElse<U>(default_: () => U, _mapper: unknown): U {
|
||||
return default_();
|
||||
}
|
||||
|
||||
or<T>(other: Option<T>): Option<T> {
|
||||
return other;
|
||||
}
|
||||
|
||||
orElse<T>(other: () => Option<T>): Option<T> {
|
||||
return other();
|
||||
}
|
||||
|
||||
andThen(_op: unknown): None {
|
||||
return this;
|
||||
}
|
||||
|
||||
// toResult<E>(error: E): Err<E> {
|
||||
// return Err(error);
|
||||
// }
|
||||
|
||||
toString(): string {
|
||||
return "None";
|
||||
}
|
||||
}
|
||||
|
||||
// Export None as a singleton, then freeze it so it can't be modified
|
||||
export const None = new NoneImpl();
|
||||
export type None = NoneImpl;
|
||||
|
||||
/**
|
||||
* Contains the success value
|
||||
*/
|
||||
class SomeImpl<T> implements BaseOption<T> {
|
||||
static readonly EMPTY = new SomeImpl<void>(undefined);
|
||||
|
||||
isSome(): this is SomeImpl<T> {
|
||||
return true;
|
||||
}
|
||||
|
||||
isNone(): this is NoneImpl {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly value!: T;
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
return [this.value][Symbol.iterator]();
|
||||
}
|
||||
|
||||
constructor(val: T) {
|
||||
if (!(this instanceof SomeImpl)) {
|
||||
return new SomeImpl(val);
|
||||
}
|
||||
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
unwrapOr(_val: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapOrElse(_f: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
expect(_msg: string): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
map<T2>(mapper: (val: T) => T2): Some<T2> {
|
||||
return new Some(mapper(this.value));
|
||||
}
|
||||
|
||||
mapOr<T2>(_default_: T2, mapper: (val: T) => T2): T2 {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
mapOrElse<U>(_default_: () => U, mapper: (val: T) => U): U {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
or(_other: Option<T>): Option<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
orElse(_other: () => Option<T>): Option<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
andThen<T2>(mapper: (val: T) => Option<T2>): Option<T2> {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
// toResult<E>(_error: E): Ok<T> {
|
||||
// return Ok(this.value);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value, but never throws.
|
||||
* Unlike `unwrap()`, this method doesn't throw and is only callable on an Some<T>
|
||||
*
|
||||
* Therefore, it can be used instead of `unwrap()` as a maintainability safeguard
|
||||
* that will fail to compile if the type of the Option is later changed to a None that can actually occur.
|
||||
*
|
||||
* (this is the `into_Some()` in rust)
|
||||
*/
|
||||
safeUnwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Some(${toString(this.value)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows Some to be callable - possible because of the es5 compilation target
|
||||
// export const Some = SomeImpl as typeof SomeImpl & (<T>(val: T) => SomeImpl<T>);
|
||||
export const Some = SomeImpl;
|
||||
export type Some<T> = SomeImpl<T>;
|
||||
|
||||
export type Option<T> = Some<T> | None;
|
||||
|
||||
export type OptionSomeType<T extends Option<unknown>> =
|
||||
T extends Some<infer U> ? U : never;
|
||||
|
||||
export type OptionSomeTypes<T extends Option<unknown>[]> = {
|
||||
[key in keyof T]: T[key] extends Option<unknown>
|
||||
? OptionSomeType<T[key]>
|
||||
: never;
|
||||
};
|
||||
|
||||
export namespace Option {
|
||||
/**
|
||||
* Parse a set of `Option`s, returning an array of all `Some` values.
|
||||
* Short circuits with the first `None` found, if any
|
||||
*/
|
||||
export function all<T extends Option<any>[]>(
|
||||
...options: T
|
||||
): Option<OptionSomeTypes<T>> {
|
||||
const someOption: unknown[] = [];
|
||||
for (let option of options) {
|
||||
if (option.isSome()) {
|
||||
someOption.push(option.value);
|
||||
} else {
|
||||
return option as None;
|
||||
}
|
||||
}
|
||||
|
||||
return new Some(someOption as OptionSomeTypes<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a set of `Option`s, short-circuits when an input value is `Some`.
|
||||
* If no `Some` is found, returns `None`.
|
||||
*/
|
||||
export function any<T extends Option<any>[]>(
|
||||
...options: T
|
||||
): Option<OptionSomeTypes<T>[number]> {
|
||||
// short-circuits
|
||||
for (const option of options) {
|
||||
if (option.isSome()) {
|
||||
return option as Some<OptionSomeTypes<T>[number]>;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// it must be None
|
||||
return None;
|
||||
}
|
||||
|
||||
export function isOption<T = any>(value: unknown): value is Option<T> {
|
||||
return value instanceof Some || value === None;
|
||||
}
|
||||
}
|
||||
536
src/lib/thirdparty/ts-result-es/result.ts
vendored
Normal file
536
src/lib/thirdparty/ts-result-es/result.ts
vendored
Normal file
@@ -0,0 +1,536 @@
|
||||
import { toString } from "./utils";
|
||||
// import { Option, None, Some } from "./option";
|
||||
|
||||
/*
|
||||
* Missing Rust Result type methods:
|
||||
* pub fn contains<U>(&self, x: &U) -> bool
|
||||
* pub fn contains_err<F>(&self, f: &F) -> bool
|
||||
* pub fn and<U>(self, res: Result<U, E>) -> Result<U, E>
|
||||
* pub fn expect_err(self, msg: &str) -> E
|
||||
* pub fn unwrap_or_default(self) -> T
|
||||
*/
|
||||
interface BaseResult<T, E> extends Iterable<T> {
|
||||
/** `true` when the result is Ok */
|
||||
isOk(): this is OkImpl<T>;
|
||||
|
||||
/** `true` when the result is Err */
|
||||
isErr(): this is ErrImpl<E>;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value, if exists. Throws an error if not.
|
||||
*
|
||||
* The thrown error's
|
||||
* [`cause'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
* is set to value contained in `Err`.
|
||||
*
|
||||
* If you know you're dealing with `Ok` and the compiler knows it too (because you tested
|
||||
* `isOk()` or `isErr()`) you should use `value` instead. While `Ok`'s `expect()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* @param msg the message to throw if no Ok value.
|
||||
*/
|
||||
expect(msg: string): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Err` value, if exists. Throws an error if not.
|
||||
* @param msg the message to throw if no Err value.
|
||||
*/
|
||||
expectErr(msg: string): E;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value.
|
||||
* Because this function may throw, its use is generally discouraged.
|
||||
* Instead, prefer to handle the `Err` case explicitly.
|
||||
*
|
||||
* If you know you're dealing with `Ok` and the compiler knows it too (because you tested
|
||||
* `isOk()` or `isErr()`) you should use `value` instead. While `Ok`'s `unwrap()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* Throws if the value is an `Err`, with a message provided by the `Err`'s value and
|
||||
* [`cause'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
* set to the value.
|
||||
*/
|
||||
unwrap(): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Err` value.
|
||||
* Because this function may throw, its use is generally discouraged.
|
||||
* Instead, prefer to handle the `Ok` case explicitly and access the `error` property
|
||||
* directly.
|
||||
*
|
||||
* Throws if the value is an `Ok`, with a message provided by the `Ok`'s value and
|
||||
* [`cause'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
* set to the value.
|
||||
*/
|
||||
unwrapErr(): E;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value or a provided default.
|
||||
*
|
||||
* @see unwrapOr
|
||||
* @deprecated in favor of unwrapOr
|
||||
*/
|
||||
else<T2>(val: T2): T | T2;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value or a provided default.
|
||||
*
|
||||
* (This is the `unwrap_or` in rust)
|
||||
*/
|
||||
unwrapOr<T2>(val: T2): T | T2;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value or computes a value with a provided function.
|
||||
*
|
||||
* The function is called at most one time, only if needed.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* Ok('OK').unwrapOrElse(
|
||||
* (error) => { console.log(`Called, got ${error}`); return 'UGH'; }
|
||||
* ) // => 'OK', nothing printed
|
||||
*
|
||||
* Err('A03B').unwrapOrElse((error) => `UGH, got ${error}`) // => 'UGH, got A03B'
|
||||
* ```
|
||||
*/
|
||||
unwrapOrElse<T2>(f: (error: E) => T2): T | T2;
|
||||
|
||||
/**
|
||||
* Calls `mapper` if the result is `Ok`, otherwise returns the `Err` value of self.
|
||||
* This function can be used for control flow based on `Result` values.
|
||||
*/
|
||||
andThen<T2, E2>(mapper: (val: T) => Result<T2, E2>): Result<T2, E | E2>;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<U, E>` by applying a function to a contained `Ok` value,
|
||||
* leaving an `Err` value untouched.
|
||||
*
|
||||
* This function can be used to compose the results of two functions.
|
||||
*/
|
||||
map<U>(mapper: (val: T) => U): Result<U, E>;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<T, F>` by applying a function to a contained `Err` value,
|
||||
* leaving an `Ok` value untouched.
|
||||
*
|
||||
* This function can be used to pass through a successful result while handling an error.
|
||||
*/
|
||||
mapErr<F>(mapper: (val: E) => F): Result<T, F>;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<U, E>` by either converting `T` to `U` using `mapper`
|
||||
* (in case of `Ok`) or using the `default_` value (in case of `Err`).
|
||||
*
|
||||
* If `default` is a result of a function call consider using `mapOrElse` instead, it will
|
||||
* only evaluate the function when needed.
|
||||
*/
|
||||
mapOr<U>(default_: U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<U, E>` by either converting `T` to `U` using `mapper`
|
||||
* (in case of `Ok`) or producing a default value using the `default` function (in case of
|
||||
* `Err`).
|
||||
*/
|
||||
mapOrElse<U>(default_: (error: E) => U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Returns `Ok()` if we have a value, otherwise returns `other`.
|
||||
*
|
||||
* `other` is evaluated eagerly. If `other` is a result of a function
|
||||
* call try `orElse()` instead – it evaluates the parameter lazily.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Ok(1).or(Ok(2)) // => Ok(1)
|
||||
* Err('error here').or(Ok(2)) // => Ok(2)
|
||||
*/
|
||||
or<E2>(other: Result<T, E2>): Result<T, E2>;
|
||||
|
||||
/**
|
||||
* Returns `Ok()` if we have a value, otherwise returns the result
|
||||
* of calling `other()`.
|
||||
*
|
||||
* `other()` is called *only* when needed and is passed the error value in a parameter.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Ok(1).orElse(() => Ok(2)) // => Ok(1)
|
||||
* Err('error').orElse(() => Ok(2)) // => Ok(2)
|
||||
*/
|
||||
orElse<T2, E2>(other: (error: E) => Result<T2, E2>): Result<T | T2, E2>;
|
||||
|
||||
/**
|
||||
* Converts from `Result<T, E>` to `Option<T>`, discarding the error if any
|
||||
*
|
||||
* Similar to rust's `ok` method
|
||||
*/
|
||||
// toOption(): Option<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the error value
|
||||
*/
|
||||
export class ErrImpl<E> implements BaseResult<never, E> {
|
||||
/** An empty Err */
|
||||
static readonly EMPTY = new ErrImpl<void>(undefined);
|
||||
|
||||
isOk(): this is OkImpl<never> {
|
||||
return false;
|
||||
}
|
||||
|
||||
isErr(): this is ErrImpl<E> {
|
||||
return true;
|
||||
}
|
||||
|
||||
readonly error!: E;
|
||||
|
||||
private readonly _stack!: string;
|
||||
|
||||
[Symbol.iterator](): Iterator<never, never, unknown> {
|
||||
return {
|
||||
next(): IteratorResult<never, never> {
|
||||
return { done: true, value: undefined! };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
constructor(val: E) {
|
||||
if (!(this instanceof ErrImpl)) {
|
||||
return new ErrImpl(val);
|
||||
}
|
||||
|
||||
this.error = val;
|
||||
|
||||
const stackLines = new Error().stack!.split("\n").slice(2);
|
||||
if (
|
||||
stackLines !== undefined &&
|
||||
stackLines.length > 0 &&
|
||||
stackLines[0].includes("ErrImpl")
|
||||
) {
|
||||
stackLines.shift();
|
||||
}
|
||||
|
||||
this._stack = stackLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated in favor of unwrapOr
|
||||
* @see unwrapOr
|
||||
*/
|
||||
else<T2>(val: T2): T2 {
|
||||
return val;
|
||||
}
|
||||
|
||||
unwrapOr<T2>(val: T2): T2 {
|
||||
return val;
|
||||
}
|
||||
|
||||
unwrapOrElse<T2>(f: (error: E) => T2): T2 {
|
||||
return f(this.error);
|
||||
}
|
||||
|
||||
expect(msg: string): never {
|
||||
// The cause casting required because of the current TS definition being overly restrictive
|
||||
// (the definition says it has to be an Error while it can be anything).
|
||||
// See https://github.com/microsoft/TypeScript/issues/45167
|
||||
throw new Error(`${msg} - Error: ${toString(this.error)}\n${this._stack}`, {
|
||||
cause: this.error as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
expectErr(_msg: string): E {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
unwrap(): never {
|
||||
// The cause casting required because of the current TS definition being overly restrictive
|
||||
// (the definition says it has to be an Error while it can be anything).
|
||||
// See https://github.com/microsoft/TypeScript/issues/45167
|
||||
throw new Error(
|
||||
`Tried to unwrap Error: ${toString(this.error)}\n${this._stack}`,
|
||||
{ cause: this.error as unknown },
|
||||
);
|
||||
}
|
||||
|
||||
unwrapErr(): E {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
map(_mapper: unknown): Err<E> {
|
||||
return this;
|
||||
}
|
||||
|
||||
andThen<T2, E2>(_op: (val: never) => Result<T2, E2>): Result<T2, E | E2> {
|
||||
return this;
|
||||
}
|
||||
|
||||
mapErr<E2>(mapper: (err: E) => E2): Err<E2> {
|
||||
return new Err(mapper(this.error));
|
||||
}
|
||||
|
||||
mapOr<U>(default_: U, _mapper: unknown): U {
|
||||
return default_;
|
||||
}
|
||||
|
||||
mapOrElse<U>(default_: (error: E) => U, _mapper: unknown): U {
|
||||
return default_(this.error);
|
||||
}
|
||||
|
||||
or<T>(other: Ok<T>): Result<T, never>;
|
||||
or<R extends Result<unknown, unknown>>(other: R): R;
|
||||
or<T, E2>(other: Result<T, E2>): Result<T, E2> {
|
||||
return other;
|
||||
}
|
||||
|
||||
orElse<T2, E2>(other: (error: E) => Result<T2, E2>): Result<T2, E2> {
|
||||
return other(this.error);
|
||||
}
|
||||
|
||||
// toOption(): Option<never> {
|
||||
// return None;
|
||||
// }
|
||||
|
||||
toString(): string {
|
||||
return `Err(${toString(this.error)})`;
|
||||
}
|
||||
|
||||
get stack(): string | undefined {
|
||||
return `${this.toString()}\n${this._stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows Err to be callable - possible because of the es5 compilation target
|
||||
// export const Err = ErrImpl as typeof ErrImpl & (<E>(err: E) => Err<E>);
|
||||
export const Err = ErrImpl;
|
||||
export type Err<E> = ErrImpl<E>;
|
||||
|
||||
/**
|
||||
* Contains the success value
|
||||
*/
|
||||
export class OkImpl<T> implements BaseResult<T, never> {
|
||||
static readonly EMPTY = new OkImpl<void>(undefined);
|
||||
|
||||
isOk(): this is OkImpl<T> {
|
||||
return true;
|
||||
}
|
||||
|
||||
isErr(): this is ErrImpl<never> {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly value!: T;
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
return [this.value][Symbol.iterator]();
|
||||
}
|
||||
|
||||
constructor(val: T) {
|
||||
if (!(this instanceof OkImpl)) {
|
||||
return new OkImpl(val);
|
||||
}
|
||||
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see unwrapOr
|
||||
* @deprecated in favor of unwrapOr
|
||||
*/
|
||||
else(_val: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapOr(_val: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapOrElse(_f: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
expect(_msg: string): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
expectErr(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapErr(): never {
|
||||
// The cause casting required because of the current TS definition being overly restrictive
|
||||
// (the definition says it has to be an Error while it can be anything).
|
||||
// See https://github.com/microsoft/TypeScript/issues/45167
|
||||
throw new Error(`Tried to unwrap Ok: ${toString(this.value)}`, {
|
||||
cause: this.value as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
map<T2>(mapper: (val: T) => T2): Ok<T2> {
|
||||
return new Ok(mapper(this.value));
|
||||
}
|
||||
|
||||
andThen<T2, E2>(mapper: (val: T) => Result<T2, E2>): Result<T2, E2> {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
mapErr(_mapper: unknown): Ok<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
mapOr<U>(_default_: U, mapper: (val: T) => U): U {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
mapOrElse<U>(_default_: (_error: never) => U, mapper: (val: T) => U): U {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
or(_other: Result<T, unknown>): Ok<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
orElse<T2, E2>(_other: (error: never) => Result<T2, E2>): Result<T, never> {
|
||||
return this;
|
||||
}
|
||||
|
||||
// toOption(): Option<T> {
|
||||
// return Some(this.value);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value, but never throws.
|
||||
* Unlike `unwrap()`, this method doesn't throw and is only callable on an Ok<T>
|
||||
*
|
||||
* Therefore, it can be used instead of `unwrap()` as a maintainability safeguard
|
||||
* that will fail to compile if the error type of the Result is later changed to an error that can actually occur.
|
||||
*
|
||||
* (this is the `into_ok()` in rust)
|
||||
*/
|
||||
safeUnwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Ok(${toString(this.value)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows Ok to be callable - possible because of the es5 compilation target
|
||||
// export const Ok = OkImpl as typeof OkImpl & (<T>(val: T) => Ok<T>);
|
||||
export const Ok = OkImpl;
|
||||
export type Ok<T> = OkImpl<T>;
|
||||
|
||||
export type Result<T, E = Error> = Ok<T> | Err<E>;
|
||||
|
||||
export type ResultOkType<T extends Result<unknown, unknown>> =
|
||||
T extends Ok<infer U> ? U : never;
|
||||
export type ResultErrType<T> = T extends Err<infer U> ? U : never;
|
||||
|
||||
export type ResultOkTypes<T extends Result<unknown, unknown>[]> = {
|
||||
[key in keyof T]: T[key] extends Result<infer _U, unknown>
|
||||
? ResultOkType<T[key]>
|
||||
: never;
|
||||
};
|
||||
export type ResultErrTypes<T extends Result<unknown, unknown>[]> = {
|
||||
[key in keyof T]: T[key] extends Result<infer _U, unknown>
|
||||
? ResultErrType<T[key]>
|
||||
: never;
|
||||
};
|
||||
|
||||
export namespace Result {
|
||||
/**
|
||||
* Parse a set of `Result`s, returning an array of all `Ok` values.
|
||||
* Short circuits with the first `Err` found, if any
|
||||
*/
|
||||
export function all<const T extends Result<any, any>[]>(
|
||||
results: T,
|
||||
): Result<ResultOkTypes<T>, ResultErrTypes<T>[number]> {
|
||||
const okResult: unknown[] = [];
|
||||
for (let result of results) {
|
||||
if (result.isOk()) {
|
||||
okResult.push(result.value);
|
||||
} else {
|
||||
return result as Err<ResultErrTypes<T>[number]>;
|
||||
}
|
||||
}
|
||||
|
||||
return new Ok(okResult as ResultOkTypes<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a set of `Result`s, short-circuits when an input value is `Ok`.
|
||||
* If no `Ok` is found, returns an `Err` containing the collected error values
|
||||
*/
|
||||
export function any<const T extends Result<any, any>[]>(
|
||||
results: T,
|
||||
): Result<ResultOkTypes<T>[number], ResultErrTypes<T>> {
|
||||
const errResult: unknown[] = [];
|
||||
|
||||
// short-circuits
|
||||
for (const result of results) {
|
||||
if (result.isOk()) {
|
||||
return result as Ok<ResultOkTypes<T>[number]>;
|
||||
} else {
|
||||
errResult.push(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// it must be a Err
|
||||
return new Err(errResult as ResultErrTypes<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an operation that may throw an Error (`try-catch` style) into checked exception style
|
||||
* @param op The operation function
|
||||
*/
|
||||
export function wrap<T, E = unknown>(op: () => T): Result<T, E> {
|
||||
try {
|
||||
return new Ok(op());
|
||||
} catch (e) {
|
||||
return new Err<E>(e as E);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an async operation that may throw an Error (`try-catch` style) into checked exception style
|
||||
* @param op The operation function
|
||||
*/
|
||||
export function wrapAsync<T, E = unknown>(
|
||||
op: () => Promise<T>,
|
||||
): Promise<Result<T, E>> {
|
||||
try {
|
||||
return op()
|
||||
.then((val) => new Ok(val))
|
||||
.catch((e) => new Err(e));
|
||||
} catch (e) {
|
||||
return Promise.resolve(new Err(e as E));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions a set of results, separating the `Ok` and `Err` values.
|
||||
*/
|
||||
export function partition<T extends Result<any, any>[]>(
|
||||
results: T,
|
||||
): [ResultOkTypes<T>, ResultErrTypes<T>] {
|
||||
return results.reduce(
|
||||
([oks, errors], v) =>
|
||||
v.isOk()
|
||||
? [[...oks, v.value] as ResultOkTypes<T>, errors]
|
||||
: [oks, [...errors, v.error] as ResultErrTypes<T>],
|
||||
[[], []] as [ResultOkTypes<T>, ResultErrTypes<T>],
|
||||
);
|
||||
}
|
||||
|
||||
export function isResult<T = any, E = any>(
|
||||
val: unknown,
|
||||
): val is Result<T, E> {
|
||||
return val instanceof Err || val instanceof Ok;
|
||||
}
|
||||
}
|
||||
11
src/lib/thirdparty/ts-result-es/utils.ts
vendored
Normal file
11
src/lib/thirdparty/ts-result-es/utils.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export function toString(val: unknown): string {
|
||||
let value = String(val);
|
||||
if (value === "[object Object]") {
|
||||
try {
|
||||
value = textutils.serialize(val as object);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
import { testTimeBasedRotation } from "./testCCLog";
|
||||
import { testSortedArray } from "./testSortedArray";
|
||||
import { testSemaphore } from "./testSemaphore";
|
||||
import { testReadWriteLock } from "./testReadWriteLock";
|
||||
|
||||
testTimeBasedRotation();
|
||||
testSortedArray();
|
||||
testSemaphore();
|
||||
testSemaphore()
|
||||
.then(() => {
|
||||
print("Semaphore test completed");
|
||||
return testReadWriteLock();
|
||||
})
|
||||
.catch((error) => {
|
||||
print(`Semaphore test failed: ${error}`);
|
||||
});
|
||||
testReadWriteLock()
|
||||
.then(() => {
|
||||
print("ReadWriteLock test completed");
|
||||
})
|
||||
.catch((error) => {
|
||||
print(`Test failed: ${error}`);
|
||||
});
|
||||
|
||||
160
src/test/testReadWriteLock.ts
Normal file
160
src/test/testReadWriteLock.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ReadWriteLock } from "../lib/ReadWriteLock";
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testReadWriteLock() {
|
||||
print("Testing ReadWriteLock...");
|
||||
|
||||
async function testMultipleReaders() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const reader1 = await lock.acquireRead();
|
||||
const reader2 = await lock.acquireRead();
|
||||
assert(
|
||||
lock.getActiveReaders() === 2,
|
||||
"allows multiple readers: active readers should be 2",
|
||||
);
|
||||
reader1.release();
|
||||
assert(
|
||||
lock.getActiveReaders() === 1,
|
||||
"allows multiple readers: active readers should be 1",
|
||||
);
|
||||
reader2.release();
|
||||
assert(
|
||||
lock.getActiveReaders() === 0,
|
||||
"allows multiple readers: active readers should be 0",
|
||||
);
|
||||
print("testMultipleReaders passed");
|
||||
}
|
||||
|
||||
async function testSingleWriter() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const writer = await lock.acquireWrite();
|
||||
assert(
|
||||
lock.isWriteLocked() === true,
|
||||
"allows only one writer: isWriteLocked should be true",
|
||||
);
|
||||
writer.release();
|
||||
assert(
|
||||
lock.isWriteLocked() === false,
|
||||
"allows only one writer: isWriteLocked should be false",
|
||||
);
|
||||
print("testSingleWriter passed");
|
||||
}
|
||||
|
||||
async function testWriterBlocksReaders() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const writer = await lock.acquireWrite();
|
||||
let readerAcquired = false;
|
||||
const _ = lock.acquireRead().then(() => {
|
||||
readerAcquired = true;
|
||||
});
|
||||
assert(
|
||||
!readerAcquired,
|
||||
"blocks readers when a writer has the lock: reader should not be acquired yet",
|
||||
);
|
||||
writer.release();
|
||||
assert(
|
||||
readerAcquired,
|
||||
"blocks readers when a writer has the lock: reader should be acquired now",
|
||||
);
|
||||
print("testWriterBlocksReaders passed");
|
||||
}
|
||||
|
||||
async function testReaderBlocksWriters() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const reader = await lock.acquireRead();
|
||||
let writerAcquired = false;
|
||||
const _ = lock.acquireWrite().then(() => {
|
||||
writerAcquired = true;
|
||||
});
|
||||
assert(
|
||||
!writerAcquired,
|
||||
"blocks writers when a reader has the lock: writer should not be acquired yet",
|
||||
);
|
||||
reader.release();
|
||||
assert(
|
||||
writerAcquired,
|
||||
"blocks writers when a reader has the lock: writer should be acquired now",
|
||||
);
|
||||
print("testReaderBlocksWriters passed");
|
||||
}
|
||||
|
||||
function testTryAcquireRead() {
|
||||
const lock = new ReadWriteLock(1);
|
||||
const reader1 = lock.tryAcquireRead();
|
||||
assert(
|
||||
reader1 !== null,
|
||||
"tryAcquireRead works: first reader should be acquired",
|
||||
);
|
||||
const reader2 = lock.tryAcquireRead();
|
||||
assert(
|
||||
reader2 === null,
|
||||
"tryAcquireRead works: second reader should not be acquired",
|
||||
);
|
||||
reader1!.release();
|
||||
const reader3 = lock.tryAcquireRead();
|
||||
assert(
|
||||
reader3 !== null,
|
||||
"tryAcquireRead works: third reader should be acquired",
|
||||
);
|
||||
reader3!.release();
|
||||
print("testTryAcquireRead passed");
|
||||
}
|
||||
|
||||
function testTryAcquireWrite() {
|
||||
const lock = new ReadWriteLock();
|
||||
const writer1 = lock.tryAcquireWrite();
|
||||
assert(
|
||||
writer1 !== null,
|
||||
"tryAcquireWrite works: first writer should be acquired",
|
||||
);
|
||||
const writer2 = lock.tryAcquireWrite();
|
||||
assert(
|
||||
writer2 === null,
|
||||
"tryAcquireWrite works: second writer should not be acquired",
|
||||
);
|
||||
writer1!.release();
|
||||
const writer3 = lock.tryAcquireWrite();
|
||||
assert(
|
||||
writer3 !== null,
|
||||
"tryAcquireWrite works: third writer should be acquired",
|
||||
);
|
||||
writer3!.release();
|
||||
print("testTryAcquireWrite passed");
|
||||
}
|
||||
|
||||
async function testRunWithReadLock() {
|
||||
const lock = new ReadWriteLock();
|
||||
let value = 0;
|
||||
await lock.runWithReadLock(() => {
|
||||
value = 1;
|
||||
});
|
||||
assert(value === 1, "runWithReadLock works: value should be 1");
|
||||
print("testRunWithReadLock passed");
|
||||
}
|
||||
|
||||
async function testRunWithWriteLock() {
|
||||
const lock = new ReadWriteLock();
|
||||
let value = 0;
|
||||
await lock.runWithWriteLock(() => {
|
||||
value = 1;
|
||||
});
|
||||
assert(value === 1, "runWithWriteLock works: value should be 1");
|
||||
print("testRunWithWriteLock passed");
|
||||
}
|
||||
|
||||
await testMultipleReaders();
|
||||
await testSingleWriter();
|
||||
await testWriterBlocksReaders();
|
||||
await testReaderBlocksWriters();
|
||||
testTryAcquireRead();
|
||||
testTryAcquireWrite();
|
||||
await testRunWithReadLock();
|
||||
await testRunWithWriteLock();
|
||||
|
||||
print("ReadWriteLock tests passed!");
|
||||
}
|
||||
@@ -62,30 +62,27 @@ async function testQueueing() {
|
||||
const [, release1] = await s.acquire();
|
||||
events.push("acquired1");
|
||||
|
||||
// These two will be queued
|
||||
await s.acquire().then(([, release]) => {
|
||||
// These two will be queued. Store their promises.
|
||||
const p2 = s.acquire().then(([, release]) => {
|
||||
events.push("acquired2");
|
||||
sleep(0.1);
|
||||
release();
|
||||
events.push("released2");
|
||||
});
|
||||
|
||||
await s.acquire().then(([, release]) => {
|
||||
events.push("acquired3");
|
||||
release();
|
||||
events.push("released3");
|
||||
});
|
||||
|
||||
// Give some time for promises to queue
|
||||
sleep(0.1);
|
||||
const p3 = s.acquire().then(([, release]) => {
|
||||
events.push("acquired3");
|
||||
events.push("released3");
|
||||
release();
|
||||
});
|
||||
|
||||
assert(events.length === 1, "Only first acquire should have completed");
|
||||
|
||||
// Release the first lock, allowing the queue to proceed
|
||||
release1();
|
||||
events.push("released1");
|
||||
release1();
|
||||
|
||||
// Wait for all promises to finish
|
||||
sleep(0.5);
|
||||
await Promise.all([p2, p3]);
|
||||
|
||||
const expected = [
|
||||
"acquired1",
|
||||
@@ -111,20 +108,20 @@ async function testPriority() {
|
||||
events.push("acquired_main");
|
||||
|
||||
// Queue with low priority
|
||||
await s.acquire(1, 10).then(([, release]) => {
|
||||
const p1 = s.acquire(1, 10).then(([, release]) => {
|
||||
events.push("acquired_low_prio");
|
||||
release();
|
||||
});
|
||||
|
||||
// Queue with high priority
|
||||
await s.acquire(1, 1).then(([, release]) => {
|
||||
const p2 = s.acquire(1, 1).then(([, release]) => {
|
||||
events.push("acquired_high_prio");
|
||||
release();
|
||||
});
|
||||
|
||||
sleep(0.1);
|
||||
release1();
|
||||
sleep(0.1);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
const expected = ["acquired_main", "acquired_high_prio", "acquired_low_prio"];
|
||||
assert(
|
||||
@@ -142,16 +139,15 @@ async function testWaitForUnlock() {
|
||||
const [, release] = await s.acquire();
|
||||
assert(s.isLocked(), "Semaphore should be locked");
|
||||
|
||||
await s.waitForUnlock().then(() => {
|
||||
const p1 = s.waitForUnlock().then(() => {
|
||||
waited = true;
|
||||
assert(!s.isLocked(), "Should be unlocked when wait is over");
|
||||
});
|
||||
|
||||
sleep(0.1);
|
||||
assert(!waited, "waitForUnlock should not resolve yet");
|
||||
|
||||
release();
|
||||
sleep(0.1);
|
||||
await Promise.all([p1]);
|
||||
assert(waited, "waitForUnlock should have resolved");
|
||||
print(" Test passed: testWaitForUnlock");
|
||||
}
|
||||
@@ -174,9 +170,7 @@ async function testCancel() {
|
||||
},
|
||||
);
|
||||
|
||||
sleep(0.1);
|
||||
s.cancel();
|
||||
sleep(0.1);
|
||||
|
||||
assert(rejected, "pending acquire should have been rejected");
|
||||
assert(s.getValue() === 0, "cancel should not affect current lock");
|
||||
|
||||
9
targets/tsconfig.accesscontrol.json
Normal file
9
targets/tsconfig.accesscontrol.json
Normal 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"]
|
||||
}
|
||||
9
targets/tsconfig.autocraft.json
Normal file
9
targets/tsconfig.autocraft.json
Normal 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"]
|
||||
}
|
||||
9
targets/tsconfig.cliExample.json
Normal file
9
targets/tsconfig.cliExample.json
Normal 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"]
|
||||
}
|
||||
9
targets/tsconfig.test.json
Normal file
9
targets/tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
9
targets/tsconfig.tuiExample.json
Normal file
9
targets/tsconfig.tuiExample.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
4
types/advanced-peripherals/shared.d.ts
vendored
4
types/advanced-peripherals/shared.d.ts
vendored
@@ -6,7 +6,7 @@ declare interface BlockItemDetailData {
|
||||
}
|
||||
|
||||
declare interface BlockDetailData {
|
||||
Items: Record<number, BlockItemDetailData>;
|
||||
Items: Record<string, BlockItemDetailData>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +30,7 @@ declare type MinecraftColor =
|
||||
| "light_purple"
|
||||
| "yellow"
|
||||
| "white"
|
||||
| "reset"; // RGB color in #RRGGBB format
|
||||
| `#${string}`;
|
||||
|
||||
declare type MinecraftFont =
|
||||
| "minecraft:default"
|
||||
|
||||
20
types/craftos/index.d.ts
vendored
20
types/craftos/index.d.ts
vendored
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user