Compare commits

..

4 Commits

Author SHA1 Message Date
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
9 changed files with 993 additions and 1108 deletions

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`.
@@ -31,6 +34,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
- **Component-Based:** Structure your UI into reusable components. See `src/tuiExample/main.ts` for a demo.
### 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.
@@ -40,6 +44,7 @@ A lightweight, functional-style framework for building command-line interfaces (
- **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.
@@ -54,6 +59,7 @@ A lightweight, functional-style framework for building command-line interfaces (
## Setup & Installation
1. **Clone the repository:**
```sh
git clone <repository-url>
cd cc-utils
@@ -69,6 +75,7 @@ A lightweight, functional-style framework for building command-line interfaces (
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
```
@@ -89,7 +96,7 @@ To deploy the built programs to your in-game computer, you need to configure the
```justfile
# Example for Linux
sync-path := "/home/user/.local/share/craftos-pc/computer/0/user/"
# Example for Windows
# sync-path := "/cygdrive/c/Users/YourUser/AppData/Roaming/CraftOS-PC/computer/0/user/"
```
@@ -105,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:**

View File

@@ -11,16 +11,37 @@ A modern, structured logging library for CC:Tweaked, inspired by Python's struct
## Quick Start
```typescript
import { createDevLogger } from "@/lib/ccStructLog";
The easiest way to get started is to create a `Logger` instance and configure it with processors, a renderer, and streams.
// Create a development logger
const logger = createDevLogger();
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
@@ -46,48 +67,69 @@ export enum LogLevel {
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).
## Pre-configured Loggers
## Common Configurations
### Development Logger
Optimized for development and debugging with human-readable console output.
A typical development logger is configured for human-readable console output with timestamps and colors.
```typescript
import { createDevLogger, LogLevel } from "@/lib/ccStructLog";
import {
Logger,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
const logger = createDevLogger({
source: "MyApp",
includeComputerId: true,
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()],
});
logger.debug("This is a debug message.");
devLogger.debug("This is a debug message.", { user: "dev" });
```
### Production Logger
Optimized for production with JSON-formatted file output and daily rotation.
```typescript
import { createProdLogger, DAY } from "@/lib/ccStructLog";
const logger = createProdLogger("app.log", {
source: "MyApp",
rotationInterval: DAY, // Rotate daily
includeConsole: false, // Don't log to console
});
logger.info("Application is running in production.");
```
## Custom Configuration
You can create a logger with a completely custom setup.
A production logger is often configured to write machine-readable JSON logs to a file with daily rotation.
```typescript
import {
Logger,
LogLevel,
addFullTimestamp,
addComputerId,
addSource,
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,
@@ -96,9 +138,9 @@ import {
const logger = new Logger({
processors: [
addFullTimestamp(),
addComputerId(),
addSource("MyApplication"),
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("MyApplication"),
],
renderer: jsonRenderer,
streams: [
@@ -112,31 +154,40 @@ logger.info("Custom logger reporting for duty.", { user: "admin" });
## Processors
Processors are functions that modify, enrich, or filter log events before they are rendered.
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 {
addTimestamp, // Add structured timestamp
addFormattedTimestamp, // Add HH:MM:SS string
addFullTimestamp, // Add YYYY-MM-DD HH:MM:SS string
filterByLevel, // Filter by minimum level
filterBy, // Filter based on a custom predicate
addSource, // Add source/logger name
addComputerId, // Add computer ID
addComputerLabel, // Add computer label
addStaticFields, // Add static fields to all events
transformField, // Transform a specific field's value
removeFields, // Remove sensitive fields
} from "@/lib/ccStructLog";
import { Logger, LogLevel, processor } from "@/lib/ccStructLog";
// Usage example
const logger = new Logger({
processors: [
addTimestamp(),
addSource("MyApp"),
filterByLevel(LogLevel.Warn), // Only allow Warn, Error, Fatal
removeFields(["password", "token"]),
// 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
});
@@ -172,10 +223,10 @@ Renderers convert the final `LogEvent` object into a string.
import { textRenderer, jsonRenderer } from "@/lib/ccStructLog";
// textRenderer: Human-readable, colored output for the console.
// Example: 15:30:45 [INFO] Message key=value
// Example: [15:30:45] [INFO] Message key=value
// jsonRenderer: Machine-readable JSON output.
// Example: {"level":"info","message":"Message","key":"value","timestamp":"..."}
// Example: {"level":2,"message":"Message","key":"value","timestamp":"15:30:45"}
```
## Streams
@@ -185,12 +236,14 @@ Streams handle the final output destination. You can use multiple streams to sen
### Built-in Streams
```typescript
import {
ConsoleStream, // Output to CC:Tweaked terminal with colors
FileStream, // Output to file with rotation support
BufferStream, // Store in an in-memory buffer
NullStream, // Discard all output
ConsoleStream,
FileStream,
BufferStream,
NullStream,
ConditionalStream,
LogLevel,
DAY,
} from "@/lib/ccStructLog";
import { ConditionalStream } from "@/lib/ccStructLog/streams"; // Note direct import
// File stream with daily rotation
const fileStream = new FileStream("app.log", DAY);
@@ -207,7 +260,7 @@ const errorStream = new ConditionalStream(
## File Rotation
`FileStream` supports automatic file rotation based on time intervals.
`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";
@@ -221,7 +274,7 @@ const dailyLog = new FileStream("app_daily.log", DAY);
// Rotate weekly
const weeklyLog = new FileStream("app_weekly.log", WEEK);
// No rotation
// No rotation (pass 0 or undefined)
const permanentLog = new FileStream("permanent.log", 0);
```
@@ -243,15 +296,22 @@ const permanentLog = new FileStream("permanent.log", 0);
- `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.
3. **Use a `source`**: Identify which component generated the log using `processor.addSource`.
```typescript
const logger = createDevLogger({ source: "UserService" });
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: [ removeFields(["password", "token"]) ],
processors: [ processor.removeFields(["password", "token"]) ],
//...
});
```

View File

@@ -1,4 +1,3 @@
import { CCLog, DAY, LogLevel } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
import { createAccessControlCli } from "./cli";
import { launchAccessControlTUI } from "./tui";
@@ -7,14 +6,31 @@ 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,
Logger,
LogLevel,
processor,
textRenderer,
} from "@/lib/ccStructLog";
const args = [...$vararg];
// Init Log
const logger = new CCLog("accesscontrol.log", {
printTerminal: true,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
let isOnConsoleStream = true;
const logger = new Logger({
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream),
new FileStream("accesscontrol.log", DAY),
],
});
// Load Config
@@ -26,7 +42,7 @@ logger.debug(textutils.serialise(config, { allow_repetitions: true }));
// Peripheral
const playerDetector = peripheral.find(
"playerDetector",
"playerDetector",
)[0] as PlayerDetectorPeripheral;
const chatBox = peripheral.find("chatBox")[0] as ChatBoxPeripheral;
const chatManager: ChatManager = new ChatManager([chatBox]);
@@ -37,410 +53,419 @@ let gWatchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
let gIsRunning = true;
interface ParseParams {
playerName?: string;
groupName?: string;
info?: PlayerInfo;
playerName?: string;
groupName?: string;
info?: PlayerInfo;
}
function reloadConfig() {
let releaser = configLock.tryAcquireWrite();
while (releaser === undefined) {
sleep(1);
releaser = configLock.tryAcquireWrite();
}
let releaser = configLock.tryAcquireWrite();
while (releaser === undefined) {
sleep(1);
releaser = configLock.tryAcquireWrite();
}
config = loadConfig(configFilepath)!;
gInRangePlayers = [];
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
config = loadConfig(configFilepath)!;
gInRangePlayers = [];
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
}
function safeParseTextComponent(
component: MinecraftTextComponent,
params?: ParseParams,
component: MinecraftTextComponent,
params?: ParseParams,
): MinecraftTextComponent {
const newComponent = deepCopy(component);
const newComponent = deepCopy(component);
if (newComponent.text == undefined) {
newComponent.text = "Wrong text, please contanct with admin";
} else if (newComponent.text.includes("%")) {
newComponent.text = newComponent.text.replace(
"%playerName%",
params?.playerName ?? "UnknowPlayer",
);
newComponent.text = newComponent.text.replace(
"%groupName%",
params?.groupName ?? "UnknowGroup",
);
newComponent.text = newComponent.text.replace(
"%playerPosX%",
params?.info?.x.toString() ?? "UnknowPosX",
);
newComponent.text = newComponent.text.replace(
"%playerPosY%",
params?.info?.y.toString() ?? "UnknowPosY",
);
newComponent.text = newComponent.text.replace(
"%playerPosZ%",
params?.info?.z.toString() ?? "UnknowPosZ",
);
}
return newComponent;
if (newComponent.text == undefined) {
newComponent.text = "Wrong text, please contanct with admin";
} else if (newComponent.text.includes("%")) {
newComponent.text = newComponent.text.replace(
"%playerName%",
params?.playerName ?? "UnknowPlayer",
);
newComponent.text = newComponent.text.replace(
"%groupName%",
params?.groupName ?? "UnknowGroup",
);
newComponent.text = newComponent.text.replace(
"%playerPosX%",
params?.info?.x.toString() ?? "UnknowPosX",
);
newComponent.text = newComponent.text.replace(
"%playerPosY%",
params?.info?.y.toString() ?? "UnknowPosY",
);
newComponent.text = newComponent.text.replace(
"%playerPosZ%",
params?.info?.z.toString() ?? "UnknowPosZ",
);
}
return newComponent;
}
function sendMessage(
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
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,
});
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();
releaser.release();
}
function sendToast(
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
chatManager.sendToast({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
title: safeParseTextComponent(
toastConfig.title ?? config.welcomeToastConfig.title,
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();
chatManager.sendToast({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
title: safeParseTextComponent(
toastConfig.title ?? config.welcomeToastConfig.title,
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 sendNotice(player: string, playerInfo?: PlayerInfo) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const onlinePlayers = playerDetector.getOnlinePlayers();
const noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
config.usersGroups
.filter((value) => value.isNotice)
.flatMap((value) => value.groupUsers ?? []),
);
logger.debug(`noticeTargetPlayers: ${noticeTargetPlayers.join(", ")}`);
const onlinePlayers = playerDetector.getOnlinePlayers();
const noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
config.usersGroups
.filter((value) => value.isNotice)
.flatMap((value) => value.groupUsers ?? []),
);
logger.debug(`noticeTargetPlayers: ${noticeTargetPlayers.join(", ")}`);
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(config.noticeToastConfig, targetPlayer, {
playerName: player,
info: playerInfo,
});
sleep(1);
}
releaser.release();
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(config.noticeToastConfig, targetPlayer, {
playerName: player,
info: playerInfo,
});
sleep(1);
}
releaser.release();
}
function sendWarn(player: string) {
const warnMsg = `Not Allowed Player ${player} Break in Home `;
logger.warn(warnMsg);
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 (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
const watchPlayerNames = gWatchPlayersInfo.flatMap((value) => value.name);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name);
if (gInRangePlayers.includes(player.name)) {
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
player.hasNoticeTimes += 1;
}
// Warn
if (config.isWarn) sendWarn(player.name);
// Record
logger.warn(
`Stranger ${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
// Get rid of player from list
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
`Stranger ${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
}
os.sleep(1);
}
releaser.release();
os.sleep(config.watchInterval);
}
}
function mainLoop() {
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 (gInRangePlayers.includes(player)) continue;
// Get player Info
const playerInfo = playerDetector.getPlayerPos(player);
if (config.adminGroupConfig.groupUsers.includes(player)) {
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
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;
groupConfig = userGroupConfig;
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);
gWatchPlayersInfo = [
...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 },
];
}
gInRangePlayers = players;
releaser.release();
os.sleep(config.detectInterval);
}
}
function keyboardLoop() {
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);
launchAccessControlTUI();
logger.info("TUI closed, resuming normal operation");
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
} finally {
logger.setInTerminal(true);
reloadConfig();
}
} else if (event.key === keys.r) {
reloadConfig();
}
// else if (event.key === keys.q) {
// gIsRunning = false;
// }
}
}
function cliLoop() {
let printTargetPlayer: string | undefined;
const cli = createAccessControlCli({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
logger: logger,
print: (msg) =>
chatManager.sendMessage({
message: msg,
targetPlayer: printTargetPlayer,
prefix: "Access Control System",
brackets: "[]",
utf8Support: true,
}),
});
while (gIsRunning) {
const result = chatManager.getReceivedMessage();
if (result.isErr()) {
sleep(0.5);
continue;
}
logger.debug(`Received message: ${result.value.message}`);
const ev = result.value;
const warnMsg = `Not Allowed Player ${player} Break in Home `;
logger.warn(warnMsg);
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const isAdmin = config.adminGroupConfig.groupUsers.includes(ev.username);
sendToast(config.warnToastConfig, player, { playerName: player });
chatManager.sendMessage({
message: safeParseTextComponent(config.warnToastConfig.msg, {
playerName: player,
}),
targetPlayer: player,
prefix: "AccessControl",
brackets: "[]",
utf8Support: true,
});
releaser.release();
if (!isAdmin) continue;
if (!ev.message.startsWith("@AC")) continue;
}
printTargetPlayer = ev.username;
logger.info(
`Received command "${ev.message}" from admin ${printTargetPlayer}`,
);
function watchLoop() {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
const commandArgs = ev.message
.substring(3)
.split(" ")
.filter((s) => s.length > 0);
logger.debug(`Command arguments: ${commandArgs.join(", ")}`);
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 (gInRangePlayers.includes(player.name)) {
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
player.hasNoticeTimes += 1;
}
cli(commandArgs);
printTargetPlayer = undefined;
}
// Warn
if (config.isWarn) sendWarn(player.name);
// Record
logger.warn(
`Stranger ${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
// Get rid of player from list
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
`Stranger ${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
}
os.sleep(1);
}
releaser.release();
os.sleep(config.watchInterval);
}
}
function mainLoop() {
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 (gInRangePlayers.includes(player)) continue;
// Get player Info
const playerInfo = playerDetector.getPlayerPos(player);
if (config.adminGroupConfig.groupUsers.includes(player)) {
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
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;
groupConfig = userGroupConfig;
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);
gWatchPlayersInfo = [
...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 },
];
}
gInRangePlayers = players;
releaser.release();
os.sleep(config.detectInterval);
}
}
function keyboardLoop() {
while (gIsRunning) {
const event = pullEventAs(KeyEvent, "key");
if (event === undefined) continue;
if (event.key === keys.c) {
logger.info("Launching Access Control TUI...");
try {
isOnConsoleStream = false;
launchAccessControlTUI();
logger.info("TUI closed, resuming normal operation");
} catch (error) {
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
} finally {
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;
}
}
function main(args: string[]) {
logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) {
if (args[0] == "start") {
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(),
);
logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) {
if (args[0] == "start") {
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(),
);
return;
} else if (args[0] == "config") {
logger.info("Launching Access Control TUI...");
logger.setInTerminal(false);
try {
launchAccessControlTUI();
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
}
return;
return;
} else if (args[0] == "config") {
logger.info("Launching Access Control TUI...");
isOnConsoleStream = false;
try {
launchAccessControlTUI();
} catch (error) {
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
}
return;
}
}
}
print(`Usage: accesscontrol start | config`);
print(" start - Start the access control system with monitoring");
print(" config - Open configuration TUI");
print(`Usage: accesscontrol start | config`);
print(" start - Start the access control system with monitoring");
print(" config - Open configuration TUI");
}
try {
main(args);
main(args);
} catch (error: unknown) {
logger.error(textutils.serialise(error as object));
logger.error(textutils.serialise(error as object));
} finally {
logger.close();
logger.close();
}

View File

@@ -1,215 +1,262 @@
import {
CraftManager,
CraftRecipe,
CreatePackageTag,
CraftManager,
CraftRecipe,
CreatePackageTag,
} from "@/lib/CraftManager";
import { CCLog, LogLevel } from "@/lib/ccLog";
import { Queue } from "@/lib/datatype/Queue";
import {
ConsoleStream,
Logger,
LogLevel,
processor,
textRenderer,
} from "@/lib/ccStructLog";
const logger = new CCLog("autocraft.log", { outputMinLevel: LogLevel.Info });
const logger = new Logger({
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
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",
// packsInventory: "minecraft:chest_14",
// itemsInventory: "minecraft:chest_15",
// packageExtractor: "create:packager_3",
blockReader: "bottom",
wiredModem: "right",
redstone: "left",
packsInventory: "minecraft:chest_1121",
itemsInventory: "minecraft:chest_1120",
packageExtractor: "create:packager_0",
};
const packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
const itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
const packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
const blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
const wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
const turtleLocalName = wiredModem.getNameLocal();
let packsInventory: InventoryPeripheral;
let itemsInventory: InventoryPeripheral;
let packageExtractor: InventoryPeripheral;
let blockReader: BlockReaderPeripheral;
let wiredModem: WiredModemPeripheral;
let turtleLocalName: string;
enum State {
IDLE,
READ_RECIPE,
CRAFT_OUTPUT,
IDLE,
READ_RECIPE,
CRAFT_OUTPUT,
}
function main() {
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);
}
while (true) {
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("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;
}
logger.info("Peripheral initialization complete...");
break;
} catch (error) {
logger.warn(
`Peripheral initialization failed for ${String(error)}, try again...`,
);
sleep(1);
}
}
// State logic
switch (currentState) {
case State.IDLE: {
if (!hasPackage) os.pullEvent("redstone");
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);
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
logger.debug(
`Turtle:\n${textutils.serialise(blockReader.getBlockData()!, { allow_repetitions: true })}`,
);
const packageDetailInfo = blockReader.getBlockData()?.Items[1];
if (packageDetailInfo === undefined) {
logger.error(`Package detail info not found`);
continue;
}
// Get OrderId and isFinal
const packageOrderId = (packageDetailInfo.tag as CreatePackageTag)
.Fragment.OrderId;
const packageIsFinal =
(packageDetailInfo.tag as CreatePackageTag).Fragment.IsFinal > 0
? true
: false;
// Get recipe
const packageRecipes =
CraftManager.getPackageRecipe(packageDetailInfo);
if (packageRecipes.isSome()) {
if (packageIsFinal) recipesQueue.enqueue(packageRecipes.value);
else recipesWaitingMap.set(packageOrderId, packageRecipes.value);
} else {
if (packageIsFinal && recipesWaitingMap.has(packageOrderId)) {
recipesQueue.enqueue(recipesWaitingMap.get(packageOrderId)!);
recipesWaitingMap.delete(packageOrderId);
} else {
logger.debug(`No recipe, just pass`);
}
}
packageExtractor.pullItems(turtleLocalName, 1);
}
if (
currentState === State.READ_RECIPE &&
nextState === State.CRAFT_OUTPUT
) {
craftManager.initItemsMap();
}
break;
}
case State.CRAFT_OUTPUT: {
// Check recipe
const recipe = recipesQueue.dequeue();
if (recipe === undefined) break;
let restCraftCnt = recipe.Count;
let maxSignleCraftCnt = restCraftCnt;
let craftItemDetail: ItemDetail | undefined = undefined;
do {
// Clear workbench
craftManager.clearTurtle();
logger.info(`Pull items according to a recipe`);
const craftCnt = craftManager
.pullItemsWithRecipe(recipe, maxSignleCraftCnt)
.unwrapOrElse((error) => {
logger.error(error.message);
return 0;
});
if (craftCnt == 0) break;
if (craftCnt < maxSignleCraftCnt) maxSignleCraftCnt = craftCnt;
const craftRet = craftManager.craft(maxSignleCraftCnt);
craftItemDetail ??= craftRet;
logger.info(`Craft ${craftCnt} times`);
restCraftCnt -= craftCnt;
} while (restCraftCnt > 0);
// Finally output
if (restCraftCnt > 0) {
logger.warn(
`Only craft ${recipe.Count - restCraftCnt}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
} else {
logger.info(
`Finish craft ${recipe.Count}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
}
// Clear workbench and inventory
const turtleItemSlots = Object.values(
blockReader.getBlockData()!.Items,
).map((val) => val.Slot + 1);
craftManager.clearTurtle(turtleItemSlots);
break;
}
default: {
logger.warn("redstone activated when init, please clear inventory");
sleep(1);
break;
}
}
// Check packages
hasPackage = redstone.getInput(peripheralsNames.redstone);
// State update
currentState = nextState;
}
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
logger.debug(
`Turtle:\n${textutils.serialise(blockReader.getBlockData()!, { allow_repetitions: true })}`,
);
const packageDetailInfo =
blockReader.getBlockData()?.Items[1];
if (packageDetailInfo === undefined) {
logger.error(`Package detail info not found`);
continue;
}
// Get OrderId and isFinal
const packageOrderId = (
packageDetailInfo.tag as CreatePackageTag
).Fragment.OrderId;
const packageIsFinal =
(packageDetailInfo.tag as CreatePackageTag).Fragment
.IsFinal > 0
? true
: false;
// Get recipe
const packageRecipes =
CraftManager.getPackageRecipe(packageDetailInfo);
if (packageRecipes.isSome()) {
if (packageIsFinal)
recipesQueue.enqueue(packageRecipes.value);
else
recipesWaitingMap.set(
packageOrderId,
packageRecipes.value,
);
} else {
if (
packageIsFinal &&
recipesWaitingMap.has(packageOrderId)
) {
recipesQueue.enqueue(
recipesWaitingMap.get(packageOrderId)!,
);
recipesWaitingMap.delete(packageOrderId);
} else {
logger.debug(`No recipe, just pass`);
}
}
packageExtractor.pullItems(turtleLocalName, 1);
}
if (
currentState === State.READ_RECIPE &&
nextState === State.CRAFT_OUTPUT
) {
craftManager.initItemsMap();
}
break;
}
case State.CRAFT_OUTPUT: {
// Check recipe
const recipe = recipesQueue.dequeue();
if (recipe === undefined) break;
let restCraftCnt = recipe.Count;
let maxSignleCraftCnt = restCraftCnt;
let craftItemDetail: ItemDetail | undefined = undefined;
do {
// Clear workbench
craftManager.clearTurtle();
logger.info(`Pull items according to a recipe`);
const craftCnt = craftManager
.pullItemsWithRecipe(recipe, maxSignleCraftCnt)
.unwrapOrElse((error) => {
logger.error(error.message);
return 0;
});
if (craftCnt == 0) break;
if (craftCnt < maxSignleCraftCnt)
maxSignleCraftCnt = craftCnt;
const craftRet = craftManager.craft(maxSignleCraftCnt);
craftItemDetail ??= craftRet;
logger.info(`Craft ${craftCnt} times`);
restCraftCnt -= craftCnt;
} while (restCraftCnt > 0);
// Finally output
if (restCraftCnt > 0) {
logger.warn(
`Only craft ${recipe.Count - restCraftCnt}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
} else {
logger.info(
`Finish craft ${recipe.Count}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
}
// 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();
main();
} catch (error: unknown) {
logger.error(textutils.serialise(error as object));
logger.error(textutils.serialise(error as object));
} finally {
logger.close();
logger.close();
}

View File

@@ -7,150 +7,14 @@
*/
// Re-export all core types and classes
export {
LogLevel,
LogEvent,
Processor,
Renderer,
Stream,
LoggerOptions,
ILogger,
} from "./types";
export { Logger } from "./Logger";
export * from "./types";
export * from "./Logger";
// Re-export all processors
export {
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
filterByLevel,
addSource,
addComputerId,
addComputerLabel,
filterBy,
transformField,
removeFields,
addStaticFields,
} from "./processors";
export * from "./processors";
// Re-export all renderers
export { jsonRenderer, textRenderer } from "./renderers";
export * from "./renderers";
// Re-export all streams
export {
ConsoleStream,
FileStream,
BufferStream,
NullStream,
SECOND,
MINUTE,
HOUR,
DAY,
WEEK,
} from "./streams";
import { Logger } from "./Logger";
import { LogLevel, LogEvent } from "./types";
import {
addFormattedTimestamp,
addFullTimestamp,
addComputerId,
} from "./processors";
import { textRenderer, jsonRenderer } from "./renderers";
import { ConsoleStream, FileStream, DAY } from "./streams";
/**
* Create a development logger with console output and colored formatting.
*
* This logger is optimized for development and debugging, with:
* - Debug level and above
* - Formatted timestamps
* - Computer ID tracking
* - Human-readable console output with colors
*
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for development
*/
export function createDevLogger(
options: {
level?: LogLevel;
source?: string;
includeComputerId?: boolean;
} = {},
): Logger {
const processors = [addFormattedTimestamp()];
if (options.includeComputerId !== false) {
processors.push(addComputerId());
}
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
return new Logger({
processors,
renderer: textRenderer,
streams: [new ConsoleStream()],
});
}
/**
* Create a production logger with file output and JSON formatting.
*
* This logger is optimized for production environments, with:
* - Info level and above
* - Full timestamps
* - Computer ID and label tracking
* - JSON output for machine processing
* - Daily file rotation
*
* @param filename - Base filename for log files
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for production
*/
export function createProdLogger(
filename: string,
options: {
level?: LogLevel;
source?: string;
rotationInterval?: number;
includeConsole?: boolean;
} = {},
): Logger {
const processors = [
addFullTimestamp(),
addComputerId(),
(event: LogEvent) => {
const label = os.getComputerLabel();
if (label) {
event.set("computer_label", label);
}
return event;
},
];
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
const streams: Array<ConsoleStream | FileStream> = [
new FileStream(filename, options.rotationInterval ?? DAY),
];
if (options.includeConsole) {
streams.push(new ConsoleStream());
}
return new Logger({
processors,
renderer: jsonRenderer,
streams,
});
}
export * from "./streams";

View File

@@ -8,213 +8,196 @@
import { LogEvent, Processor, LogLevel } from "./types";
/**
* Adds a timestamp to the log event.
*
* This processor adds the current time as a structured timestamp object
* using CC:Tweaked's os.date() function. The timestamp includes year,
* month, day, hour, minute, and second components.
*
* Performance note: os.date() is relatively expensive, so this should
* typically be placed early in the processor chain and used only once.
*
* @param event - The log event to process
* @returns The event with timestamp added
*/
export function addTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
event.set("timestamp", timestamp);
return event;
};
}
/**
* Adds a human-readable timestamp string to the log event.
*
* This processor adds a formatted timestamp string that's easier to read
* in log output. The format is "HH:MM:SS" in UTC time.
*
* @param event - The log event to process
* @returns The event with formatted timestamp added
*/
export function addFormattedTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const timeStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("time", timeStr);
return event;
};
}
/**
* Adds a full ISO-like timestamp string to the log event.
*
* This processor adds a complete timestamp in YYYY-MM-DD HH:MM:SS format
* which is useful for file logging and structured output.
*
* @param event - The log event to process
* @returns The event with full timestamp added
*/
export function addFullTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const fullTimeStr = `${timestamp.year}-${string.format("%02d", timestamp.month)}-${string.format("%02d", timestamp.day)} ${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("datetime", fullTimeStr);
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 !== undefined && eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
/**
* Adds a logger name/source to the log event.
*
* This processor is useful when you have multiple loggers in your application
* and want to identify which component generated each log entry.
*
* @param name - The name/source to add to log events
* @returns A processor function that adds the source name
*/
export function addSource(name: string): Processor {
return (event) => {
event.set("source", name);
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;
};
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 !== undefined && eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
/**
* Adds a logger name/source to the log event.
*
* This processor is useful when you have multiple loggers in your application
* and want to identify which component generated each log entry.
*
* @param name - The name/source to add to log events
* @returns A processor function that adds the source name
*/
export function addSource(name: string): Processor {
return (event) => {
event.set("source", name);
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

@@ -6,7 +6,7 @@
* different output formats (JSON, console-friendly, etc.).
*/
import { Renderer } from "./types";
import { LogLevel, Renderer } from "./types";
/**
* Renders log events as JSON strings.
@@ -43,44 +43,30 @@ export const jsonRenderer: Renderer = (event) => {
* timestamp, level, message, and additional context fields formatted
* in a readable way.
*
* Format: [HH:MM:SS] [LEVEL] message { key=value, key2=value2 }
* 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 timestamp = event.get("timestamp") as LuaDate | undefined;
const timeStr = event.get("time") as string | undefined;
const level = (event.get("level") as string)?.toUpperCase() ?? "UNKNOWN";
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) ?? "";
// Format timestamp
let timestampStr = "";
if (timeStr) {
timestampStr = timeStr;
} else if (timestamp) {
timestampStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
}
// Start building the output
let output = `[${timestampStr}] [${level}] ${message}`;
let output = `[${timeStr}] [${level}] ${message} \t`;
// Add context fields (excluding the core fields we already used)
const contextFields: string[] = [];
for (const [key, value] of event.entries()) {
if (
key !== "timestamp" &&
key !== "time" &&
key !== "level" &&
key !== "message"
) {
if (key !== "timestamp" && key !== "level" && key !== "message") {
contextFields.push(`${key}=${tostring(value)}`);
}
}
if (contextFields.length > 0) {
output += ` { ${contextFields.join(", ")} }`;
output += contextFields.join(", ");
}
return output;

View File

@@ -6,7 +6,7 @@
* implements the Stream interface and handles its own output logic.
*/
import { Stream, LogEvent } from "./types";
import { LogLevel, Stream, LogEvent } from "./types";
/**
* Console stream that outputs to the CC:Tweaked terminal.
@@ -16,14 +16,14 @@ import { Stream, LogEvent } from "./types";
* color after writing each message.
*/
export class ConsoleStream implements Stream {
private levelColors: Map<string, number> = new Map([
["trace", colors.lightGray],
["debug", colors.gray],
["info", colors.white],
["warn", colors.orange],
["error", colors.red],
["fatal", colors.red],
]);
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.
@@ -32,8 +32,9 @@ export class ConsoleStream implements Stream {
* @param event - The original log event for context (used for level-based coloring)
*/
public write(message: string, event: LogEvent): void {
const level = event.get("level") as string | undefined;
const color = level ? this.levelColors.get(level) : undefined;
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();

View File

@@ -7,56 +7,16 @@
import {
Logger,
createDevLogger,
createProdLogger,
// Processors
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
addSource,
addComputerId,
addStaticFields,
transformField,
// Renderers
textRenderer,
jsonRenderer,
// Streams
processor,
ConsoleStream,
FileStream,
BufferStream,
DAY,
ConditionalStream,
HOUR,
jsonRenderer,
LogLevel,
textRenderer,
} from "../lib/ccStructLog";
import { ConditionalStream } from "@/lib/ccStructLog/streams";
// =============================================================================
// Basic Usage Examples
// =============================================================================
print("=== Basic Usage Examples ===");
// 1. Quick start with pre-configured loggers
const devLog = createDevLogger();
devLog.info("Application started", { version: "1.0.0", port: 8080 });
devLog.debug("Debug information", { userId: 123, action: "login" });
devLog.error("Something went wrong", {
error: "Connection failed",
retries: 3,
});
// 2. Production logging to file
const prodLog = createProdLogger("app.log", {
source: "MyApplication",
rotationInterval: DAY,
includeConsole: true,
});
prodLog.info("User action", { userId: 456, action: "purchase", amount: 29.99 });
prodLog.warn("Low disk space", { available: 1024, threshold: 2048 });
// =============================================================================
// Custom Logger Configurations
@@ -67,10 +27,10 @@ print("\n=== Custom Logger Configurations ===");
// 4. Custom logger with specific processors and renderer
const customLogger = new Logger({
processors: [
addFullTimestamp(),
addComputerId(),
addSource("CustomApp"),
addStaticFields({
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("CustomApp"),
processor.addStaticFields({
environment: "development",
version: "2.1.0",
}),
@@ -109,10 +69,10 @@ const sanitizePasswords = (event: Map<string, unknown>) => {
const secureLogger = new Logger({
processors: [
addTimestamp(),
processor.addTimestamp(),
addRequestId,
sanitizePasswords,
transformField("message", (msg) => `[SECURE] ${msg}`),
processor.transformField("message", (msg) => `[SECURE] ${msg}`),
],
renderer: jsonRenderer,
streams: [new ConsoleStream()],
@@ -133,7 +93,7 @@ print("\n=== Stream Examples ===");
// 11. Buffer stream for batch processing
const bufferStream = new BufferStream(100); // Keep last 100 messages
const bufferLogger = new Logger({
processors: [addFormattedTimestamp()],
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), (msg, event) => {
@@ -162,7 +122,7 @@ for (const msg of bufferedMessages) {
// 12. Multi-stream with different formats
const multiFormatLogger = new Logger({
processors: [addFullTimestamp(), addComputerId()],
processors: [processor.addTimestamp(), processor.addComputerId()],
renderer: (event) => "default", // This won't be used
streams: [
// Console with human-readable format
@@ -196,7 +156,7 @@ print("\n=== Error Handling Examples ===");
// 13. Robust error handling
const robustLogger = new Logger({
processors: [
addTimestamp(),
processor.addTimestamp(),
// Processor that might fail
(event) => {
try {
@@ -231,7 +191,7 @@ print("\n=== Cleanup Examples ===");
// 14. Proper cleanup
const fileLogger = new Logger({
processors: [addTimestamp()],
processors: [processor.addTimestamp()],
renderer: jsonRenderer,
streams: [new FileStream("temp.log")],
});
@@ -249,37 +209,3 @@ print("- all.log (complete log)");
print("- debug.log (detailed debug info)");
print("- structured.log (JSON format)");
print("- temp.log (temporary file, now closed)");
// =============================================================================
// Performance Comparison (commented out to avoid noise)
// =============================================================================
/*
print("\n=== Performance Comparison ===");
const iterations = 1000;
// Test simple console logging
const startTime1 = os.clock();
const simpleLogger = createMinimalLogger();
for (let i = 0; i < iterations; i++) {
simpleLogger.info(`Simple message ${i}`);
}
const endTime1 = os.clock();
print(`Simple Console Logger: ${endTime1 - startTime1} seconds`);
// Test complex processor chain
const startTime2 = os.clock();
const complexLogger = createDetailedLogger("perf_test.log", {
source: "PerfTest"
});
for (let i = 0; i < iterations; i++) {
complexLogger.info(`Complex message ${i}`, {
iteration: i,
data: { nested: { value: i * 2 } }
});
}
complexLogger.close();
const endTime2 = os.clock();
print(`Complex Processor Chain: ${endTime2 - startTime2} seconds`);
*/