Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ name: Build

on:
push:
branches: [main]
branches: [master]
tags: ["v*"]
pull_request:
branches: [main]
branches: [master]
workflow_dispatch:

jobs:
Expand All @@ -32,7 +32,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
cache-read-only: ${{ github.ref != 'refs/heads/master' }}

- name: Make gradlew executable
run: chmod +x gradlew
Expand Down
36 changes: 17 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,10 @@ commit and tag.
Two MC versions (`-PtargetVersion=1.21.10` / `1.21.11`); version-specific code lives in `src/version-*/kotlin/`.
CI: `.github/workflows/build.yml` — both versions on push/PR to `main`; auto-publishes to Modrinth on tag push.

## Testing quirks
## Testing quirks → see `agent_docs/testing.md`

`src/test/kotlin/ru/pyxiion/ignis/` — JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime.

- `BrigadierTreeTest` reflects `CommandNode.children` field directly — `getChildren()` returns `Collection`, not `Map`.
Use `childrenField.get(node) as Map<*, *>`.
- `MetaTableRegistryTest` must NOT call `MetaTableRegistry.init()` — that triggers MC bootstrap and crashes. Tests read
pre-existing metatables directly.
JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. Two tests have quirks: `BrigadierTreeTest` (reflection on
`CommandNode.children`) and `MetaTableRegistryTest` (must NOT call `init()`).

## Conventions & gotchas

Expand All @@ -46,24 +42,26 @@ CI: `.github/workflows/build.yml` — both versions on push/PR to `main`; auto-p
- Per-instance wrapper state (e.g. `WorldWrap`'s `InstanceData` with `playerCache` + `tickProvider`) lives on
`__pxrp_data` userdata, not on Kotlin `companion object` fields. The shared `BUILT` metatable template on
`companion object` IS the right place for shared/constant data — it must survive reload.
- `mc.sleep(ticks)` / `mc.fetch(url)` coroutine-yielding async is NOT available in event handlers; use `mc.schedule(0, fn)`.
- EventBus runs `LuaClosure` handlers through a `LuaThread` (`EventBus.kt` `invokeCallback`), so coroutine-yielding
async (`mc.sleep`/`mc.fetch`) and suspend functions work inside event handlers — they did not before.
- `luaSuspendFunction(scope, block)` / `luaSuspendFunctionNil` (`Utils.kt`) return Lua functions that yield and resume
the coroutine when the suspend block completes. Requirements: must be called inside a coroutine (not main thread) and
the thread must have a `LuaThread.resumeHandler`. The main thread handler is set in `LuaMcApi.init`. `future.handle`
must be registered BEFORE `scope.launch` (fast-completion race). Design rationale: `docs/async-suspend-bridge.md`.
- `EventBus` requires a `stateProvider: () -> LuaState?` for `LuaClosure` handlers; without it they throw. Regions use
`RegionManager.sharedStateProvider` (set in `LuaMcApi.init`).

## Lua environment → see `agent_docs/lua.md`

Loaded libs, `package.path`, globals, lambda syntax, scheduler tick, built-in `require` libs (`format`, `simple`,
`chestgui`).

## API surface (site reference)
## Design docs → see `docs/`

When writing scripts, prefer linking to docs over source code.
When updating the API, always ask user if he wants to update the documentation (site) & lua-types (<project>/lua-types/*.lua).
Changelog (`site/src/content/docs/changelog.md`) says WHAT changed; `docs/` (e.g. `async-suspend-bridge.md`) documents
WHY — design decisions, tradeoffs, deferred work. Point there before re-deriving rationale.

| Topic | File |
|---------------------------|--------------------------------------------------------------------------------------------------|
| All events (mc.on) | [`PxIgnis.kt`](src/main/java/ru/pyxiion/ignis/PxIgnis.kt) (also `/reference/events` in site docs) |
| mc.\* API | [`LuaMcApi.kt`](src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt) |
| register() syntax + types | [`CommandSyntax.kt`](src/main/java/ru/pyxiion/ignis/commands/CommandSyntax.kt) |
| **Full docs** | **ignis.pyxiion.ru** |
## API surface → see `agent_docs/api.md`

`register("syntax", function(ctx))` does NOT have `ctx.args`. It uses positional args.
For `register("cmd <arg1:word> <arg2:player>", handler)` handler is `(ctx, arg1, arg2)`.
Topics: all events (`mc.on`), `mc.*` API, `register()` syntax + types. `register("syntax", function(ctx))` does NOT
have `ctx.args` — it uses positional args.
14 changes: 14 additions & 0 deletions agent_docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# API surface (site reference)

When writing scripts, prefer linking to docs over source code.
When updating the API, always ask user if he wants to update the documentation (site) & lua-types (<project>/lua-types/*.lua).

| Topic | File |
|---------------------------|--------------------------------------------------------------------------------------------------|
| All events (mc.on) | [`PxIgnis.kt`](src/main/java/ru/pyxiion/ignis/PxIgnis.kt) (also `/reference/events` in site docs) |
| mc.\* API | [`LuaMcApi.kt`](src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt) |
| register() syntax + types | [`CommandSyntax.kt`](src/main/java/ru/pyxiion/ignis/commands/CommandSyntax.kt) |
| **Full docs** | **ignis.pyxiion.ru** |

`register("syntax", function(ctx))` does NOT have `ctx.args`. It uses positional args.
For `register("cmd <arg1:word> <arg2:player>", handler)` handler is `(ctx, arg1, arg2)`.
8 changes: 8 additions & 0 deletions agent_docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Testing

`src/test/kotlin/ru/pyxiion/ignis/` — JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime.

- `BrigadierTreeTest` reflects `CommandNode.children` field directly — `getChildren()` returns `Collection`, not `Map`.
Use `childrenField.get(node) as Map<*, *>`.
- `MetaTableRegistryTest` must NOT call `MetaTableRegistry.init()` — that triggers MC bootstrap and crashes. Tests read
pre-existing metatables directly.
18 changes: 15 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,15 @@ base {
loom {
accessWidenerPath = file("src/main/resources/pxignis.accesswidener")

splitEnvironmentSourceSets()

mods {
"pxignis" {
sourceSet sourceSets.main
}
"pxignis" {
sourceSet sourceSets.client
}
}

log4jConfigs.from "log4j-dev.xml"
Expand Down Expand Up @@ -83,7 +88,8 @@ test {

shadowJar {
configurations = [project.configurations.shadow]
minimize()
from sourceSets.main.output
from sourceSets.client.output

relocate 'org.luaj', 'ru.pyxiion.luanova'
relocate 'me.lucko.fabric.api.permissions', 'ru.pyxiion.lib.fabric.api.permissions'
Expand All @@ -97,8 +103,14 @@ remapJar {
archiveClassifier.set null
}

// Version-specific source overrides (src/version-${buildTarget}/kotlin takes priority)
sourceSets.main.kotlin.srcDirs = ["src/version-${buildTarget}/kotlin", "src/main/java"]
sourceSets {
main {
kotlin.srcDirs = ["src/version-${buildTarget}/kotlin", "src/main/java"]
}
client {
kotlin.srcDirs = ["src/version-${buildTarget}/client-kotlin", "src/client/java"]
}
}

processResources {
inputs.property "version", project.version
Expand Down
89 changes: 89 additions & 0 deletions docs/async-suspend-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Async suspend bridge (Kotlin ↔ Lua)

Status: implemented (unreleased).

## Problem

Calling Kotlin `suspend` code from Lua used to mean one of two bad options:

- `runBlocking` on the server thread — blocks the Minecraft server for the whole duration, freezing the game.
- Starting a coroutine with no way to hand the result back to the Lua script that called it.

The goal: a Lua coroutine should **yield**, let the server thread keep running, and **resume right where it left off**
— with the suspend block's result — once the async work is done.

## Design

### `luaSuspendFunction` yields, doesn't block

`luaSuspendFunction(scope, block)` returns a `LuaContinuableFunction` (PxLuaNova's suspendable function type). When
called:

1. It captures the result in a `CompletableFuture` and yields the Lua coroutine via `YieldContinuationException`.
2. The suspend block runs on the supplied `CoroutineScope`.
3. When the block completes, the future's `handle` resumes the Lua coroutine with the result.

The Lua coroutine is a real Lua coroutine (an `LuaThread`), so the yield/resume machinery is already there — we just
need something to eventually *resume* it.

### `LuaThread.ResumeHandler` decouples *what* from *where*

The resume handler is a per-`LuaThread` callback: "when this thread should be resumed with these args, do this." This
decouples the two responsibilities:

- **What to do** (run the Kotlin block, produce a result) — owned by `luaSuspendFunction`.
- **Where to resume** (the runtime's threading model) — owned by the host.

PxIgnis sets the handler on the **main thread** (`LuaState.getMainThread()`), where it's inherited by every child
coroutine — so all coroutines get it automatically. The handler defers to the server thread:

```kotlin
LuaThread.ResumeHandler { thread, args ->
server.run { thread.resumeOrLog(args, "async callback") }
}
```

Why store it on `LuaThread` and not `LuaState`? Because "where to resume" is a property of the *thread of execution*,
and coroutines inherit it from their parent. `LuaState` is shared across many coroutines; the handler must follow the
coroutine.

### Failure modes are explicit, not silent

A suspend function needs two preconditions: the caller must be inside a coroutine (else there's nothing to yield/resume
through) and the thread must have a resume handler (else nothing will ever resume it). Both fail with a clear
`LuaError` instead of hanging forever.

### Async functions on the server thread

PxLuaNova's `LuaState.setCurrent(state)` is normally only set by the async coroutine runner (`State.run()`). The
synchronous path (server thread) never set it, so `LuaState.current()` returned `null` during Lua execution. `lua_resume_sync`
now sets it on entry and restores it in `finally`, making both execution paths consistent.

## Threading model

- Suspend blocks run on `IgnisRuntime.modScope` (`SupervisorJob() + Dispatchers.Default`).
- Resumes are dispatched back to the **server thread** via `server.run { ... }` — never resumed synchronously from a
callback thread.
- The mod scope is cancelled on `SERVER_STOPPING` so in-flight coroutines die with the server; a `SupervisorJob` means
one failing block doesn't cancel unrelated work.

## Why event handlers run through `LuaThread`

Event handlers used to be invoked directly (`callback.invoke()`). A handler that called `mc.sleep` / `mc.fetch` / a
suspend function would yield across a plain call and crash. Routing `LuaClosure` handlers through a `LuaThread`
(`LuaThread(state, cb).resumeOrLog(...)`) gives event handlers the same coroutine semantics as scheduled tasks and
commands.

## What was deliberately not done

- **A thread pool for LuaThreads**: deferred. Sync-mode `LuaThread` instances are cheap and resetting a thread's
`State` is invasive; the win is marginal for the current workload.

## Key files

- `pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java` — `ResumeHandler`, `lua_resume_sync`
`LuaState.current()` fix.
- `src/main/java/ru/pyxiion/ignis/Utils.kt` — `luaSuspendFunction` / `luaSuspendFunctionNil`.
- `src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt` — main-thread resume handler, `suspendFunction` helper.
- `src/main/java/ru/pyxiion/ignis/EventBus.kt` — closure handlers via `LuaThread`.
- `src/main/java/ru/pyxiion/ignis/IgnisRuntime.kt` — `modScope`.
9 changes: 9 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Docs

Design documents for PxIgnis. These explain *why* things work the way they do — the tradeoffs and decisions behind the
code. Changelog entries describe what changed; these describe the reasoning.

## Documents

- [Async suspend bridge (Kotlin ↔ Lua)](./async-suspend-bridge.md) — how Lua coroutines call Kotlin `suspend` blocks
without blocking the server thread.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx1G
# Fabric Properties
# check these on https://modmuss50.me/fabric.html
minecraft_version=1.21.11
yarn_mappings=1.21.11+build.5
yarn_mappings=1.21.11+build.6
loader_version=0.19.2
loom_version=1.16-SNAPSHOT
# Mod Properties
Expand Down
2 changes: 1 addition & 1 deletion log4j-dev.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
<AppenderRef ref="ServerGuiConsole"/>
</Logger>
</Loggers>
</Configuration>
</Configuration>
Loading
Loading