Skip to content

Intro to Macro and Task

Peter Jiang edited this page Jun 18, 2024 · 6 revisions

⚠️ WARNING: The macro system is in beta, so

  • The API is not stable
  • Breaking changes can be made at any time without warning
  • The documentation is not complete
  • The macro system is not fully tested

Last updated for: v0.5.0

Prerequisites

  • Intermediate TypeScript knowledge (async/await, OOP, etc)
  • Familiarity with Deno
  • Familiarity with Lodestone on a user level

What is a macro?

A macro is a TypeScript module that can be run by Lodestone Core dynamically. In other words, a macro is a plugin for Lodestone. (We don't use the word plugin because it's used in the context of Minecraft)

View example macros here

What is a task?

A task is a running instance of a macro.

In simpler terms:

Macro = an executable

Task = a process

A task can:

  • Be started and stopped at will
  • Access internal states of Lodestone, such as
    • The state of any instance
    • Read the console output of any instance
  • Change internal states of Lodestone, such as
    • Start and stop any instance
    • Send commands to any instance
  • Listen to real-time events
    • Instance state changes
    • Console output of any instance
  • Do anything a normal Deno program can do

Why?

The macro system allows you to extend the functionality of Lodestone. You can write a macro that does anything from backing up your world to automatically restarting your instance to a discord bot that allows you to control your instance.

Getting started with writing your first task.

Setup

  1. Macros are executed in a modified Deno runtime, so it is recommended that you follow their environment setup.

Note that this is not strictly necessary, but will provide you with a better developer experience.

  1. Download Lodestone CLI.

Install the latest version with

lodestone_cli -v v0.5.0

Writing your first prelaunch.ts

Create an instance with prelaunch.ts at the root of the instance.

This macro will be executed before the instance is launched.

macro_tutorial_1

Paste in the following code:

import { EventStream } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/events.ts";
import { Instance } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/instance.ts";

const current = await Instance.current()

const eventStream = new EventStream(current.getUUID(), await current.name());

console.log("Start macro");

eventStream.emitConsoleOut("Hello world!");

console.log("End macro");

Start the instance, you should see "Hello World!" being printed in the console.

image

If you inspect the terminal Lodestone CLI is running in, you should see "Start macro" and "End macro" in between the other logs

image

Dissecting what's going on

  • We first imported a library via a URL. This is a feature of Deno, which allows you to import modules from anywhere on the internet. In this case, we are importing a library that allows us to interact with Lodestone
  • We use the Deno built in console.log function to print something to the stdout of the program, which is different from the instance's console
  • Each task is executed under the context of an instance, so we can get a handle to the instance the task is running in with Instance.current()
  • We then get the instance's UUID and name, and create an EventStream with it. This allows us to interact with Lodestone's event system
  • We then use the EventStream to emit a console output event, which will be printed in the instance's console

A more complex macro : automatic backups

One of a commonly requested feature is to have automatic backups. Let's write a macro that does that.

import { format } from "https://deno.land/std@0.177.1/datetime/format.ts";
import { copy } from "https://deno.land/std@0.191.0/fs/copy.ts";
import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import { EventStream } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/events.ts";
import { lodestoneVersion } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/prelude.ts";
import { MinecraftJavaInstance } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/instance.ts";


const currentInstance = await MinecraftJavaInstance.current();

const eventStream = new EventStream(currentInstance.getUUID(), await currentInstance.name());

const backupFolderRelative = "backups";

const delaySec = 60 * 60;

const instancePath = await currentInstance.path();
const backupFolder = `${instancePath}/${backupFolderRelative}`;
while (true) {
    eventStream.emitConsoleOut("[Backup Macro] Backing up world...");
    if (await currentInstance.state() == "Stopped") {
        eventStream.emitConsoleOut("[Backup Macro] Instance stopped, exiting...");
        break;
    }

    const now = new Date();
    const now_str = format(now, "yy-MM-dd_HH");
    try {
        await copy(`${instancePath}/world`, `${backupFolder}/backup_${now_str}`);
    } catch (e) {
        console.log(e)
    }

    await sleep(delaySec);
}

A quick rundown of what's going on:

  • We first get a handle to the instance the task is running in
  • We get the instance path of said instance
  • In each iteration of the loop, we check if the instance is stopped, if it is, we exit the macro
  • We then copy the world folder to a backup folder

Seems simple enough, let's try it out by starting the instance.

image

Hmm, it looks like our instance hanged while starting up.

It shouldn't be a surprise, since we are running an infinite loop in the prelaunch macro. The instance will not be able to start up until the macro is finished.

But what if we wish to run a macro that runs forever? We can use the EventStream.emitDetach(); to detach the macro from the instance.

import { format } from "https://deno.land/std@0.177.1/datetime/format.ts";
import { copy } from "https://deno.land/std@0.191.0/fs/copy.ts";
import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import { EventStream } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/events.ts";
import { lodestoneVersion } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/prelude.ts";
import { MinecraftJavaInstance } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/instance.ts";


const currentInstance = await MinecraftJavaInstance.current();

const eventStream = new EventStream(currentInstance.getUUID(), await currentInstance.name());

const backupFolderRelative = "backups";

const delaySec = 60 * 60;

const instancePath = await currentInstance.path();
const backupFolder = `${instancePath}/${backupFolderRelative}`;
EventStream.emitDetach();
while (true) {
    eventStream.emitConsoleOut("[Backup Macro] Backing up world...");
    if (await currentInstance.state() == "Stopped") {
        eventStream.emitConsoleOut("[Backup Macro] Instance stopped, exiting...");
        break;
    }

    const now = new Date();
    const now_str = format(now, "yy-MM-dd_HH");
    try {
        await copy(`${instancePath}/world`, `${backupFolder}/backup_${now_str}`);
    } catch (e) {
        console.log(e)
    }

    await sleep(delaySec);
}

We should see the instance starting up successfully now, and we should see a backup folder with our backups being created in the instance folder.

image

Writing a macro that can be started manually

In the previous examples, our macros are executed in the prelaunch phase, which means that they are executed before the instance is started.

To write a macro that can be started manually, first locate or create the macros folder in the instance folder. Then, create a new folder with the name of your macro, and create a index.ts file in that folder.

For this example, I will simply re-use the backup macro we wrote earlier.

alt text

Start the macro with the "Run Macro" button:

alt text

You can see and manage all running tasks with the "Running Tasks" bar.

Macro that takes in additional configurations

Some macro may want additional configuration from the end user, in the backup macro's case, we may want to specify the backup interval and the backup folder.

To do so, replace index.ts with the following:

import { format } from "https://deno.land/std@0.177.1/datetime/format.ts";
import { copy } from "https://deno.land/std@0.191.0/fs/copy.ts";
import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import { EventStream } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/events.ts";
import { lodestoneVersion } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/prelude.ts";
import { MinecraftJavaInstance } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/instance.ts";


const currentInstance = await MinecraftJavaInstance.current();

const eventStream = new EventStream(currentInstance.getUUID(), await currentInstance.name());

// Lodestone will parse the configuration class and inject the configuration into the macro
class LodestoneConfig {
    // Where to store the backups relative to the instance path
    backupFolderRelative: string = "backups";
    // How long to wait between backups in seconds
    delaySec: number = 3600;
}

// not technically necessary, but it's a good practice to appease the linter
declare const config: LodestoneConfig;

// make sure the config is injected properly
console.log(config)

const instancePath = await currentInstance.path();
const backupFolder = `${instancePath}/${config.backupFolderRelative}`;
EventStream.emitDetach();
while (true) {
    eventStream.emitConsoleOut("[Backup Macro] Backing up world...");
    if (await currentInstance.state() == "Stopped") {
        eventStream.emitConsoleOut("[Backup Macro] Instance stopped, exiting...");
        break;
    }

    const now = new Date();
    const now_str = format(now, "yy-MM-dd_HH");
    try {
        await copy(`${instancePath}/world`, `${backupFolder}/backup_${now_str}`);
    } catch (e) {
        console.log(e)
    }

    await sleep(config.delaySec);
}

Now click on the "Edit Config" button, and a modal will pop up allowing you to edit the configuration.

alt text

Running the macro again, you should see the macro printing out the configuration you set.

React to events

In the previous examples, we manually polled the instance state to check if the instance is stopped. We can also react to events emitted by the instance.

import { EventStream } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/events.ts";
import { Instance } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/instance.ts";

const current = await Instance.current()

const eventStream = new EventStream(current.getUUID(), await current.name());

EventStream.emitDetach();

while (true) {
    const state = await eventStream.nextStateChange();
    eventStream.emitConsoleOut(`State changed to ${state}`);
}

If you check your instance console, you should see the macro printing out the instance state whenever it changes.

image

You can imagine the possibilities of what you can do with this. For example, you can write a macro that is also a discord bot that notifies you when your instance is stopped, to a macro that automatically stops your instance when there is no activity, etc.

Checkout more examples here

Technical details

Performance

A task has no performance impact on the instance*, as it is executed in a separate thread. However, it is recommended to not run too many tasks at once, as it can cause the instance to become unresponsive on the dashboard. See more in the Deadlocks section.

* If you run a high frequency loop that spams the stdin or rcon of the instance, it can cause the instance to lag.

Advantages

  • Macros are easy to write. If you are making something simple, there is no need to fuss about with modding or plugins APIs.

  • Macros are easy to distribute. You can share your macro with others by simply sharing the macro file, or use HTTP import to import the macro from a URL.

  • Macros are flexible and powerful. You can do almost anything with macros, from coding simple mini-games, to a ChatGPT powered chatbot.

Limitations

  • Macros are not as powerful as proper plugins. Since macro runs on top of the instance, it cannot do things that requires access to the instance internals, such as modifying world generation, modifying the server tick loop, etc.

  • Macros do not have a stable API (yet). The API is still in development, and may change in the future. However, we will try to keep the API as stable as possible.

Some pitfalls

Deadlocks

In Rust, every instance sits behind a mutex. This means that only one thread can access the instance at a time. If you have a macro that is constantly accessing an instance's resources, or if you are not careful with your ops call, it can cause the instance to become unresponsive on the dashboard.

For example, something like this:

import { MinecraftJavaInstance } from "https://raw.githubusercontent.com/Lodestone-Team/lodestone-macro-lib/main/instance.ts";

const instance = await MinecraftJavaInstance.current();

while (true) {
    await instance.state();
}

will probably interfere with the instance's ability to respond to the dashboard. Since each time getInstanceState is called, it will try to acquire the instance mutex, which will block another caller, say the HTTP handler, from acquiring the mutex.

(I say probably since anything in Deno should run slower than Rust, so it might not be able to acquire the mutex fast enough to cause a noticeable impact.)

Deadlocks can be nasty to debug and hard to reproduce, sometimes you will need an understanding of the instance's implementation for debugging.