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
31 changes: 30 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ on:
workflow_dispatch:

jobs:
pxluanova:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
java-version: 21
distribution: temurin

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/master' }}

- name: Test PxLuaNova
working-directory: pxluanova
run: chmod +x gradlew && ./gradlew test --no-daemon

- name: Upload test reports on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: pxluanova-test-reports
path: |
pxluanova/pxluanova-test/build/reports/tests/
pxluanova/pxluanova-test/build/test-results/

build:
runs-on: ubuntu-latest
strategy:
Expand Down Expand Up @@ -58,7 +87,7 @@ jobs:

release:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
needs: [pxluanova, build]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,23 @@ JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. Two tests have quir
- 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.
- 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.
- EventBus runs `LuaClosure` handlers through a `LuaThread` (`EventBus.kt` `invoke`), so coroutine-yielding
async (`async.sleep`/`async.fetch`) and suspend functions work inside event handlers.
- Events: built-in names live in `events/EventCatalog.kt` (`GlobalEvents`, `RegionEvents`); Minecraft callbacks
post them through `events/GameEvents.kt` (mixins call its `@JvmStatic` functions). Handlers get one event table
`e` (fields set with `rawset`, `e:cancel()` from a shared metatable). Pre-0.18 names are `LegacyEvent` views that
rebuild positional args from `e`. Adding an event = `EventDef` + a `GameEvents` function + `lua-types/19-events.lua`
+ `site/.../reference/events.md`. Design: `docs/events.md`.
- Script errors/warnings go through `runtime/ScriptErrors` (log + chat to `px.ignis.prompt_errors`, rate-limited),
not `logger` directly.
- `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`.
- Lua states are single-threaded. `async` tasks on the `threadpool` executor (`AsyncExecutor.isolated`) run in a
fresh worker state (`ScriptEnvironment.newWorkerState`: sandbox + `async`, no `mc`); `AsyncLib.Isolation` copies
the function, upvalues, args and results across with PxLuaNova's `LuaTransfer` (userdata rules:
`StateTransferPolicy`). One `AsyncLib` per state, created in `ScriptEnvironment.rebuild`.
- `EventBus` requires a `stateProvider: () -> LuaState?` for `LuaClosure` handlers; without it they throw. Regions use
`RegionManager.sharedStateProvider` (set in `LuaMcApi.init`).

Expand Down
26 changes: 13 additions & 13 deletions MODRINTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The API reflects what the project itself needed.
| **Reload** | `/ignis reload` re-executes all scripts; persistent state via `mc.data` / `player.data` |
| **MC API** | Particles, sounds, blocks, entities, NBT, structures, weather, world border, explosions |
| **UI** | Per-player sidebar, chest GUI lib, boss bars, holograms, titles |
| **Async** | `mc.fetch()` for HTTP, `mc.sleep()` for coroutine delays |
| **Async** | `require "async"`: `async.fetch()` for HTTP, `async.sleep()` for delays |
| **Storage** | Per-player and global tables |
| **Dev tools** | LuaLS types for IntelliSense, metatable extension hooks |

Expand All @@ -40,18 +40,18 @@ The API reflects what the project itself needed.
### 1. Region + async

```lua
local async = require "async"
local arena = world:createRegion(vec(-50, 64, -50), vec(50, 80, 50))

arena:on("entity_enter", function(entity)
if entity:isPlayer() then
mc.broadcast(entity.name .. " entered the arena")
arena:on("enter", function(e)
if e.player then
mc.broadcast(e.player.name .. " entered the arena")

coroutine.wrap(function()
local res = mc.fetch("https://api.example.com/arena/log")
if not res.ok then
mc.broadcast("log failed: " .. res.error, true)
end
end)()
-- event handlers run as coroutines, so they can wait on async calls directly
local res = async.fetch("https://api.example.com/arena/log")
if not res.ok then
mc.broadcast("log failed: " .. res.error, true)
end
end
end)
```
Expand All @@ -67,9 +67,9 @@ mc.registerBehaviour("zombie_chase", function(self, mob)
end
end)

mc.on("entity_spawn", function(entity)
if entity.isMob and entity.type == "minecraft:zombie" then
entity:setAI("zombie_chase")
mc.on("entity.spawn", function(e)
if e.entity.isMob and e.entity.type == "minecraft:zombie" then
e.entity:setAI("zombie_chase")
end
end)
```
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,18 @@ end)
```

```lua
mc.on("player_join", function(player)
player.data.joins = (player.data.joins or 0) + 1
player:sendMessage("Welcome back! (#" .. player.data.joins .. ")")
mc.on("player.join", function(e)
local data = e.player.data
data.joins = (data.joins or 0) + 1
e.player:sendMessage("Welcome back! (#" .. data.joins .. ")")
end)
```

```lua
mc.on("player_block_break", function(player, pos, blockId)
if blockId == "minecraft:diamond_ore" then
player:sendTitle({ title = "Lucky!" })
player.world:particle("minecraft:totem_of_undying", pos)
mc.on("block.break", function(e)
if e.block == "minecraft:diamond_ore" then
e.player:sendTitle({ title = "Lucky!" })
e.player.world:particle("minecraft:totem_of_undying", e.pos)
end
end)
```
Expand Down Expand Up @@ -76,7 +77,7 @@ end, "px.ignis.shop")
| **Reload** | `/ignis reload` re-executes all scripts; persistent state via `mc.data` / `player.data` |
| **MC API** | Particles, sounds, blocks, entities, NBT, structures, weather, world border, explosions |
| **UI** | Per-player sidebar, chest GUI lib, boss bars, holograms, titles |
| **Async** | `mc.fetch()` for HTTP, `mc.sleep()` for coroutine delays |
| **Async** | `require "async"`: `async.fetch()` for HTTP, `async.sleep()` for delays, tasks on the server thread or a thread pool |
| **Storage** | Per-player and global JSON-backed key-value tables |
| **Dev tools** | LuaLS types for IntelliSense, `mc.dump()`, metatable extension hooks |

Expand Down
5 changes: 4 additions & 1 deletion agent_docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ When updating the API, always ask user if he wants to update the documentation (

| Topic | File |
|---------------------------|--------------------------------------------------------------------------------------------------|
| All events (mc.on) | [`PxIgnis.kt`](src/main/java/ru/pyxiion/ignis/PxIgnis.kt) (also `/reference/events` in site docs) |
| All events (mc.on) | [`EventCatalog.kt`](src/main/java/ru/pyxiion/ignis/events/EventCatalog.kt), fields in [`GameEvents.kt`](src/main/java/ru/pyxiion/ignis/events/GameEvents.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)`.

Event handlers get one table `e` (`mc.on("block.break", function(e) ... e:cancel() end, { priority = "high" })`).
Old underscore names (`player_join`, ...) still work with positional args but are deprecated.
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ processResources {
inputs.property "loader_version", project.loader_version
filteringCharset = "UTF-8"

// LuaLS stubs, unpacked into config/ignis/.types on server start (EditorSupport)
from("lua-types") {
into "lua-types"
}

filesMatching("fabric.mod.json") {
expand "version": project.version,
"minecraft_version": project.minecraft_version,
Expand Down
2 changes: 1 addition & 1 deletion docs/async-suspend-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ now sets it on entry and restores it in `finally`, making both execution paths c

## Why event handlers run through `LuaThread`

Event handlers used to be invoked directly (`callback.invoke()`). A handler that called `mc.sleep` / `mc.fetch` / a
Event handlers used to be invoked directly (`callback.invoke()`). A handler that called `mc.sleep` / `mc.fetch` (now `async.sleep` / `async.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.
Expand Down
62 changes: 62 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Event model

What `mc.on` / `region:on` do and why. The user-facing reference is `site/src/content/docs/reference/events.md`.

## Problems with the 0.17 model

- **Fail-open.** `fireWithResults` skipped a handler that threw, and callers only looked for a `false` result. A
whitelist on `player_join_init` or a protection handler that crashed let the action through.
- **Cancellation did not stop anything.** Every handler ran and the decision was taken afterwards, so a reward
handler paid for a block that a protection handler refused, with no way to know.
- **Order = file names.** Handlers ran in registration order, i.e. alphabetical file order.
- **Positional arguments.** Fields could only be appended; events with the same name had different signatures
(global `player_death(entity, type, amount)` vs region `player_death(entity, source)`); damage was split into four
overlapping events.
- **No name check.** `mc.on("player_joim", ...)` silently never fired.
- **Throttle** existed only on `region:on`, was ignored for cancellable events, and only counted down on ticks that
had a `tick` handler.

## Design

- **One event table per event** (`e`). Fields are named, so events can grow without breaking handlers. The table is
built only when someone listens (`EventBus.post(name) { fill }`), so hot events (`player.move`, `tick`) cost a map
lookup when unused.
- **`e:cancel(reason?)` / `e.cancelled`.** State lives in the table, so a later handler can read it and (with
`receiveCancelled`) reset it. The reason doubles as the kick message for `player.login`.
- **Cancelled events skip handlers by default.** The common case is "protect, then react"; a reaction to something
that did not happen is the bug. Observers opt in with `receiveCancelled = true` (the opposite of Bukkit's
`ignoreCancelled`, whose default is the surprising one).
- **Numeric priorities, higher first**, with aliases `lowest/low/normal/high/highest` = -200/-100/0/100/200. Numbers
leave room between the aliases; "higher runs first" reads naturally with skip-when-cancelled: checks go high,
ordinary handlers at 0, observers low. Ties keep registration order (stable insert).
- **Fail closed** for built-in cancellable events: a throwing handler cancels the event and the error is reported.
Custom events (`mc.emit`) are not cancelled by errors: they are mostly notifications, and one broken listener
should not silence the others.
- **Async handlers** return to the dispatcher at their first yield. Anything they do afterwards is too late to
affect the event, so `e:cancel()` on a finished event throws instead of silently doing nothing.
- **Names.** Built-in events use `category.action` (`player.join`, `block.break`, `entity.hurt`); lifecycle events
keep their old names (`init`, `tick`, ...) since they had no arguments and an extra `e` argument breaks nothing.
Region events drop the prefix (`enter`, `leave`, `move`, `death`). Damage is two events: `entity.hurt` (before,
cancellable) and `entity.damaged` (after); `e.player` is set when the entity is a player instead of separate
`player_*` variants.
- **Unknown names** warn with the closest known name (Levenshtein, `_` = `.`). Names containing `:` are custom and
never warned about.
- **Throttle** uses a clock (the bus's own tick counter, or `RegionManager.ticks` for regions) instead of
per-handler countdowns, so it works whatever else is registered.

## Compatibility

Old names are `LegacyEvent` views over the new events: a filter (e.g. `player_hurt` = `entity.hurt` where
`e.player ~= nil`) and a function that rebuilds the old positional arguments from `e`. Their handlers sit in the
same priority-ordered list as new ones and `return false` still cancels. They log a deprecation warning once per
name per reload.

Deliberate breaks, all towards the safer behaviour: old handlers are also skipped after a cancel and also fail
closed; `mc.emit` refuses built-in names (it would have delivered positional args to handlers expecting `e`).

## Not done

- Mutable fields (e.g. changing `e.amount` in `entity.hurt`, `e.message` in `player.chat`) would be the next step:
read the field back after dispatch.
- Each handler call still creates a `LuaThread`. Reusing threads for handlers that finish without yielding would cut
allocation on hot events.
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ code. Changelog entries describe what changed; these describe the reasoning.

- [Async suspend bridge (Kotlin ↔ Lua)](./async-suspend-bridge.md) — how Lua coroutines call Kotlin `suspend` blocks
without blocking the server thread.
- [Event model](./events.md) — event tables, cancellation, priorities, fail-closed errors and how the old
positional event names are kept working.
67 changes: 32 additions & 35 deletions lua-types/00-globals.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,44 @@
-- * Methods that mutate state return `nil`. Read methods return their value.
-- * The runtime injects these as Lua globals: `mc`, `vec`, `register`.
-- * All PxIgnis APIs are read synchronously unless documented as async
-- (the only async APIs are `mc.fetch` and `mc.sleep`).
-- (async APIs live in the `async` module: `require "async"`).
--
-- See https://ignis.pyxiion.ru for the human-readable reference.

---@alias PxIgnisEventName
---| 'server_start' -- () not cancellable
---| 'server_stop' -- () not cancellable
---| 'init' -- () not cancellable
---| 'uninit' -- () not cancellable
---| 'player_join_init' -- (player) cancellable (return false)
---| 'player_join' -- (player) not cancellable
---| 'player_respawn' -- (player, alive: boolean) not cancellable
---| 'player_leave' -- (player) not cancellable
---| 'player_death' -- (player, damageType: string) not cancellable
---| 'player_chat' -- (player, message: string) cancellable
---| 'player_block_break' -- (player, pos: Vec, blockId: string) cancellable
---| 'player_block_place' -- (player, pos: Vec, blockId: string) cancellable
---| 'player_use_item' -- (player, hand: string, item: Item|nil, itemId: string) cancellable
---| 'player_attack_entity' -- (player, entity: Entity) cancellable
---| 'player_interact_entity' -- (player, entity: Entity, hand: string) cancellable
---| 'player_hurt' -- (player, damageType: string, amount: number) cancellable
---| 'entity_hurt' -- (entity: Entity, damageType: string, amount: number, source: Entity|nil) cancellable
---| 'player_damage' -- (player, damageType: string, amount: number, blocked: boolean) not cancellable
---| 'entity_damage' -- (entity: Entity, damageType: string, amount: number, source: Entity|nil, blocked: boolean) not cancellable
---| 'player_kill' -- (player, target: Entity, damageSource: string) not cancellable
---| 'entity_spawn' -- (entity: Entity) not cancellable
---| 'entity_despawn' -- (entity: Entity) not cancellable
---| 'entity_death' -- (entity: Entity, damageType: string, amount: number) cancellable
---| 'tick' -- () not cancellable
---| 'init' # scripts loaded (also after /ignis reload)
---| 'uninit' # before scripts are unloaded
---| 'server_start'
---| 'server_stop'
---| 'tick' # every server tick
---| 'player.login' # cancellable: e:cancel("kick message")
---| 'player.join'
---| 'player.leave'
---| 'player.respawn'
---| 'player.chat' # cancellable
---| 'player.move'
---| 'player.use_item' # cancellable
---| 'player.attack' # cancellable
---| 'player.interact' # cancellable
---| 'player.kill'
---| 'player.consume' # cancellable
---| 'player.pickup' # cancellable
---| 'player.drop' # cancellable
---| 'block.break' # cancellable
---| 'block.place' # cancellable
---| 'entity.spawn'
---| 'entity.despawn'
---| 'entity.hurt' # cancellable, before damage is applied
---| 'entity.damaged' # after damage is applied
---| 'entity.death' # cancellable

---@alias RegionEventName
---| 'entity_enter' -- (entity: Entity)
---| 'entity_leave' -- (entity: Entity)
---| 'entity_move' -- (entity: Entity, from: Vec, to: Vec)
---| 'player_enter' -- (player: Player)
---| 'player_leave' -- (player: Player)
---| 'player_move' -- (player: Player, from: Vec, to: Vec)
---| 'entity_death' -- (entity: Entity, source: string, amount: number)
---| 'player_death' -- (player: Player, source: string)
---| 'tick' -- ()
---| 'destroy' -- ()
---| 'enter' # an entity (or player: e.player) entered the region
---| 'leave'
---| 'move' # moved inside the region
---| 'death' # died inside the region
---| 'tick'
---| 'destroy'

---@class SidebarConfig
---@field title? string
Expand Down
Loading
Loading