Compare commits

..

27 Commits

Author SHA1 Message Date
SikongJueluo
66b46c6d70 refactor(logging): migrate from CCLog to structured logger 2025-11-21 21:15:52 +08:00
SikongJueluo
de97fb4858 refactor(logger): simplify logger configuration and improve file rotation 2025-11-21 21:15:06 +08:00
SikongJueluo
0612477325 feat(logging): add auto-cleanup functionality to FileStream 2025-11-21 15:58:42 +08:00
SikongJueluo
82a9fec46d refactor(autocraft): improve peripheral initialization with retry logic 2025-11-21 14:56:56 +08:00
SikongJueluo
94f0de4c90 docs: update README and ccStructLog documentation 2025-11-21 14:56:25 +08:00
SikongJueluo
cf7ddefc2e refactor(logging): migrate from CCLog to structured Logger 2025-11-21 14:23:10 +08:00
SikongJueluo
3287661318 refactor(logging): restructure exports and consolidate processors 2025-11-21 14:22:46 +08:00
SikongJueluo
6d5cf11f2b feat(logging): implement structured logging system with ccStructLog 2025-11-20 21:47:16 +08:00
SikongJueluo
a4e74dcfa0 docs: ccCLI framework and ChatManager 2025-11-03 22:32:18 +08:00
SikongJueluo
2f57d9ab3d feature: accesscontrol welcome message 2025-11-03 13:20:21 +08:00
SikongJueluo
7e03d960bd fix: wrong type, chat manager unicode string; feature: accesscontrol welcome message and chinese support 2025-11-02 21:04:12 +08:00
SikongJueluo
f76a3666b1 feature: chat manager support utf8 2025-11-01 16:58:18 +08:00
SikongJueluo
d6971fb22f fix: cli framework help option not work 2025-11-01 14:34:19 +08:00
SikongJueluo
796bf1c2dc feature: global timer manager; reconstruct: accesscontrol; fix: chat manager
feature:
- add global timer manager
reconstruct:
- use new cli framework for accesscontrol
- use chat manager for accesscontrol
fix:
- chat manager only send one time
2025-11-01 13:16:42 +08:00
SikongJueluo
959ec0c424 feature: ChatManager
feature:
- multi chatboxes manage
- chatbox message queue
2025-10-30 12:58:53 +08:00
SikongJueluo
e680ef0263 reconstruct: project compile 2025-10-27 22:33:27 +08:00
SikongJueluo
1891259ee7 fix: cli framework
fix:
- cli command path wrong
- help output nil
2025-10-27 22:02:53 +08:00
7a17ca7fbf reconstruct: cli framework 2025-10-27 16:50:04 +08:00
SikongJueluo
f7167576cd feature: cli framework 2025-10-27 11:55:32 +08:00
SikongJueluo
2ab091d939 fix: accesscontrol toast; feature: autocraft basic; reconstruct: autocraft
fix:
- accesscontrol send toast failed
- advanced peripherals BlockDetailData wrong record type
feature:
- autocraft support multi package craft
- autocraft more fast craft speed
reconstruct:
- CraftManager algorithm
- autocraft logic
2025-10-26 20:19:49 +08:00
SikongJueluo
119bc1997a reconstruct: autocraft algorithm; feature: rust-style result
reconstruct:
- move queue and sortedarray to dir datatype
- move semaphore and readwritelock to dir mutex
- reconstruct autocraft search algorithm, use hashmap instead of forloop
- adjust some code style
feature:
- add rust-style result lib
2025-10-26 10:06:50 +08:00
SikongJueluo
ac70e1acd3 fix: auto reload
reconstruct: semaphore and read write lock use undefined instead of null
fix: accesscontrol auto reload config
2025-10-19 15:46:56 +08:00
SikongJueluo
d90574e514 feature: rwlock; fix: test for semaphore
feature:
- add read & write lock and test
- sorted array add peek function
fix:
- fix semaphore test (sleep block in async)
reconstruct:
- semaphore releaser
2025-10-18 14:53:34 +08:00
SikongJueluo
4e71fbffc3 feature: add sortedarray semaphore 2025-10-17 21:41:23 +08:00
a3479865c8 feature: add data type queue 2025-10-17 16:40:44 +08:00
SikongJueluo
9d9dcade7b feature: log add min output log level; reconstruct: accesscontrol cli 2025-10-16 21:10:36 +08:00
SikongJueluo
6304518f0e fix: deepcopy did't run as expected 2025-10-16 19:38:52 +08:00
69 changed files with 10653 additions and 4379 deletions

2
.gitignore vendored
View File

@@ -5,7 +5,7 @@ build/
reference/
src/**/*.md
QWEN.md
.ai/
# Devenv
.devenv*

View File

@@ -5,16 +5,24 @@ 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-logExample
build-tuiExample:
pnpm tstl -p ./targets/tsconfig.tuiExample.json
build-cliExample:
pnpm tstl -p ./targets/tsconfig.cliExample.json
build-logExample:
pnpm tstl -p ./targets/tsconfig.logExample.json
sync:
rsync --delete -r "./build/" "{{ sync-path }}"

View File

@@ -5,6 +5,7 @@ A collection of advanced utilities and libraries for Minecraft ComputerCraft, wr
## Features
### 1. Access Control System
A comprehensive system for managing player access to a specific area. It uses a `playerDetector` to monitor for players in range and a `chatBox` to interact with them and administrators.
- **Player Detection:** Monitors a configurable range for players.
@@ -15,6 +16,7 @@ A comprehensive system for managing player access to a specific area. It uses a
- **Logging:** Detailed logging of events, viewable with the included `logviewer` program.
### 2. AutoCraft System
An automated crafting solution designed to work with the Create mod's packaged recipes.
- **Automated Crafting:** Detects cardboard packages in a chest and automatically crafts the recipes they contain.
@@ -22,6 +24,7 @@ An automated crafting solution designed to work with the Create mod's packaged r
- **Inventory Management:** Manages pulling ingredients from a source inventory and pushing crafted items to a destination.
### 3. ccTUI Framework
A declarative, reactive TUI (Terminal User Interface) framework inspired by [SolidJS](https://www.solidjs.com/) for building complex and interactive interfaces in ComputerCraft.
- **Declarative Syntax:** Build UIs with simple, composable functions like `div`, `label`, `button`, and `input`.
@@ -30,8 +33,20 @@ 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
- **`ccLog`:** A robust logging library with automatic, time-based log file rotation.
### 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.
- **`ccStructLog`:** A modern, structured logging library inspired by Python's `structlog`. It provides a flexible, extensible framework based on processors, renderers, and streams, designed for CC:Tweaked. See the [ccStructLog Documentation](./docs/ccStructLog.md) for more details.
- **`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.
@@ -44,6 +59,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
## Setup & Installation
1. **Clone the repository:**
```sh
git clone <repository-url>
cd cc-utils
@@ -59,6 +75,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
This project uses `just` to manage build tasks. The compiled Lua files will be placed in the `build/` directory.
- **Build all modules:**
```sh
just build
```
@@ -95,44 +112,30 @@ To deploy the built programs to your in-game computer, you need to configure the
### Access Control
- **Start the system:**
```sh
accesscontrol start
```
- **Open the configuration TUI:**
```sh
accesscontrol config
```
Alternatively, press `c` while the main program is running.
- **View logs:**
```sh
logviewer accesscontrol.log
```
Alternatively, press `c` while the main program is running.
- **Admin Commands (in-game chat):**
```
@AC /help
@AC /add user Notch
@AC /list
@AC help
@AC add user Notch
@AC list
```
### AutoCraft
The autocraft program runs in the background. Simply run it on a turtle with the correct peripheral setup (see `src/autocraft/main.ts`). It will automatically process packages placed in the designated chest.
```sh
autocraft
```
### TUI Example
Run the example program to see a demonstration of the `ccTUI` framework.
```sh
tuiExample
```
## Development
- **Lint and format the code:**
@@ -142,4 +145,4 @@ tuiExample
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.

235
docs/ChatManager.md Normal file
View File

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

248
docs/ccCLI.md Normal file
View File

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

341
docs/ccStructLog.md Normal file
View File

@@ -0,0 +1,341 @@
# ccStructLog
A modern, structured logging library for CC:Tweaked, inspired by Python's structlog. This library provides a flexible, extensible logging framework based on processors, renderers, and streams.
## Features
- **Structured Logging**: Log events are represented as key-value pairs, not just strings.
- **Extensible**: Easy to customize with processors, renderers, and streams.
- **Type Safe**: Full TypeScript support with proper type definitions.
- **CC:Tweaked Optimized**: Designed specifically for Minecraft's ComputerCraft environment, with features like file rotation and colored console output.
## Quick Start
The easiest way to get started is to create a `Logger` instance and configure it with processors, a renderer, and streams.
Here's a simple example of a logger that prints colored, human-readable messages to the console:
```typescript
import {
Logger,
LogLevel,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
// Create a logger
const logger = new Logger({
processors: [
processor.addTimestamp({ format: "%T" }), // Add HH:MM:SS timestamp
processor.filterByLevel(LogLevel.Info), // Log Info and higher
processor.addSource("MyApp"),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
// Log messages with context
logger.info("Server started", { port: 8080, version: "1.0.0" });
logger.warn("Low disk space", { available: 1024, threshold: 2048 });
logger.error("Connection failed", { host: "example.com", retries: 3 });
// This debug message will be filtered out by `filterByLevel`
logger.debug("This is a debug message.");
```
## Core Concepts
### Log Levels
```typescript
export enum LogLevel {
Trace = 0, // Very detailed diagnostic information
Debug = 1, // Diagnostic information for development
Info = 2, // General informational messages
Warn = 3, // Potentially harmful situations
Error = 4, // Error events that might allow continued execution
Fatal = 5, // Very severe errors that might cause termination
}
```
### Data Flow
1. **Capture**: User calls `logger.info("message", {key: "value"})`.
2. **Package**: A `LogEvent` object (`Map<string, unknown>`) is created with the message, context, and metadata.
3. **Process**: The event is passed through a chain of processors (e.g., to add a timestamp, filter by level).
4. **Render**: The final event is converted to a string by a renderer (e.g., `textRenderer`, `jsonRenderer`).
5. **Output**: The string is sent to one or more streams (e.g., console, file).
## Common Configurations
### Development Logger
A typical development logger is configured for human-readable console output with timestamps and colors.
```typescript
import {
Logger,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
const devLogger = new Logger({
processors: [
processor.addTimestamp({ format: "%F %T" }), // YYYY-MM-DD HH:MM:SS
processor.addSource("DevApp"),
processor.addComputerId(),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
devLogger.debug("This is a debug message.", { user: "dev" });
```
### Production Logger
A production logger is often configured to write machine-readable JSON logs to a file with daily rotation.
```typescript
import {
Logger,
LogLevel,
processor,
jsonRenderer,
FileStream,
DAY,
} from "@/lib/ccStructLog";
const prodLogger = new Logger({
processors: [
processor.addTimestamp(), // Default format is %F %T
processor.filterByLevel(LogLevel.Info),
processor.addSource("ProdApp"),
processor.addComputerId(),
],
renderer: jsonRenderer,
streams: [
new FileStream("app.log", DAY), // Rotate daily
],
});
prodLogger.info("Application is running in production.");
```
## Custom Configuration
You can create a logger with any combination of processors, renderers, and streams.
```typescript
import {
Logger,
processor,
jsonRenderer,
FileStream,
ConsoleStream,
HOUR,
} from "@/lib/ccStructLog";
const logger = new Logger({
processors: [
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("MyApplication"),
],
renderer: jsonRenderer,
streams: [
new ConsoleStream(),
new FileStream("custom.log", HOUR), // Rotate every hour
],
});
logger.info("Custom logger reporting for duty.", { user: "admin" });
```
## Processors
Processors are functions that modify, enrich, or filter log events before they are rendered. They are all available under the `processor` namespace.
### Built-in Processors
```typescript
import { Logger, LogLevel, processor } from "@/lib/ccStructLog";
// Usage example
const logger = new Logger({
processors: [
// Adds a timestamp. Format is compatible with os.date().
// Default: "%F %T" (e.g., "2023-10-27 15:30:00")
processor.addTimestamp({ format: "%T" }), // e.g., "15:30:00"
// Filter by minimum level
processor.filterByLevel(LogLevel.Warn), // Only allow Warn, Error, Fatal
// Filter based on a custom predicate
processor.filterBy((event) => event.get("user") === "admin"),
// Add source/logger name
processor.addSource("MyApp"),
// Add computer ID or label
processor.addComputerId(),
processor.addComputerLabel(),
// Add static fields to all events
processor.addStaticFields({ env: "production", version: "1.2.3" }),
// Transform a specific field's value
processor.transformField("user_id", (id) => `user_${id}`),
// Remove sensitive fields
processor.removeFields(["password", "token"]),
],
// ... other config
});
```
### Custom Processors
A custom processor is a function that takes a `LogEvent` and returns a `LogEvent` or `undefined` (to drop the event).
```typescript
import { LogEvent } from "@/lib/ccStructLog";
// Add a unique request ID to all log events
const addRequestId = (event: LogEvent): LogEvent => {
event.set("requestId", `req_${Math.random().toString(36).substr(2, 9)}`);
return event;
};
// Sanitize sensitive information
const sanitizePasswords = (event: LogEvent): LogEvent => {
if (event.has("password")) {
event.set("password", "[REDACTED]");
}
return event;
};
```
## Renderers
Renderers convert the final `LogEvent` object into a string.
### Built-in Renderers
```typescript
import { textRenderer, jsonRenderer } from "@/lib/ccStructLog";
// textRenderer: Human-readable, colored output for the console.
// Example: [15:30:45] [INFO] Message key=value
// jsonRenderer: Machine-readable JSON output.
// Example: {"level":2,"message":"Message","key":"value","timestamp":"15:30:45"}
```
## Streams
Streams handle the final output destination. You can use multiple streams to send logs to different places.
### Built-in Streams
```typescript
import {
ConsoleStream,
FileStream,
BufferStream,
NullStream,
ConditionalStream,
LogLevel,
DAY,
} from "@/lib/ccStructLog";
// File stream with daily rotation
const fileStream = new FileStream("app.log", DAY);
// Buffer stream (useful for testing or UI display)
const bufferStream = new BufferStream(100); // Keep last 100 messages
// Conditional stream (only send errors to a separate file)
const errorStream = new ConditionalStream(
new FileStream("errors.log"),
(message, event) => (event.get("level") as LogLevel) >= LogLevel.Error
);
```
## File Rotation
`FileStream` supports automatic file rotation based on time intervals. The rotation interval is specified in seconds as the second argument to the constructor.
```typescript
import { FileStream, HOUR, DAY, WEEK } from "@/lib/ccStructLog";
// Rotate every hour
const hourlyLog = new FileStream("app_hourly.log", HOUR);
// Rotate daily (recommended for most applications)
const dailyLog = new FileStream("app_daily.log", DAY);
// Rotate weekly
const weeklyLog = new FileStream("app_weekly.log", WEEK);
// No rotation (pass 0 or undefined)
const permanentLog = new FileStream("permanent.log", 0);
```
## Best Practices
1. **Use Structured Context**: Always provide relevant context as key-value pairs.
```typescript
// Good
logger.info("User action completed", { userId: 123, action: "purchase" });
// Less useful
logger.info("User 123 purchased an item");
```
2. **Choose Appropriate Levels**:
- `debug`: For developers to diagnose issues.
- `info`: Normal application behavior.
- `warn`: Potentially harmful situations that don't break functionality.
- `error`: Errors that affect a single operation but not the whole app.
- `fatal`: Critical errors that require the application to shut down.
3. **Use a `source`**: Identify which component generated the log using `processor.addSource`.
```typescript
import { Logger, processor } from "@/lib/ccStructLog";
const logger = new Logger({
processors: [processor.addSource("UserService")],
// ...
});
```
4. **Sanitize Sensitive Data**: Use a processor to remove passwords, API keys, etc.
```typescript
import { Logger, processor } from "@/lib/ccStructLog";
const secureLogger = new Logger({
processors: [ processor.removeFields(["password", "token"]) ],
//...
});
```
5. **Proper Cleanup**: Close loggers during application shutdown to ensure file streams are saved.
```typescript
// At application shutdown
logger.close();
```
## Examples
See `src/logExample/main.ts` for comprehensive usage examples including:
- Basic logging patterns
- Custom processor chains
- Multiple output streams with different formats
- Error handling strategies
## API Reference
For complete API documentation, refer to the TypeScript definitions in each module:
- `src/lib/ccStructLog/types.ts` - Core interfaces and types
- `src/lib/ccStructLog/Logger.ts` - Main Logger class
- `src/lib/ccStructLog/processors.ts` - Built-in processors
- `src/lib/ccStructLog/renderers.ts` - Built-in renderers
- `src/lib/ccStructLog/streams.ts` - Built-in streams
- `src/lib/ccStructLog/index.ts` - Convenience functions and exports

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ interface UserGroupConfig {
groupName: string;
isAllowed: boolean;
isNotice: boolean;
isWelcome: boolean;
groupUsers: string[];
}
@@ -20,6 +21,7 @@ interface AccessConfig {
watchInterval: number;
noticeTimes: number;
detectRange: number;
isWelcome: boolean;
isWarn: boolean;
adminGroupConfig: UserGroupConfig;
welcomeToastConfig: ToastConfig;
@@ -34,11 +36,13 @@ const defaultConfig: AccessConfig = {
watchInterval: 10,
noticeTimes: 2,
isWarn: false,
isWelcome: true,
adminGroupConfig: {
groupName: "Admin",
groupUsers: ["Selcon"],
isAllowed: true,
isNotice: true,
isWelcome: false,
},
usersGroups: [
{
@@ -46,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);

View File

@@ -1,110 +0,0 @@
/**
* Access Control Log Viewer
* Simple log viewer that allows launching the TUI with 'c' key
*/
import { launchAccessControlTUI } from "./tui";
const args = [...$vararg];
function displayLog(filepath: string, lines = 20) {
const [file] = io.open(filepath, "r");
if (!file) {
print(`Failed to open log file: ${filepath}`);
return;
}
const content = file.read("*a");
file.close();
if (content === null || content === undefined || content === "") {
print("Log file is empty");
return;
}
const logLines = content.split("\n");
const startIndex = Math.max(0, logLines.length - lines);
const displayLines = logLines.slice(startIndex);
term.clear();
term.setCursorPos(1, 1);
print("=== Access Control Log Viewer ===");
print("Press 'c' to open configuration TUI, 'q' to quit, 'r' to refresh");
print("==========================================");
print("");
for (const line of displayLines) {
if (line.trim() !== "") {
print(line);
}
}
print("");
print("==========================================");
print(`Showing last ${displayLines.length} lines of ${filepath}`);
}
function main(args: string[]) {
const logFilepath = args[0] || `${shell.dir()}/accesscontrol.log`;
const lines = args[1] ? parseInt(args[1]) : 20;
if (isNaN(lines) || lines <= 0) {
print("Usage: logviewer [logfile] [lines]");
print(" logfile - Path to log file (default: accesscontrol.log)");
print(" lines - Number of lines to display (default: 20)");
return;
}
let running = true;
// Initial display
displayLog(logFilepath, lines);
while (running) {
const [eventType, key] = os.pullEvent();
if (eventType === "key") {
if (key === keys.c) {
// Launch TUI
print("Launching Access Control TUI...");
try {
launchAccessControlTUI();
// Refresh display after TUI closes
displayLog(logFilepath, lines);
} catch (error) {
if (error === "TUI_CLOSE" || error === "Terminated") {
displayLog(logFilepath, lines);
} else {
print(`TUI error: ${String(error)}`);
os.sleep(2);
displayLog(logFilepath, lines);
}
}
} else if (key === keys.q) {
// Quit
running = false;
} else if (key === keys.r) {
// Refresh
displayLog(logFilepath, lines);
}
} else if (eventType === "terminate") {
running = false;
}
}
term.clear();
term.setCursorPos(1, 1);
print("Log viewer closed.");
}
try {
main(args);
} catch (error) {
if (error === "Terminated") {
print("Log viewer terminated by user.");
} else {
print("Error in log viewer:");
printError(error);
}
}

View File

@@ -1,43 +1,99 @@
import { CCLog, DAY } 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";
import {
ConditionalStream,
ConsoleStream,
DAY,
FileStream,
getStructLogger,
LoggerOptions,
LogLevel,
MB,
processor,
setStructLoggerConfig,
textRenderer,
} from "@/lib/ccStructLog";
const DEBUG = false;
const args = [...$vararg];
// Init Log
const logger = new CCLog("accesscontrol.log", true, DAY);
let isOnConsoleStream = true;
const loggerConfig: LoggerOptions = {
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream),
new FileStream({
filePath: "accesscontrol.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 7,
maxSizeBytes: MB,
},
}),
],
};
setStructLoggerConfig(loggerConfig);
const logger = getStructLogger();
// 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!");
if (DEBUG)
logger.debug(textutils.serialise(config, { allow_repetitions: true }));
const groupNames = config.usersGroups.map((value) => value.groupName);
// 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() {
let releaser = configLock.tryAcquireWrite();
while (releaser === undefined) {
sleep(1);
releaser = configLock.tryAcquireWrite();
}
config = loadConfig(configFilepath)!;
gInRangePlayers = [];
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
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"));
}
function safeParseTextComponent(
component: MinecraftTextComponent,
params?: ParseParams,
): string {
): MinecraftTextComponent {
const newComponent = deepCopy(component);
if (newComponent.text == undefined) {
@@ -45,11 +101,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%",
@@ -64,7 +120,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(
@@ -72,67 +155,95 @@ 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,
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
undefined,
true,
);
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) {
if (DEBUG) {
const watchPlayerNames = watchPlayersInfo.flatMap((value) => value.name);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
for (const player of watchPlayersInfo) {
const watchPlayerNames = gWatchPlayersInfo.flatMap(
(value) => value.name,
);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
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);
@@ -144,48 +255,66 @@ function watchLoop() {
// Record
logger.warn(
`${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
`Stranger ${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
// Get rid of player from list
watchPlayersInfo = watchPlayersInfo.filter(
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
`${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
`Stranger ${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
}
os.sleep(3);
os.sleep(1);
}
releaser.release();
os.sleep(config.watchInterval);
}
}
function mainLoop() {
while (true) {
const players = playerDetector.getPlayersInRange(config.detectRange);
if (DEBUG) {
const playersList = "[ " + players.join(",") + " ]";
logger.debug(`Detected ${players.length} players: ${playersList}`);
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;
@@ -194,43 +323,121 @@ 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);
isOnConsoleStream = false;
launchAccessControlTUI();
logger.info("TUI closed, resuming normal operation");
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
} finally {
logger.setInTerminal(true);
config = loadConfig(configFilepath);
logger.info("Reload config successfully!");
isOnConsoleStream = true;
reloadConfig();
}
} else if (event.key === keys.r) {
reloadConfig();
}
// else if (event.key === keys.q) {
// gIsRunning = false;
// }
}
}
function cliLoop() {
let printTargetPlayer: string | undefined;
const cli = createAccessControlCli({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
logger: logger,
print: (msg) =>
chatManager.sendMessage({
message: msg,
targetPlayer: printTargetPlayer,
prefix: "Access Control System",
brackets: "[]",
utf8Support: true,
}),
});
while (gIsRunning) {
const result = chatManager.getReceivedMessage();
if (result.isErr()) {
sleep(0.5);
continue;
}
logger.debug(`Received message: ${result.value.message}`);
const ev = result.value;
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const isAdmin = config.adminGroupConfig.groupUsers.includes(
ev.username,
);
releaser.release();
if (!isAdmin) continue;
if (!ev.message.startsWith("@AC")) continue;
printTargetPlayer = ev.username;
logger.info(
`Received command "${ev.message}" from admin ${printTargetPlayer}`,
);
const commandArgs = ev.message
.substring(3)
.split(" ")
.filter((s) => s.length > 0);
logger.debug(`Command arguments: ${commandArgs.join(", ")}`);
cli(commandArgs);
printTargetPlayer = undefined;
}
}
@@ -238,40 +445,31 @@ 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,
configFilepath,
logger,
chatBox,
groupNames,
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(),
() => gTimerManager.run(),
() => cliLoop(),
() => watchLoop(),
() => keyboardLoop(),
() => chatManager.run(),
);
print(
"Access Control System started. Press 'c' to open configuration TUI.",
);
parallel.waitForAll(
() => {
mainLoop();
},
() => {
void cli.startConfigLoop();
},
() => {
watchLoop();
},
() => {
keyboardLoop();
},
);
return;
} else if (args[0] == "config") {
logger.info("Launching Access Control TUI...");
logger.setInTerminal(false);
isOnConsoleStream = false;
try {
launchAccessControlTUI();
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
}
return;
}

View File

@@ -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);
@@ -99,25 +99,31 @@ const AccessControlTUI = () => {
// Validate numbers
if (
validateNumber(currentConfig.detectInterval?.toString() ?? "") === null
validateNumber(
currentConfig.detectInterval?.toString() ?? "",
) === null
) {
showError("Invalid Detect Interval: must be a number");
return;
}
if (
validateNumber(currentConfig.watchInterval?.toString() ?? "") === null
validateNumber(
currentConfig.watchInterval?.toString() ?? "",
) === null
) {
showError("Invalid Watch Interval: must be a number");
return;
}
if (
validateNumber(currentConfig.noticeTimes?.toString() ?? "") === null
validateNumber(currentConfig.noticeTimes?.toString() ?? "") ===
null
) {
showError("Invalid Notice Times: must be a number");
return;
}
if (
validateNumber(currentConfig.detectRange?.toString() ?? "") === null
validateNumber(currentConfig.detectRange?.toString() ?? "") ===
null
) {
showError("Invalid Detect Range: must be a number");
return;
@@ -153,7 +159,9 @@ const AccessControlTUI = () => {
for (const toastConfig of toastConfigs) {
if (toastConfig.value != undefined) {
const serialized = textutils.serialiseJSON(toastConfig.value);
const serialized = textutils.serialiseJSON(
toastConfig.value,
);
if (!validateTextComponent(serialized)) {
showError(
`Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`,
@@ -210,7 +218,9 @@ const AccessControlTUI = () => {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
groupUsers: currentAdmin.groupUsers.filter((user) => user !== userName),
groupUsers: currentAdmin.groupUsers.filter(
(user) => user !== userName,
),
});
} else {
// Regular group
@@ -268,7 +278,10 @@ const AccessControlTUI = () => {
onFocusChanged: () => {
const num = validateNumber(getDetectInterval());
if (num !== null) setConfig("detectInterval", num);
else setDetectInterval(config().detectInterval.toString());
else
setDetectInterval(
config().detectInterval.toString(),
);
},
}),
),
@@ -282,7 +295,8 @@ const AccessControlTUI = () => {
onFocusChanged: () => {
const num = validateNumber(getWatchInterval());
if (num !== null) setConfig("watchInterval", num);
else setWatchInterval(config().watchInterval.toString());
else
setWatchInterval(config().watchInterval.toString());
},
}),
),
@@ -323,6 +337,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),
}),
),
);
};
@@ -338,11 +361,15 @@ const AccessControlTUI = () => {
div(
{ class: "flex flex-col" },
label({}, "Groups:"),
For({ each: () => groups, class: "flex flex-col" }, (group, index) =>
For(
{ each: () => groups, class: "flex flex-col" },
(group, index) =>
button(
{
class:
selectedGroupIndex() === index() ? "bg-blue text-white" : "",
selectedGroupIndex() === index()
? "bg-blue text-white"
: "",
onClick: () => setSelectedGroupIndex(index()),
},
group.groupName,
@@ -355,6 +382,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:"),
@@ -452,7 +507,10 @@ const AccessControlTUI = () => {
* Toast Configuration Tab Factory
*/
const createToastTab = (
toastType: "welcomeToastConfig" | "warnToastConfig" | "noticeToastConfig",
toastType:
| "welcomeToastConfig"
| "warnToastConfig"
| "noticeToastConfig",
) => {
return () => {
const toastConfig = config()[toastType];
@@ -496,7 +554,9 @@ const AccessControlTUI = () => {
} catch {
setTempToastConfig({
...getTempToastConfig(),
title: textutils.serialiseJSON(currentToastConfig.title),
title: textutils.serialiseJSON(
currentToastConfig.title,
),
});
}
},
@@ -508,7 +568,10 @@ const AccessControlTUI = () => {
type: "text",
value: () => getTempToastConfig().msg,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), msg: value }),
setTempToastConfig({
...getTempToastConfig(),
msg: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
@@ -529,7 +592,9 @@ const AccessControlTUI = () => {
} catch {
setTempToastConfig({
...getTempToastConfig(),
msg: textutils.serialiseJSON(currentToastConfig.msg),
msg: textutils.serialiseJSON(
currentToastConfig.msg,
),
});
// Invalid JSON, ignore
}
@@ -541,9 +606,20 @@ 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 }),
setTempToastConfig({
...getTempToastConfig(),
prefix: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
@@ -561,7 +637,10 @@ const AccessControlTUI = () => {
type: "text",
value: () => getTempToastConfig().brackets,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), brackets: value }),
setTempToastConfig({
...getTempToastConfig(),
brackets: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
@@ -636,7 +715,10 @@ const AccessControlTUI = () => {
{ when: () => currentTab() === TABS.WELCOME_TOAST },
WelcomeToastTab(),
),
Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()),
Match(
{ when: () => currentTab() === TABS.WARN_TOAST },
WarnToastTab(),
),
Match(
{ when: () => currentTab() === TABS.NOTICE_TOAST },
NoticeToastTab(),
@@ -661,7 +743,10 @@ const AccessControlTUI = () => {
For({ each: () => tabNames }, (tabName, index) =>
button(
{
class: currentTab() === index() ? "bg-blue text-white" : "",
class:
currentTab() === index()
? "bg-blue text-white"
: "",
onClick: () => setCurrentTab(index() as TabIndex),
},
tabName,

View File

@@ -1,120 +1,275 @@
import { CraftManager } from "@/lib/CraftManager";
import * as peripheralManager from "../lib/PeripheralManager";
import { CCLog } from "@/lib/ccLog";
import {
CraftManager,
CraftRecipe,
CreatePackageTag,
} from "@/lib/CraftManager";
import { Queue } from "@/lib/datatype/Queue";
import {
ConsoleStream,
DAY,
FileStream,
Logger,
LogLevel,
processor,
textRenderer,
} from "@/lib/ccStructLog";
const log = new CCLog("autocraft.log");
const logger = new Logger({
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConsoleStream(),
new FileStream({
filePath: "autocraft.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 3,
},
}),
],
});
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",
};
function main() {
const packagesContainer = peripheralManager.findByNameRequired(
"inventory",
peripheralsRelativeSides.packagesContainer,
);
let packsInventory: InventoryPeripheral;
let itemsInventory: InventoryPeripheral;
let packageExtractor: InventoryPeripheral;
let blockReader: BlockReaderPeripheral;
let wiredModem: WiredModemPeripheral;
let turtleLocalName: string;
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");
while (true) {
if (!hasPackage) os.pullEvent("redstone");
hasPackage = redstone.getInput("front");
if (!hasPackage) {
continue;
enum State {
IDLE,
READ_RECIPE,
CRAFT_OUTPUT,
}
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}`);
function main() {
let isFinishedInitPeripheral = false;
while (!isFinishedInitPeripheral) {
try {
packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
turtleLocalName = wiredModem.getNameLocal();
logger.info("Peripheral initialization complete...");
isFinishedInitPeripheral = true;
} catch (error) {
logger.warn(
`Peripheral initialization failed for ${String(error)}, try again...`,
);
sleep(1);
}
}
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);
}
logger.info("AutoCraft init finished...");
while (true) {
// 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;
}
}
// State logic
switch (currentState) {
case State.IDLE: {
if (!hasPackage) os.pullEvent("redstone");
hasPackage = redstone.getInput(peripheralsNames.redstone);
break;
}
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
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`);
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;
}
// Extract package
// log.info(`Get recipe ${textutils.serialise(recipe)}`);
// 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
View 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
View File

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

View File

@@ -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, _) => ({
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}`));
// Check item max stack count
if (ingredient.maxCount < maxCraftCnt) {
maxCraftCnt = ingredient.maxCount;
}
const ingredientMaxCount = ingredient.maxCount;
if (maxCraftCount > ingredientMaxCount) {
maxCraftCount = ingredientMaxCount;
restCount = maxCraftCount;
}
log.info(
`Slot ${slot} ${ingredient.name} max count: ${ingredientMaxCount}`,
// Pull items
const pullItemsCnt = this.pullFromInventory(
ingredient.name,
maxCraftCnt,
CRAFT_SLOT_TABLE[index],
);
if (pullItemsCnt.isErr()) return pullItemsCnt;
// 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;
}
if (pullItemsCnt.value < maxCraftCnt)
return new Err(Error("Not enough items in inventory"));
}
if (restCount > 0) return 0;
}
return maxCraftCount;
return new Ok(maxCraftCnt);
}
}

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

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

226
src/lib/ccCLI/cli.ts Normal file
View 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
View 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
View 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
View 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
View 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[];
}

View File

@@ -1,166 +0,0 @@
enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
// Define time interval constants in seconds
export const SECOND = 1;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;
export class CCLog {
private fp: LuaFile | undefined;
private filename?: string;
private interval: number;
private startTime: number;
private currentTimePeriod: string;
private inTerm: boolean;
constructor(filename?: string, inTerm = true, interval: number = DAY) {
term.clear();
term.setCursorPos(1, 1);
this.interval = interval;
this.inTerm = inTerm;
this.startTime = os.time(os.date("*t"));
this.currentTimePeriod = this.getTimePeriodString(this.startTime);
if (filename != undefined && filename.length != 0) {
this.filename = filename;
const filepath = this.generateFilePath(filename, this.currentTimePeriod);
const [file, error] = io.open(filepath, fs.exists(filepath) ? "a" : "w+");
if (file != undefined) {
this.fp = file;
} else {
throw Error(error);
}
}
}
/**
* Generates a time period string based on the interval
* For DAY interval: YYYY-MM-DD
* For HOUR interval: YYYY-MM-DD-HH
* For MINUTE interval: YYYY-MM-DD-HH-MM
* For SECOND interval: YYYY-MM-DD-HH-MM-SS
*/
private getTimePeriodString(time: number): string {
const periodStart = Math.floor(time / this.interval) * this.interval;
const d = os.date("*t", periodStart);
if (this.interval >= DAY) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}`;
} else if (this.interval >= HOUR) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}`;
} else if (this.interval >= MINUTE) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}-${string.format("%02d", d.min)}`;
}
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}-${string.format("%02d", d.min)}-${string.format("%02d", d.sec)}`;
}
private generateFilePath(baseFilename: string, timePeriod: string): string {
const scriptDir = shell.dir() ?? "";
const [filenameWithoutExt, extension] = baseFilename.includes(".")
? baseFilename.split(".")
: [baseFilename, "log"];
return fs.combine(
scriptDir,
`${filenameWithoutExt}_${timePeriod}.${extension}`,
);
}
private checkAndRotateLogFile() {
if (this.filename != undefined && this.filename.length != 0) {
const currentTime = os.time(os.date("*t"));
const currentTimePeriod = this.getTimePeriodString(currentTime);
// If we're in a new time period, rotate the log file
if (currentTimePeriod !== this.currentTimePeriod) {
// Close current file if open
if (this.fp) {
this.fp.close();
this.fp = undefined;
}
// Update the current time period
this.currentTimePeriod = currentTimePeriod;
// Open new log file for the new time period
const filepath = this.generateFilePath(
this.filename,
this.currentTimePeriod,
);
const [file, error] = io.open(
filepath,
fs.exists(filepath) ? "a" : "w+",
);
if (file != undefined) {
this.fp = file;
} else {
throw Error(error);
}
}
}
}
private getFormatMsg(msg: string, level: LogLevel): string {
const date = os.date("*t");
return `[ ${date.year}/${String(date.month).padStart(2, "0")}/${String(date.day).padStart(2, "0")} ${String(date.hour).padStart(2, "0")}:${String(date.min).padStart(2, "0")}:${String(date.sec).padStart(2, "0")} ${LogLevel[level]} ] : ${msg}`;
}
public writeLine(msg: string, color?: Color) {
// Check if we need to rotate the log file
this.checkAndRotateLogFile();
if (this.inTerm) {
let originalColor: Color = 0;
if (color != undefined) {
originalColor = term.getTextColor();
term.setTextColor(color);
}
print(msg);
if (color != undefined) {
term.setTextColor(originalColor);
}
}
// Log
if (this.fp != undefined) {
this.fp.write(msg + "\r\n");
}
}
public debug(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Debug), colors.gray);
}
public info(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Info), colors.green);
}
public warn(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Warn), colors.orange);
}
public error(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Error), colors.red);
}
public setInTerminal(value: boolean) {
this.inTerm = value;
}
public close() {
if (this.fp !== undefined) {
this.fp.close();
this.fp = undefined;
}
}
}

View File

@@ -0,0 +1,176 @@
/**
* Main Logger class implementation.
* This is the primary entry point for users to interact with the logging system.
*/
import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
import { processor } from "./processors";
import { ConsoleStream } from "./streams";
import { textRenderer } from "./renderers";
/**
* The main Logger class that orchestrates the logging pipeline.
*
* This class takes log messages, creates LogEvent objects, processes them through
* a chain of processors, renders them to strings, and outputs them via streams.
*/
export class Logger implements ILogger {
private options: LoggerOptions;
private loggerName?: string;
/**
* Create a new Logger instance.
*
* @param options - Configuration options for the logger
* @param name - The name of the logger
*/
constructor(options: LoggerOptions, name?: string) {
this.options = options;
this.loggerName = name;
}
/**
* Main logging method that handles the complete logging pipeline.
*
* @param level - The log level
* @param message - The log message
* @param context - Additional context data as key-value pairs
*/
public log(
level: LogLevel,
message: string,
context: Record<string, unknown> = {},
): void {
// 1. Create initial LogEvent with core fields
let event: LogEvent | undefined = new Map<string, unknown>([
["level", level],
["message", message],
...Object.entries(context),
]);
if (this.loggerName !== undefined)
event.set("loggerName", this.loggerName);
// 2. Process through the processor chain
for (const processor of this.options.processors) {
if (event === undefined) {
break; // Event was dropped by a processor
}
event = processor(event);
}
// 3. Render and output if event wasn't dropped
if (event !== undefined) {
const output = this.options.renderer(event);
// Send to all configured streams
for (const stream of this.options.streams) {
stream.write(output, event);
}
}
}
/**
* Log a trace message.
* Typically used for very detailed diagnostic information.
*
* @param message - The log message
* @param context - Additional context data
*/
public trace(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Trace, message, context);
}
/**
* Log a debug message.
* Used for diagnostic information useful during development.
*
* @param message - The log message
* @param context - Additional context data
*/
public debug(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Debug, message, context);
}
/**
* Log an info message.
* Used for general informational messages about application flow.
*
* @param message - The log message
* @param context - Additional context data
*/
public info(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Info, message, context);
}
/**
* Log a warning message.
* Used for potentially harmful situations that don't stop execution.
*
* @param message - The log message
* @param context - Additional context data
*/
public warn(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Warn, message, context);
}
/**
* Log an error message.
* Used for error events that might allow the application to continue.
*
* @param message - The log message
* @param context - Additional context data
*/
public error(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Error, message, context);
}
/**
* Log a fatal message.
* Used for very severe error events that might cause termination.
*
* @param message - The log message
* @param context - Additional context data
*/
public fatal(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Fatal, message, context);
}
/**
* Update the logger's configuration.
* Useful for dynamically changing logging behavior at runtime.
*
* @param options - New configuration options to merge with existing ones
*/
public configure(options: Partial<LoggerOptions>): void {
this.options = {
...this.options,
...options,
};
}
/**
* Close all streams and clean up resources.
* Should be called when the logger is no longer needed.
*/
public close(): void {
for (const stream of this.options.streams) {
if (stream.close) {
stream.close();
}
}
}
}
let globalLoggerConfig: LoggerOptions = {
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [new ConsoleStream()],
};
export function getStructLogger(name?: string): Logger {
return new Logger(globalLoggerConfig, name);
}
export function setStructLoggerConfig(config: LoggerOptions): void {
globalLoggerConfig = config;
}

View File

@@ -0,0 +1,20 @@
/**
* Main entry point for the ccStructLog library.
*
* This module provides convenient factory functions and pre-configured
* logger instances for common use cases. It exports all the core components
* while providing easy-to-use defaults for typical logging scenarios.
*/
// Re-export all core types and classes
export * from "./types";
export * from "./Logger";
// Re-export all processors
export * from "./processors";
// Re-export all renderers
export * from "./renderers";
// Re-export all streams
export * from "./streams";

View File

@@ -0,0 +1,187 @@
/**
* Standard processors for the ccStructLog library.
*
* Processors are functions that can modify, enrich, or filter log events
* as they flow through the logging pipeline. Each processor receives a
* LogEvent and can return a modified LogEvent or undefined to drop the log.
*/
import { LogEvent, Processor, LogLevel } from "./types";
export namespace processor {
/**
* Configuration options for the timestamp processor.
*/
interface TimestampConfig {
/**
* The format string takes the same formats as C's strftime function.
*/
format?: string;
}
/**
* Adds a timestamp to each log event.
*
* This processor adds a "time" field to each log event with the current
* timestamp. The timestamp format can be customized using the `format`
* option.
*
* @param config - Configuration options for the timestamp processor.
* @returns A processor function that adds a timestamp to each log event.
*/
export function addTimestamp(config: TimestampConfig = {}): Processor {
return (event) => {
let time: string;
if (config.format === undefined) {
time = os.date("%F %T") as string;
} else {
time = os.date(config.format) as string;
}
event.set("timestamp", time);
return event;
};
}
/**
* Filters log events by minimum level.
*
* This processor drops log events that are below the specified minimum level.
* Note: The Logger class already does early filtering for performance, but
* this processor can be useful for dynamic filtering or when you need
* different levels for different streams.
*
* @param minLevel - The minimum log level to allow through
* @returns A processor function that filters by level
*/
export function filterByLevel(minLevel: LogLevel): Processor {
return (event) => {
const eventLevel = event.get("level") as LogLevel | undefined;
if (eventLevel === undefined) {
return event; // Pass through if no level is set
}
if (eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
/**
* Adds the current computer ID to the log event.
*
* In CC:Tweaked environments, this can help identify which computer
* generated the log when logs are aggregated from multiple sources.
*
* @param event - The log event to process
* @returns The event with computer ID added
*/
export function addComputerId(): Processor {
return (event) => {
event.set("computer_id", os.getComputerID());
return event;
};
}
/**
* Adds the current computer label to the log event.
*
* If the computer has a label set, this adds it to the log event.
* This can be more human-readable than the computer ID.
*
* @param event - The log event to process
* @returns The event with computer label added (if available)
*/
export function addComputerLabel(): Processor {
return (event) => {
const label = os.getComputerLabel();
if (label !== undefined && label !== null) {
event.set("computer_label", label);
}
return event;
};
}
/**
* Filters out events that match a specific condition.
*
* This is a generic processor that allows you to filter events based on
* any custom condition. The predicate function should return true to keep
* the event and false to drop it.
*
* @param predicate - Function that returns true to keep the event
* @returns A processor function that filters based on the predicate
*/
export function filterBy(
predicate: (event: LogEvent) => boolean,
): Processor {
return (event) => {
return predicate(event) ? event : undefined;
};
}
/**
* Transforms a specific field in the log event.
*
* This processor allows you to modify the value of a specific field
* using a transformation function.
*
* @param fieldName - The name of the field to transform
* @param transformer - Function to transform the field value
* @returns A processor function that transforms the specified field
*/
export function transformField(
fieldName: string,
transformer: (value: unknown) => unknown,
): Processor {
return (event) => {
if (event.has(fieldName)) {
const currentValue = event.get(fieldName);
const newValue = transformer(currentValue);
event.set(fieldName, newValue);
}
return event;
};
}
/**
* Removes specified fields from the log event.
*
* This processor can be used to strip sensitive or unnecessary information
* from log events before they are rendered and output.
*
* @param fieldNames - Array of field names to remove
* @returns A processor function that removes the specified fields
*/
export function removeFields(fieldNames: string[]): Processor {
return (event) => {
for (const fieldName of fieldNames) {
event.delete(fieldName);
}
return event;
};
}
/**
* Adds static fields to every log event.
*
* This processor adds the same set of fields to every log event that
* passes through it. Useful for adding application name, version,
* environment, etc.
*
* @param fields - Object containing the static fields to add
* @returns A processor function that adds the static fields
*/
export function addStaticFields(
fields: Record<string, unknown>,
): Processor {
return (event) => {
for (const [key, value] of Object.entries(fields)) {
event.set(key, value);
}
return event;
};
}
}

View File

@@ -0,0 +1,79 @@
/**
* Standard renderers for the ccStructLog library.
*
* Renderers are functions that convert processed LogEvent objects into
* their final string representation. Different renderers can produce
* different output formats (JSON, console-friendly, etc.).
*/
import { LogLevel, Renderer } from "./types";
/**
* Renders log events as JSON strings.
*
* This renderer converts the LogEvent Map into a plain object and then
* serializes it as JSON. This format is ideal for structured logging
* and machine processing.
*
* Note: This assumes textutils.serialiseJSON is available (CC:Tweaked).
* Falls back to a simple key=value format if JSON serialization fails.
*
* @param event - The log event to render
* @returns JSON string representation of the event
*/
export const jsonRenderer: Renderer = (event) => {
try {
// Convert Map to plain object for JSON serialization
const obj: Record<string, unknown> = {};
for (const [key, value] of event.entries()) {
obj[key] = value;
}
// Use CC:Tweaked's JSON serialization if available
return textutils.serialiseJSON(obj);
} catch (error) {
return String(error);
}
};
/**
* Renders log events in a human-readable Text format.
*
* This renderer creates output suitable for terminal display, with
* timestamp, level, message, and additional context fields formatted
* in a readable way.
*
* Format: [YYYY-MM-DD HH:MM:SS] [LEVEL] message key=value, key2=value2
*
* @param event - The log event to render
* @returns Human-readable string representation
*/
export const textRenderer: Renderer = (event) => {
// Extract core components
const timeStr = event.get("timestamp") as string | undefined;
const level: string | undefined = LogLevel[event.get("level") as LogLevel];
const message = (event.get("message") as string) ?? "";
const loggerName = event.get("loggerName") as string | undefined;
// Start building the output
let output = `${timeStr} [${level}] ${message} \t ${loggerName !== undefined ? "[" + loggerName + "]" : ""}`;
// Add context fields (excluding the core fields we already used)
const contextFields: string[] = [];
for (const [key, value] of event.entries()) {
if (
key !== "timestamp" &&
key !== "level" &&
key !== "message" &&
key !== "loggerName"
) {
contextFields.push(`${key}=${tostring(value)}`);
}
}
if (contextFields.length > 0) {
output += contextFields.join(", ");
}
return output;
};

View File

@@ -0,0 +1,491 @@
/**
* Standard output streams for the ccStructLog library.
*
* Streams are responsible for writing the final formatted log messages
* to their destination (console, file, network, etc.). Each stream
* implements the Stream interface and handles its own output logic.
*/
import { LogLevel, Stream, LogEvent } from "./types";
/**
* Configuration interface for FileStream with auto-cleanup options.
*/
interface FileStreamConfig {
/** Path to the log file */
filePath: string;
/**
* Time in seconds between file rotations (0 = no rotation)
* Time must larger than one DAY
* @default 0
*/
rotationInterval?: number;
/** Auto-cleanup configuration */
autoCleanup?: {
/** Whether to enable auto-cleanup */
enabled: boolean;
/** Maximum number of log files to keep */
maxFiles?: number;
/** Maximum total size in bytes for all log files */
maxSizeBytes?: number;
/** Directory to search for log files (defaults to log file directory) */
logDir?: string;
/** File pattern to match (defaults to base filename pattern) */
pattern?: string;
};
}
/**
* Console stream that outputs to the CC:Tweaked terminal.
*
* This stream writes log messages to the computer's terminal with
* color coding based on log levels. It preserves the original text
* color after writing each message.
*/
export class ConsoleStream implements Stream {
private levelColors: { [key: string]: number } = {
Trace: colors.lightGray,
Debug: colors.gray,
Info: colors.green,
Warn: colors.orange,
Error: colors.red,
Fatal: colors.red,
};
/**
* Write a formatted log message to the terminal.
*
* @param message - The formatted log message
* @param event - The original log event for context (used for level-based coloring)
*/
public write(message: string, event: LogEvent): void {
const level: string | undefined =
LogLevel[event.get("level") as LogLevel];
const color = level !== undefined ? this.levelColors[level] : undefined;
if (color !== undefined) {
const originalColor = term.getTextColor();
term.setTextColor(color);
print(message);
term.setTextColor(originalColor);
} else {
print(message);
}
}
}
/**
* File stream that outputs to a file on disk.
*
* This stream writes log messages to a specified file, creating the file
* if it doesn't exist and appending to it if it does. It handles file
* rotation based on time intervals.
*/
export class FileStream implements Stream {
private fileHandle: LuaFile | undefined;
private filePath: string;
private rotationInterval: number;
private lastRotationTime: number;
private autoCleanupConfig?: FileStreamConfig["autoCleanup"];
/**
* Create a new file stream with configuration object.
*
* @param config - FileStream configuration object
*/
constructor(config: FileStreamConfig) {
this.filePath = config.filePath;
this.rotationInterval = config.rotationInterval || 0;
if (this.rotationInterval !== 0 && this.rotationInterval < DAY)
throw Error("Rotation interval must be at least one day");
this.autoCleanupConfig = config.autoCleanup;
this.lastRotationTime = os.time();
this.openFile();
}
/**
* Open the log file for writing.
* Creates the file if it doesn't exist, appends if it does.
*/
private openFile(): void {
const actualPath =
this.rotationInterval > 0
? this.getRotatedFilename()
: this.filePath;
const [handle, err] = io.open(actualPath, "a");
if (handle === undefined) {
printError(
`Failed to open log file ${actualPath}: ${err ?? "Unknown error"}`,
);
return;
}
this.fileHandle = handle;
// Perform auto-cleanup when opening file
this.performAutoCleanup();
}
/**
* Generate a filename with timestamp for file rotation.
*/
private getRotatedFilename(): string {
const currentTime = os.time(os.date("*t"));
const rotationPeriod =
Math.floor(currentTime / this.rotationInterval) *
this.rotationInterval;
const date = os.date("*t", rotationPeriod) as LuaDate;
const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}`;
// Split filename and extension
const splitStrs = this.filePath.split(".");
if (splitStrs.length === 1) {
return `${this.filePath}_${timestamp}.log`;
}
const name = splitStrs[0];
const ext = splitStrs[1];
return `${name}_${timestamp}.${ext}`;
}
/**
* Check if file rotation is needed and rotate if necessary.
*/
private checkRotation(): void {
if (this.rotationInterval <= 0) return;
const currentTime = os.time();
if (
Math.floor(
(currentTime - this.lastRotationTime) / this.rotationInterval,
) > 0
) {
// Time to rotate
this.close();
this.lastRotationTime = currentTime;
this.openFile();
// Auto-cleanup is performed in openFile()
}
}
/**
* Perform auto-cleanup based on configuration.
* This method is called automatically when opening files or rotating.
*/
private performAutoCleanup(): void {
if (!this.autoCleanupConfig || !this.autoCleanupConfig.enabled) {
return;
}
const config = this.autoCleanupConfig;
// Cleanup by file count if configured
if (config.maxFiles !== undefined && config.maxFiles > 0) {
this.cleanupOldLogFiles(config.maxFiles, config.logDir);
}
// Cleanup by total size if configured
if (config.maxSizeBytes !== undefined && config.maxSizeBytes > 0) {
this.cleanupLogFilesBySize(config.maxSizeBytes, config.logDir);
}
}
/**
* Enable or update auto-cleanup configuration at runtime.
*
* @param config - Auto-cleanup configuration
*/
public setAutoCleanup(config: FileStreamConfig["autoCleanup"]): void {
this.autoCleanupConfig = config;
}
/**
* Write a formatted log message to the file.
*
* @param message - The formatted log message
* @param event - The original log event (unused in this implementation)
*/
public write(message: string, event: LogEvent): void {
this.checkRotation();
if (this.fileHandle) {
this.fileHandle.write(message + "\n");
this.fileHandle.flush();
}
}
/**
* Close the file handle and release resources.
*/
public close(): void {
if (this.fileHandle) {
this.fileHandle.close();
this.fileHandle = undefined;
}
}
/**
* Search for log files matching the specified pattern in a directory.
*
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @returns Array of log file information including path, size, and modification time
*/
private searchLogFiles(
logDir?: string,
): Array<{ path: string; size: number; modified: number }> {
const directory = logDir || fs.getDir(this.filePath);
const splitStrs = this.filePath.split(".");
const name = splitStrs[0] + "_";
const ext = splitStrs.length > 1 ? splitStrs[1] : "log";
if (!fs.exists(directory) || !fs.isDir(directory)) {
return [];
}
const logFiles: Array<{
path: string;
size: number;
modified: number;
}> = [];
const files = fs.list(directory);
for (const file of files) {
const fullPath = fs.combine(directory, file);
if (
fs.isDir(fullPath) ||
!file.startsWith(name) ||
!file.endsWith(ext)
)
continue;
const attributes = fs.attributes(fullPath);
if (attributes !== undefined) {
logFiles.push({
path: fullPath,
size: attributes.size,
modified: attributes.modified,
});
}
}
return logFiles;
}
/**
* Clean up old log files by keeping only the specified number of most recent files.
*
* @param maxFiles - Maximum number of log files to keep
* @param logDir - Directory containing log files (defaults to directory of current log file)
*/
public cleanupOldLogFiles(maxFiles: number, logDir?: string): void {
if (maxFiles <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length <= maxFiles) return;
// Sort by modification time (newest first)
logFiles.sort((a, b) => b.modified - a.modified);
// Delete files beyond the limit
for (let i = maxFiles; i < logFiles.length; i++) {
try {
fs.delete(logFiles[i].path);
} catch (err) {
printError(
`Failed to delete old log file ${logFiles[i].path}: ${err}`,
);
}
}
}
/**
* Clean up log files by total size, deleting oldest files until total size is under limit.
*
* @param maxSizeBytes - Maximum total size in bytes for all log files
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @param fileName - Base File Name
*/
public cleanupLogFilesBySize(maxSizeBytes: number, logDir?: string): void {
if (maxSizeBytes <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length === 0) return;
// Calculate total size
let totalSize = 0;
for (const logFile of logFiles) {
totalSize += logFile.size;
}
// If total size is within limit, no cleanup needed
if (totalSize <= maxSizeBytes) {
return;
}
// Sort by modification time (oldest first for deletion)
logFiles.sort((a, b) => a.modified - b.modified);
// Delete oldest files until we're under the size limit
for (const logFile of logFiles) {
if (totalSize <= maxSizeBytes) {
break;
}
try {
fs.delete(logFile.path);
totalSize -= logFile.size;
} catch (err) {
printError(`Failed to delete log file ${logFile.path}: ${err}`);
}
}
}
}
/**
* Buffer stream that collects log messages in memory.
*
* This stream stores log messages in an internal buffer, which can be
* useful for testing, temporary storage, or implementing custom output
* logic that processes multiple messages at once.
*/
export class BufferStream implements Stream {
private buffer: string[] = [];
private maxSize: number;
/**
* Create a new buffer stream.
*
* @param maxSize - Maximum number of messages to store (0 = unlimited)
*/
constructor(maxSize: number = 0) {
this.maxSize = maxSize;
}
/**
* Write a formatted log message to the buffer.
*
* @param message - The formatted log message
* @param event - The original log event (unused in this implementation)
*/
public write(message: string, event: LogEvent): void {
this.buffer.push(message);
// Trim buffer if it exceeds max size
if (this.maxSize > 0 && this.buffer.length > this.maxSize) {
this.buffer.shift();
}
}
/**
* Get all buffered messages.
*
* @returns Array of all buffered log messages
*/
public getMessages(): string[] {
return [...this.buffer];
}
/**
* Get and clear all buffered messages.
*
* @returns Array of all buffered log messages
*/
public flush(): string[] {
const messages = [...this.buffer];
this.buffer = [];
return messages;
}
/**
* Clear the buffer without returning messages.
*/
public clear(): void {
this.buffer = [];
}
/**
* Get the current number of buffered messages.
*
* @returns Number of messages in the buffer
*/
public size(): number {
return this.buffer.length;
}
}
/**
* Null stream that discards all log messages.
*
* This stream can be useful for testing or when you want to temporarily
* disable logging output without reconfiguring the entire logger.
*/
export class NullStream implements Stream {
/**
* Discard the log message (do nothing).
*
* @param message - The formatted log message (ignored)
* @param event - The original log event (ignored)
*/
public write(message: string, event: LogEvent): void {
// Intentionally do nothing
}
}
/**
* Conditional stream that only writes messages meeting certain criteria.
*
* This stream wraps another stream and only forwards messages that
* match the specified condition.
*/
export class ConditionalStream implements Stream {
private targetStream: Stream;
private condition: (message: string, event: LogEvent) => boolean;
/**
* Create a new conditional stream.
*
* @param targetStream - The stream to write to when condition is met
* @param condition - Function that returns true to allow writing
*/
constructor(
targetStream: Stream,
condition: (message: string, event: LogEvent) => boolean,
) {
this.targetStream = targetStream;
this.condition = (message, event) => condition(message, event);
}
/**
* Write a formatted log message if the condition is met.
*
* @param message - The formatted log message
* @param event - The original log event
*/
public write(message: string, event: LogEvent): void {
if (this.condition(message, event)) {
this.targetStream.write(message, event);
}
}
/**
* Close the target stream.
*/
public close(): void {
if (this.targetStream.close) {
this.targetStream.close();
}
}
}
// Time constants for file rotation
export const SECOND = 1;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;
// Byte constants for file rotation
export const MB = 1024 * 1024;
export const KB = 1024;

View File

@@ -0,0 +1,107 @@
/**
* Core types for the ccStructLog library.
* This module defines the fundamental interfaces and types used throughout the logging system.
*/
/**
* Available log levels in order of severity.
*/
export enum LogLevel {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
Fatal = 5,
}
/**
* A log event represented as a key-value map.
* Uses Map to maintain insertion order of keys.
*/
export type LogEvent = Map<string, unknown>;
/**
* A processor function that can modify, filter, or enrich log events.
*
* @param event - The log event to process
* @returns The processed log event, or undefined to drop the log
*/
export type Processor = (event: LogEvent) => LogEvent | undefined;
/**
* A renderer function that converts a log event to a string representation.
*
* @param event - The final log event after all processing
* @returns The formatted string representation
*/
export type Renderer = (event: LogEvent) => string;
/**
* Interface for output streams that handle the final log output.
*/
export interface Stream {
/**
* Write a formatted log message to the output destination.
*
* @param message - The formatted log message
* @param event - The original log event for context
*/
write(message: string, event: LogEvent): void;
/**
* Close the stream and release any resources.
* Optional method for cleanup.
*/
close?(): void;
}
/**
* Configuration options for creating a Logger instance.
*/
export interface LoggerOptions {
/** Array of processors to apply to log events */
processors: Processor[];
/** Renderer to format the final log output */
renderer: Renderer;
/** Array of streams to output the formatted logs */
streams: Stream[];
}
/**
* Interface for the main Logger class.
*/
export interface ILogger {
/**
* Log a message at the specified level.
*
* @param level - The log level
* @param message - The log message
* @param context - Additional context data
*/
log(
level: LogLevel,
message: string,
context?: Record<string, unknown>,
): void;
/** Log at trace level */
trace(message: string, context?: Record<string, unknown>): void;
/** Log at debug level */
debug(message: string, context?: Record<string, unknown>): void;
/** Log at info level */
info(message: string, context?: Record<string, unknown>): void;
/** Log at warn level */
warn(message: string, context?: Record<string, unknown>): void;
/** Log at error level */
error(message: string, context?: Record<string, unknown>): void;
/** Log at fatal level */
fatal(message: string, context?: Record<string, unknown>): void;
}

View File

@@ -398,11 +398,17 @@ export class UIObject {
const newScrollX = Math.max(
0,
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX),
Math.min(
this.scrollProps.maxScrollX,
this.scrollProps.scrollX + deltaX,
),
);
const newScrollY = Math.max(
0,
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY),
Math.min(
this.scrollProps.maxScrollY,
this.scrollProps.scrollY + deltaY,
),
);
this.scrollProps.scrollX = newScrollX;

View File

@@ -5,10 +5,10 @@
import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer";
import { CCLog, HOUR } from "../ccLog";
import { setLogger } from "./context";
import { InputProps } from "./components";
import { Setter } from "./reactivity";
import { getStructLogger, Logger } from "@/lib/ccStructLog";
import { setLogger } from "./context";
/**
* Main application class
@@ -21,7 +21,7 @@ export class Application {
private focusedNode?: UIObject;
private termWidth: number;
private termHeight: number;
private logger: CCLog;
private logger: Logger;
private cursorBlinkState = false;
private lastBlinkTime = 0;
private readonly BLINK_INTERVAL = 0.5; // seconds
@@ -30,7 +30,7 @@ export class Application {
const [width, height] = term.getSize();
this.termWidth = width;
this.termHeight = height;
this.logger = new CCLog("tui_debug.log", false, HOUR);
this.logger = getStructLogger("ccTUI");
setLogger(this.logger);
this.logger.debug("Application constructed.");
}
@@ -95,7 +95,6 @@ export class Application {
this.root.unmount();
}
this.logger.close();
clearScreen();
}
@@ -220,8 +219,10 @@ export class Application {
| undefined;
if (type === "checkbox") {
// Toggle checkbox
const onChangeProp = (this.focusedNode.props as InputProps).onChange;
const checkedProp = (this.focusedNode.props as InputProps).checked;
const onChangeProp = (this.focusedNode.props as InputProps)
.onChange;
const checkedProp = (this.focusedNode.props as InputProps)
.checked;
if (
typeof onChangeProp === "function" &&
@@ -256,7 +257,10 @@ export class Application {
const valueProp = (this.focusedNode.props as InputProps).value;
const onInputProp = (this.focusedNode.props as InputProps).onInput;
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
if (
typeof valueProp !== "function" ||
typeof onInputProp !== "function"
) {
return;
}
@@ -269,7 +273,10 @@ export class Application {
this.needsRender = true;
} else if (key === keys.right) {
// Move cursor right
this.focusedNode.cursorPos = math.min(currentValue.length, cursorPos + 1);
this.focusedNode.cursorPos = math.min(
currentValue.length,
cursorPos + 1,
);
this.needsRender = true;
} else if (key === keys.backspace) {
// Delete character before cursor
@@ -297,11 +304,15 @@ export class Application {
* Handle character input events
*/
private handleCharEvent(char: string): void {
if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input"
) {
const type = (this.focusedNode.props as InputProps).type;
if (type !== "checkbox") {
// Insert character at cursor position
const onInputProp = (this.focusedNode.props as InputProps).onInput;
const onInputProp = (this.focusedNode.props as InputProps)
.onInput;
const valueProp = (this.focusedNode.props as InputProps).value;
if (
@@ -334,7 +345,10 @@ export class Application {
if (clicked !== undefined) {
this.logger.debug(
string.format("handleMouseClick: Found node of type %s.", clicked.type),
string.format(
"handleMouseClick: Found node of type %s.",
clicked.type,
),
);
// Set focus
if (
@@ -347,7 +361,8 @@ export class Application {
}
this.focusedNode = clicked;
if (typeof clicked.props.onFocusChanged === "function") {
const onFocusChanged = clicked.props.onFocusChanged as Setter<boolean>;
const onFocusChanged = clicked.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(true);
}
@@ -371,11 +386,15 @@ export class Application {
"handleMouseClick: onClick handler found, executing.",
);
(onClick as () => void)();
this.logger.debug("handleMouseClick: onClick handler finished.");
this.logger.debug(
"handleMouseClick: onClick handler finished.",
);
this.needsRender = true;
}
} else if (clicked.type === "input") {
const type = (clicked.props as InputProps).type as string | undefined;
const type = (clicked.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") {
const onChangeProp = (clicked.props as InputProps).onChange;
const checkedProp = (clicked.props as InputProps).checked;
@@ -393,7 +412,9 @@ export class Application {
this.needsRender = true;
} else {
this.logger.debug("handleMouseClick: No node found at click position.");
this.logger.debug(
"handleMouseClick: No node found at click position.",
);
}
}
@@ -428,7 +449,9 @@ export class Application {
);
// Only return interactive elements
if (node.type === "button" || node.type === "input") {
this.logger.debug("findNodeAt: Node is interactive, returning.");
this.logger.debug(
"findNodeAt: Node is interactive, returning.",
);
return node;
}
}
@@ -498,7 +521,9 @@ export class Application {
);
// Only return scrollable elements
if (node.type === "scroll-container") {
this.logger.debug("findNodeAt: Node is scrollable, returning.");
this.logger.debug(
"findNodeAt: Node is scrollable, returning.",
);
return node;
}
}

View File

@@ -167,12 +167,15 @@ export function label(
const sentences = createMemo(() => {
const words = splitByWhitespace(text());
const ret = concatSentence(words, 40);
context.logger?.debug(`label words changed : [ ${ret.join(",")} ]`);
context.logger?.debug(
`label words changed : [ ${ret.join(",")} ]`,
);
return ret;
});
const forNode = For({ class: `flex flex-col`, each: sentences }, (word) =>
label({ class: p.class }, word),
const forNode = For(
{ class: `flex flex-col`, each: sentences },
(word) => label({ class: p.class }, word),
);
return forNode;

View File

@@ -4,13 +4,13 @@
* to all components without prop drilling.
*/
import type { CCLog } from "../ccLog";
import { Logger } from "@/lib/ccStructLog";
/**
* The global context object for the TUI application.
* This will be set by the Application instance on creation.
*/
export const context: { logger: CCLog | undefined } = {
export const context: { logger: Logger | undefined } = {
logger: undefined,
};
@@ -18,6 +18,6 @@ export const context: { logger: CCLog | undefined } = {
* Sets the global logger instance.
* @param l The logger instance.
*/
export function setLogger(l: CCLog): void {
export function setLogger(l: Logger): void {
context.logger = l;
}

View File

@@ -62,7 +62,10 @@ function measureNode(
if (node.styleProps.width === "screen") {
const termSize = getTerminalSize();
measuredWidth = termSize.width;
} else if (node.styleProps.width === "full" && parentWidth !== undefined) {
} else if (
node.styleProps.width === "full" &&
parentWidth !== undefined
) {
measuredWidth = parentWidth;
} else if (typeof node.styleProps.width === "number") {
measuredWidth = node.styleProps.width;
@@ -297,7 +300,13 @@ export function calculateLayout(
const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size
calculateLayout(child, childSize.width, childSize.height, childX, childY);
calculateLayout(
child,
childSize.width,
childSize.height,
childX,
childY,
);
}
return;
}
@@ -368,7 +377,8 @@ export function calculateLayout(
// Cross axis (vertical) alignment
if (align === "center") {
childY = startY + math.floor((availableHeight - measure.height) / 2);
childY =
startY + math.floor((availableHeight - measure.height) / 2);
} else if (align === "end") {
childY = startY + (availableHeight - measure.height);
} else {
@@ -385,7 +395,8 @@ export function calculateLayout(
// Cross axis (horizontal) alignment
if (align === "center") {
childX = startX + math.floor((availableWidth - measure.width) / 2);
childX =
startX + math.floor((availableWidth - measure.width) / 2);
} else if (align === "end") {
childX = startX + (availableWidth - measure.width);
} else {

View File

@@ -75,10 +75,10 @@ export function createSignal<T>(initialValue: T): Signal<T> {
// Notify all subscribed listeners
if (batchDepth > 0) {
// In batch mode, collect effects to run later
listeners.forEach(listener => pendingEffects.add(listener));
listeners.forEach((listener) => pendingEffects.add(listener));
} else {
// Run effects immediately
listeners.forEach(listener => {
listeners.forEach((listener) => {
try {
listener();
} catch (e) {
@@ -152,7 +152,7 @@ export function batch(fn: () => void): void {
const effects = Array.from(pendingEffects);
pendingEffects.clear();
effects.forEach(effect => {
effects.forEach((effect) => {
try {
effect();
} catch (e) {

View File

@@ -18,7 +18,10 @@ function getTextContent(node: UIObject): string {
}
// For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) {
if (
node.children.length > 0 &&
node.children[0].textContent !== undefined
) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return child.textContent();
@@ -39,7 +42,11 @@ function isPositionVisible(
): boolean {
let current = node.parent;
while (current) {
if (isScrollContainer(current) && current.layout && current.scrollProps) {
if (
isScrollContainer(current) &&
current.layout &&
current.scrollProps
) {
const { x: containerX, y: containerY } = current.layout;
const { viewportWidth, viewportHeight } = current.scrollProps;
@@ -189,7 +196,9 @@ function drawNode(
}
case "input": {
const type = (node.props as InputProps).type as string | undefined;
const type = (node.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") {
// Draw checkbox
@@ -224,7 +233,11 @@ function drawNode(
const focusedBgColor = bgColor ?? colors.white;
const unfocusedBgColor = bgColor ?? colors.black;
if (displayText === "" && placeholder !== undefined && !focused) {
if (
displayText === "" &&
placeholder !== undefined &&
!focused
) {
displayText = placeholder;
showPlaceholder = true;
currentTextColor = currentTextColor ?? colors.gray;
@@ -235,7 +248,9 @@ function drawNode(
}
// Set background and clear the input area, creating a 1-character padding on the left
term.setBackgroundColor(focused ? focusedBgColor : unfocusedBgColor);
term.setBackgroundColor(
focused ? focusedBgColor : unfocusedBgColor,
);
term.setCursorPos(x, y);
term.write(" ".repeat(width));
@@ -247,7 +262,9 @@ function drawNode(
// Move text if it's too long for the padded area
const startDisPos =
cursorPos >= renderWidth ? cursorPos - renderWidth + 1 : 0;
cursorPos >= renderWidth
? cursorPos - renderWidth + 1
: 0;
const stopDisPos = startDisPos + renderWidth;
if (focused && !showPlaceholder && cursorBlinkState) {
@@ -271,7 +288,10 @@ function drawNode(
}
}
// Draw cursor at the end of the text if applicable
if (cursorPos === textToRender.length && cursorPos < renderWidth) {
if (
cursorPos === textToRender.length &&
cursorPos < renderWidth
) {
term.setBackgroundColor(currentTextColor);
term.setTextColor(focusedBgColor);
term.write(" ");
@@ -281,7 +301,9 @@ function drawNode(
}
} else {
// Not focused or no cursor, just write the text
term.write(textToRender.substring(startDisPos, stopDisPos));
term.write(
textToRender.substring(startDisPos, stopDisPos),
);
}
}
break;

View File

@@ -45,7 +45,9 @@ export interface SetStoreFunction<T> {
* setTodos([{ title: "First", done: false }]);
* ```
*/
export function createStore<T extends object>(initialValue: T): [Accessor<T>, SetStoreFunction<T>] {
export function createStore<T extends object>(
initialValue: T,
): [Accessor<T>, SetStoreFunction<T>] {
// Use a signal to track the entire state
const [get, set] = createSignal(initialValue);
@@ -88,8 +90,11 @@ export function createStore<T extends object>(initialValue: T): [Accessor<T>, Se
if (Array.isArray(current)) {
const newArray = [...current] as unknown[];
if (typeof newArray[index] === "object" && newArray[index] !== undefined) {
newArray[index] = { ...(newArray[index]!), [key]: value };
if (
typeof newArray[index] === "object" &&
newArray[index] !== undefined
) {
newArray[index] = { ...newArray[index]!, [key]: value };
}
set(newArray as T);
}

View File

View File

@@ -1,4 +1,4 @@
class ccDate {
export class ccDate {
private _timestamp: number;
constructor() {
@@ -21,5 +21,3 @@ class ccDate {
return this._timestamp;
}
}
export { ccDate };

View File

@@ -30,25 +30,22 @@ 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;
}
if (target instanceof Date) {
return new Date(target.getTime()) as any;
if (Array.isArray(target)) {
return (target as unknown[]).map((v: unknown) => deepCopy(v)) as T;
}
if (target instanceof Array) {
const cp = [] as any[];
(target as any[]).forEach((v) => { cp.push(v); });
return cp.map((n: any) => deepCopy<any>(n)) as any;
}
if (typeof target === 'object') {
const cp = { ...(target as { [key: string]: any }) } as { [key: string]: any };
Object.keys(cp).forEach(k => {
cp[k] = deepCopy<any>(cp[k]);
if (typeof target === "object") {
const cp = { ...(target as Record<string, unknown>) } as Record<
string,
unknown
>;
Object.keys(cp).forEach((k) => {
cp[k] = deepCopy<unknown>(cp[k]);
});
return cp as T;
}
return target;
};
@MohammadFakhreddin
}

105
src/lib/datatype/Queue.ts Normal file
View File

@@ -0,0 +1,105 @@
export class Node<T> {
public value: T;
public next?: Node<T>;
public prev?: Node<T>;
constructor(value: T, next?: Node<T>, prev?: Node<T>) {
this.value = value;
this.next = next;
this.prev = prev;
}
}
export class Queue<T> {
private _head?: Node<T>;
private _tail?: Node<T>;
private _size: number;
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 | T[]): void {
if (Array.isArray(data)) {
for (const val of data) {
this.enqueue(val);
}
return;
}
const node = new Node(data);
if (this._head === undefined) {
this._head = node;
this._tail = node;
} else {
this._tail!.next = node;
node.prev = this._tail;
this._tail = node;
}
this._size++;
}
public dequeue(): T | undefined {
if (this._head === undefined) return undefined;
const node = this._head;
this._head = node.next;
if (this._head !== undefined) this._head.prev = undefined;
this._size--;
return node.value;
}
public clear(): void {
this._head = undefined;
this._tail = undefined;
this._size = 0;
}
public peek(): T | undefined {
if (this._head === undefined) return undefined;
return this._head.value;
}
public size(): number {
return this._size;
}
public toArray(): T[] | undefined {
if (this._size === 0) return undefined;
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);
currentNode = currentNode.next!;
}
return array;
}
[Symbol.iterator](): Iterator<T> {
let currentNode = this._head;
return {
next(): IteratorResult<T> {
if (currentNode === undefined) {
return { value: undefined, done: true };
} else {
const data = currentNode.value;
currentNode = currentNode.next;
return { value: data, done: false };
}
},
};
}
}

View File

@@ -0,0 +1,86 @@
interface Priority<T> {
priority: number;
data: T;
}
export class SortedArray<T> {
private _data: Priority<T>[];
constructor(data?: Priority<T>[]) {
this._data = data ?? [];
}
private findIndex(priority: number): number {
const target = priority + 1;
let left = 0;
let right = this._data.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (this._data[mid].priority < target) {
left = mid + 1;
} else if (this._data[mid].priority > target) {
right = mid - 1;
} else {
right = mid - 1;
}
}
return left - 1;
}
public push(value: Priority<T>): void {
if (this._data.length === 0) {
this._data.push(value);
return;
} else if (this._data.length === 1) {
if (this._data[0].priority <= value.priority) this._data.push(value);
else this._data = [value, ...this._data];
return;
}
const index = this.findIndex(value.priority);
if (index === this._data.length - 1) {
if (this._data[index].priority <= value.priority) {
this._data.push(value);
} else {
this._data = [
...this._data.slice(0, index),
value,
...this._data.slice(index),
];
}
return;
}
const endIndex = index + 1;
this._data = [
...this._data.slice(0, endIndex),
value,
...this._data.slice(endIndex),
];
}
public shift(): T | undefined {
const value = this._data.shift();
return value?.data;
}
public pop(): T | undefined {
const value = this._data.pop();
return value?.data;
}
public peek(): T | undefined {
return this._data[0]?.data;
}
public toArray(): T[] {
return this._data.map(({ data }) => data);
}
public clear() {
this._data = [];
}
}

View 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);
}
}

171
src/lib/mutex/Semaphore.ts Normal file
View File

@@ -0,0 +1,171 @@
import { SortedArray } from "../datatype/SortedArray";
const E_CANCELED = new Error("Request canceled");
// const E_INSUFFICIENT_RESOURCES = new Error("Insufficient resources");
interface QueueEntry {
resolve(result: [number, () => void]): void;
reject(error: unknown): void;
weight: number;
}
interface Waiter {
resolve(): void;
}
type Releaser = () => void;
export class Semaphore {
private _value: number;
private _cancelError: Error;
private _queue = new SortedArray<QueueEntry>();
private _waiters = new SortedArray<Waiter>();
constructor(value: number, cancelError: Error = E_CANCELED) {
if (value < 0) {
throw new Error("Semaphore value must be non-negative");
}
this._value = value;
this._cancelError = cancelError;
}
acquire(weight = 1, priority = 0): Promise<[number, Releaser]> {
if (weight <= 0) {
throw new Error(`invalid weight ${weight}: must be positive`);
}
return new Promise((resolve, reject) => {
const entry: QueueEntry = { resolve, reject, weight };
if (this._queue.toArray().length === 0 && weight <= this._value) {
this._dispatchItem(entry);
} else {
this._queue.push({ priority, data: entry });
}
});
}
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 undefined;
}
this._value -= weight;
return this._newReleaser(weight);
}
async runExclusive<T>(
callback: (value: number) => T | Promise<T>,
weight = 1,
priority = 0,
): Promise<T> {
const [value, release] = await this.acquire(weight, priority);
try {
return await callback(value);
} finally {
release();
}
}
waitForUnlock(weight = 1, priority = 0): Promise<void> {
if (weight <= 0) {
throw new Error(`invalid weight ${weight}: must be positive`);
}
if (this._couldLockImmediately(weight)) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const waiter: Waiter = { resolve };
this._waiters.push({ priority, data: waiter });
});
}
isLocked(): boolean {
return this._value <= 0;
}
getValue(): number {
return this._value;
}
setValue(value: number): void {
if (value < 0) {
throw new Error("Semaphore value must be non-negative");
}
this._value = value;
this._dispatchQueue();
}
release(weight = 1): void {
if (weight <= 0) {
throw new Error(`invalid weight ${weight}: must be positive`);
}
this._value += weight;
this._dispatchQueue();
}
cancel(): void {
const queueItems = this._queue.toArray();
queueItems.forEach((entry) => entry.reject(this._cancelError));
this._queue.clear();
const waiters = this._waiters.toArray();
waiters.forEach((waiter) => waiter.resolve());
this._waiters.clear();
}
private _dispatchQueue(): void {
this._drainWaiters();
let next = this._peek();
while (next && next.weight <= this._value) {
const item = this._queue.shift();
if (item) {
this._dispatchItem(item);
}
this._drainWaiters();
next = this._peek();
}
}
private _dispatchItem(item: QueueEntry): void {
const previousValue = this._value;
this._value -= item.weight;
item.resolve([previousValue, this._newReleaser(item.weight)]);
}
private _peek(): QueueEntry | undefined {
return this._queue.peek();
}
private _newReleaser(weight: number): Releaser {
let called = false;
return () => {
if (called) return;
called = true;
this.release(weight);
};
}
private _drainWaiters(): void {
const waiters = this._waiters.toArray();
if (waiters.length === 0) return;
// If no queue or resources available, resolve all waiters
const hasQueue = this._queue.toArray().length > 0;
if (!hasQueue || this._value > 0) {
waiters.forEach((waiter) => waiter.resolve());
this._waiters.clear();
}
}
private _couldLockImmediately(weight: number): boolean {
return this._queue.toArray().length === 0 && weight <= this._value;
}
}

21
src/lib/thirdparty/ts-result-es/LICENSE vendored Normal file
View 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.

View File

@@ -0,0 +1,2 @@
export * from "./result";
export * from "./option";

View 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;
}
}

View 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;
}
}

View 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;
}

220
src/logExample/main.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Example usage of the ccStructLog library.
*
* This file demonstrates various ways to use the restructured logging system,
* including basic usage, custom configurations, and advanced scenarios.
*/
import {
Logger,
processor,
ConsoleStream,
FileStream,
BufferStream,
ConditionalStream,
HOUR,
jsonRenderer,
LogLevel,
textRenderer,
} from "../lib/ccStructLog";
// =============================================================================
// Custom Logger Configurations
// =============================================================================
print("\n=== Custom Logger Configurations ===");
// 4. Custom logger with specific processors and renderer
const customLogger = new Logger({
processors: [
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("CustomApp"),
processor.addStaticFields({
environment: "development",
version: "2.1.0",
}),
],
renderer: jsonRenderer,
streams: [
new ConsoleStream(),
new FileStream({
filePath: "custom.log",
rotationInterval: HOUR,
}),
],
});
customLogger.info("Custom logger example", {
feature: "user_management",
operation: "create_user",
});
// =============================================================================
// Advanced Processor Examples
// =============================================================================
print("\n=== Advanced Processor Examples ===");
// 6. Custom processors
const addRequestId = (event: Map<string, unknown>) => {
event.set("requestId", `req_${Math.random().toString(36).substr(2, 9)}`);
return event;
};
const sanitizePasswords = (event: Map<string, unknown>) => {
// Remove sensitive information
if (event.has("password")) {
event.set("password", "[REDACTED]");
}
if (event.has("token")) {
event.set("token", "[REDACTED]");
}
return event;
};
const secureLogger = new Logger({
processors: [
processor.addTimestamp(),
addRequestId,
sanitizePasswords,
processor.transformField("message", (msg) => `[SECURE] ${msg}`),
],
renderer: jsonRenderer,
streams: [new ConsoleStream()],
});
secureLogger.info("User login attempt", {
username: "john_doe",
password: "secret123",
token: "abc123def456",
});
// =============================================================================
// Stream Examples
// =============================================================================
print("\n=== Stream Examples ===");
// 11. Buffer stream for batch processing
const bufferStream = new BufferStream(100); // Keep last 100 messages
const bufferLogger = new Logger({
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), (msg, event) => {
if (event.get("level") === LogLevel.Info) {
return false;
} else {
return true;
}
}),
bufferStream,
],
});
// Log several messages
for (let i = 0; i < 5; i++) {
bufferLogger.info(`Buffered info message ${i}`, { iteration: i });
bufferLogger.warn(`Buffered warn message ${i}`, { iteration: i });
}
// Get all buffered messages
const bufferedMessages = bufferStream.getMessages();
print(`Buffered ${bufferedMessages.length} messages:`);
for (const msg of bufferedMessages) {
print(` ${msg}`);
}
// 12. Multi-stream with different formats
const multiFormatLogger = new Logger({
processors: [processor.addTimestamp(), processor.addComputerId()],
renderer: (event) => "default", // This won't be used
streams: [
// Console with human-readable format
{
write: (_, event) => {
const formatted = textRenderer(event);
new ConsoleStream().write(formatted, event);
},
},
// File with JSON format
{
write: (_, event) => {
const formatted = jsonRenderer(event);
new FileStream({ filePath: "structured.log" }).write(
formatted,
event,
);
},
},
],
});
multiFormatLogger.info("Multi-format message", {
feature: "logging",
test: true,
});
// =============================================================================
// Error Handling and Edge Cases
// =============================================================================
print("\n=== Error Handling Examples ===");
// 13. Robust error handling
const robustLogger = new Logger({
processors: [
processor.addTimestamp(),
// Processor that might fail
(event) => {
try {
// Simulate potential failure
if (Math.random() > 0.8) {
throw new Error("Processor failed");
}
event.set("processed", true);
return event;
} catch (error) {
// Log processor errors but don't break the chain
printError(`Processor error: ${String(error)}`);
event.set("processor_error", true);
return event;
}
},
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
// Log multiple messages to see error handling in action
for (let i = 0; i < 10; i++) {
robustLogger.info(`Message ${i}`, { attempt: i });
}
// =============================================================================
// Cleanup Examples
// =============================================================================
print("\n=== Cleanup Examples ===");
// 14. Proper cleanup
const fileLogger = new Logger({
processors: [processor.addTimestamp()],
renderer: jsonRenderer,
streams: [new FileStream({ filePath: "structured.log" })],
});
fileLogger.info("Temporary log entry");
// Clean shutdown - close all streams
fileLogger.close();
print("\n=== Examples Complete ===");
print("Check the generated log files:");
print("- app.log (daily rotation)");
print("- custom.log (hourly rotation)");
print("- all.log (complete log)");
print("- debug.log (detailed debug info)");
print("- structured.log (JSON format)");
print("- temp.log (temporary file, now closed)");

View File

@@ -1,3 +1,22 @@
import { testTimeBasedRotation } from "./testCcLog";
import { testTimeBasedRotation } from "./testCCLog";
import { testSortedArray } from "./testSortedArray";
import { testSemaphore } from "./testSemaphore";
import { testReadWriteLock } from "./testReadWriteLock";
testTimeBasedRotation();
testSortedArray();
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}`);
});

View File

@@ -1,35 +0,0 @@
import { CCLog, MINUTE, HOUR, SECOND } from "@/lib/ccLog";
// Test the new time-based rotation functionality
function testTimeBasedRotation() {
print("Testing time-based log rotation functionality...");
// Test with default interval (1 day)
const logger1 = new CCLog("test_log_default.txt");
logger1.info("This is a test message with default interval (1 day)");
// Test with custom interval (1 hour)
const logger2 = new CCLog("test_log_hourly.txt", HOUR);
logger2.info("This is a test message with 1-hour interval");
// Test with custom interval (30 minutes)
const logger3 = new CCLog("test_log_30min.txt", 30 * MINUTE);
logger3.info("This is a test message with 30-minute interval");
// Test with custom interval (5 seconds)
const logger4 = new CCLog("test_log_5sec.txt", 5 * SECOND);
logger4.info("This is a test message with 5-second interval");
for (let i = 0; i < 10; i++) {
logger4.info(`This is a test message with 5-second interval ${i}`);
sleep(1);
}
logger1.close();
logger2.close();
logger3.close();
logger4.close();
print("Test completed successfully!");
}
export { testTimeBasedRotation };

View 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!");
}

193
src/test/testSemaphore.ts Normal file
View File

@@ -0,0 +1,193 @@
import { Semaphore } from "@/lib/Semaphore";
function assert(condition: boolean, message: string) {
if (!condition) {
error(message);
}
}
async function testBasicAcquireRelease() {
print(" Running test: testBasicAcquireRelease");
const s = new Semaphore(1);
assert(s.getValue() === 1, "Initial value should be 1");
const [, release] = await s.acquire();
assert(s.getValue() === 0, "Value after acquire should be 0");
release();
assert(s.getValue() === 1, "Value after release should be 1");
print(" Test passed: testBasicAcquireRelease");
}
async function testRunExclusive() {
print(" Running test: testRunExclusive");
const s = new Semaphore(1);
let inside = false;
await s.runExclusive(() => {
inside = true;
assert(s.isLocked(), "Should be locked inside runExclusive");
assert(s.getValue() === 0, "Value should be 0 inside runExclusive");
});
assert(inside, "Callback should have been executed");
assert(!s.isLocked(), "Should be unlocked after runExclusive");
assert(s.getValue() === 1, "Value should be 1 after runExclusive");
print(" Test passed: testRunExclusive");
}
function testTryAcquire() {
print(" Running test: testTryAcquire");
const s = new Semaphore(1);
const release1 = s.tryAcquire();
assert(release1 !== null, "tryAcquire should succeed");
assert(s.getValue() === 0, "Value should be 0 after tryAcquire");
const release2 = s.tryAcquire();
assert(release2 === null, "tryAcquire should fail when locked");
release1!();
assert(s.getValue() === 1, "Value should be 1 after release");
const release3 = s.tryAcquire();
assert(release3 !== null, "tryAcquire should succeed again");
release3!();
print(" Test passed: testTryAcquire");
}
async function testQueueing() {
print(" Running test: testQueueing");
const s = new Semaphore(1);
const events: string[] = [];
// Take the lock
const [, release1] = await s.acquire();
events.push("acquired1");
// These two will be queued. Store their promises.
const p2 = s.acquire().then(([, release]) => {
events.push("acquired2");
events.push("released2");
release();
});
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
events.push("released1");
release1();
// Wait for all promises to finish
await Promise.all([p2, p3]);
const expected = [
"acquired1",
"released1",
"acquired2",
"released2",
"acquired3",
"released3",
];
assert(
textutils.serialiseJSON(events) === textutils.serialiseJSON(expected),
`Event order incorrect: got ${textutils.serialiseJSON(events)}`,
);
print(" Test passed: testQueueing");
}
async function testPriority() {
print(" Running test: testPriority");
const s = new Semaphore(1);
const events: string[] = [];
const [, release1] = await s.acquire();
events.push("acquired_main");
// Queue with low priority
const p1 = s.acquire(1, 10).then(([, release]) => {
events.push("acquired_low_prio");
release();
});
// Queue with high priority
const p2 = s.acquire(1, 1).then(([, release]) => {
events.push("acquired_high_prio");
release();
});
release1();
await Promise.all([p1, p2]);
const expected = ["acquired_main", "acquired_high_prio", "acquired_low_prio"];
assert(
textutils.serialiseJSON(events) === textutils.serialiseJSON(expected),
`Priority order incorrect: got ${textutils.serialiseJSON(events)}`,
);
print(" Test passed: testPriority");
}
async function testWaitForUnlock() {
print(" Running test: testWaitForUnlock");
const s = new Semaphore(1);
let waited = false;
const [, release] = await s.acquire();
assert(s.isLocked(), "Semaphore should be locked");
const p1 = s.waitForUnlock().then(() => {
waited = true;
assert(!s.isLocked(), "Should be unlocked when wait is over");
});
assert(!waited, "waitForUnlock should not resolve yet");
release();
await Promise.all([p1]);
assert(waited, "waitForUnlock should have resolved");
print(" Test passed: testWaitForUnlock");
}
async function testCancel() {
print(" Running test: testCancel");
const cancelError = new Error("Canceled for test");
const s = new Semaphore(1, cancelError);
let rejected = false;
const [, release] = await s.acquire();
s.acquire().then(
() => {
assert(false, "acquire should have been rejected");
},
(err) => {
assert(err === cancelError, "acquire rejected with wrong error");
rejected = true;
},
);
s.cancel();
assert(rejected, "pending acquire should have been rejected");
assert(s.getValue() === 0, "cancel should not affect current lock");
release();
assert(s.getValue() === 1, "release should still work");
print(" Test passed: testCancel");
}
export async function testSemaphore() {
print("Testing Semaphore...");
await testBasicAcquireRelease();
await testRunExclusive();
testTryAcquire();
await testQueueing();
await testPriority();
await testWaitForUnlock();
await testCancel();
print("Semaphore tests passed!");
}

View File

@@ -0,0 +1,62 @@
import { SortedArray } from "@/lib/SortedArray";
function assert(condition: boolean, message: string) {
if (!condition) {
error(message);
}
}
function assertDeepEquals(actual: object, expect: object, message: string) {
const jsonExpect = textutils.serialiseJSON(expect, {
allow_repetitions: true,
});
const jsonActual = textutils.serialiseJSON(actual, {
allow_repetitions: true,
});
if (jsonExpect !== jsonActual) {
error(`${message}: expected ${jsonExpect}, got ${jsonActual}`);
}
}
export function testSortedArray() {
print("Testing SortedArray...");
// Test constructor
const sortedArray = new SortedArray<string>();
assert(
sortedArray.toArray().length === 0,
"Constructor: initial length should be 0",
);
// Test push (FIFO)
const fifoArray = new SortedArray<string>([]);
fifoArray.push({ priority: 2, data: "b" });
fifoArray.push({ priority: 1, data: "a" });
fifoArray.push({ priority: 3, data: "c" });
fifoArray.push({ priority: 2, data: "b2" });
assertDeepEquals(fifoArray.toArray(), ["a", "b", "b2", "c"], "Push (FIFO)");
// Test shift
const shiftedValue = fifoArray.shift();
assert(shiftedValue === "a", "Shift: should return the first element");
assertDeepEquals(
fifoArray.toArray(),
["b", "b2", "c"],
"Shift: array should be modified",
);
// Test pop
const poppedValue = fifoArray.pop();
assert(poppedValue === "c", "Pop: should return the last element");
assertDeepEquals(
fifoArray.toArray(),
["b", "b2"],
"Pop: array should be modified",
);
// Test clear
fifoArray.clear();
assert(fifoArray.toArray().length === 0, "Clear: array should be empty");
print("SortedArray tests passed!");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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