From 820da9e56be854417229902791bfbb7b142d7714 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:30:06 +0000 Subject: [PATCH 1/4] lua-types: fix overloads and definitions LuaLS could not use - mc.on / region:on overloads listed several event names in one union; LuaLS matched only the first, so e was untyped in player.join, player.leave, tick, entity.death, block.place and others. One overload per name now. - chestgui: describe the real API (set/button/decorate/open/close) instead of setItem/onClick, which the library does not have. - mc.createItem: add the (id, opts) form used by demo.lua. - Fix a syntax error in mc.registerBehaviour, drop vec operators LuaLS does not support, move SimpleLib.defaultOverlay under its class. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac --- lua-types/01-vec.lua | 5 +--- lua-types/11-region.lua | 3 ++- lua-types/14-mc.lua | 25 +++++++++++++----- lua-types/17-modules.lua | 55 +++++++++++++++++++++++++++------------- lua-types/19-events.lua | 22 +++++++++++----- 5 files changed, 74 insertions(+), 36 deletions(-) diff --git a/lua-types/01-vec.lua b/lua-types/01-vec.lua index 926517d..d401a85 100644 --- a/lua-types/01-vec.lua +++ b/lua-types/01-vec.lua @@ -21,10 +21,7 @@ local Vec = {} ---@operator div(number): Vec ---Negation. ---@operator unm: Vec ----Component-wise equality. ----@operator eq(Vec|Vec3Like): boolean ----Renders as `"(x, y, z)"`. ----@operator tostring: string +-- `==` compares components; `tostring(v)` renders as `"(x, y, z)"`. ---Returns sqrt(x² + y² + z²). ---@return number diff --git a/lua-types/11-region.lua b/lua-types/11-region.lua index 5249348..c67fc19 100644 --- a/lua-types/11-region.lua +++ b/lua-types/11-region.lua @@ -27,7 +27,8 @@ Region.entities = nil ---@param callback fun(e: RegionEvent) ---@param opts? EventOptions ---@return integer ----@overload fun(self: Region, event: '"enter"'|'"leave"', callback: fun(e: RegionEntityEvent), opts?: EventOptions): integer +---@overload fun(self: Region, event: '"enter"', callback: fun(e: RegionEntityEvent), opts?: EventOptions): integer +---@overload fun(self: Region, event: '"leave"', callback: fun(e: RegionEntityEvent), opts?: EventOptions): integer ---@overload fun(self: Region, event: '"move"', callback: fun(e: RegionMoveEvent), opts?: EventOptions): integer ---@overload fun(self: Region, event: '"death"', callback: fun(e: RegionDeathEvent), opts?: EventOptions): integer function Region:on(event, callback, opts) end diff --git a/lua-types/14-mc.lua b/lua-types/14-mc.lua index 9fc2104..cc19402 100644 --- a/lua-types/14-mc.lua +++ b/lua-types/14-mc.lua @@ -5,6 +5,16 @@ ---@class mcApi mc = {} +---@class ItemOpts +---@field count? integer +---@field name? string +---@field lore? string[] +---@field unbreakable? boolean +---@field custom_model_data? integer + +---@class ItemSpec : ItemOpts +---@field id string + -- === Functions ============================================================= ---@param text string @@ -116,7 +126,7 @@ function mc.mapped(className) end ---Registers a custom mob behaviour handler. See /reference/mob-ai. ---@param id string ----@param fn fun(entity. Mob, ctx. table) +---@param fn fun(entity: Mob, ctx: table) function mc.registerBehaviour(id, fn) end ---@param title string @@ -131,13 +141,14 @@ function mc.createBossBar(title, color, style) end ---@return boolean, integer|string function mc.execute(command, opts) end ----Creates an ItemStack. Two overloads. ---- createItem("minecraft.stone", 64) ---- createItem({ id = "minecraft.diamond_sword", count = 1, name = "Excalibur", lore = {...}, unbreakable = true, custom_model_data = 1 }) ----@param idOrSpec string|table ----@param count? integer +---Creates an ItemStack. Three forms: +--- createItem("minecraft:stone", 64) +--- createItem("minecraft:diamond_sword", { name = "Excalibur", lore = {...}, unbreakable = true }) +--- createItem({ id = "minecraft:diamond_sword", count = 1, name = "Excalibur", custom_model_data = 1 }) +---@param idOrSpec string|ItemSpec +---@param countOrOpts? integer|ItemOpts ---@return Item -function mc.createItem(idOrSpec, count) end +function mc.createItem(idOrSpec, countOrOpts) end -- === Event methods ========================================================= diff --git a/lua-types/17-modules.lua b/lua-types/17-modules.lua index 3d0c252..907513c 100644 --- a/lua-types/17-modules.lua +++ b/lua-types/17-modules.lua @@ -18,9 +18,8 @@ function Format.format(template, args) end function Format.broadcastFormat(template, args) end ---@class SimpleLib -local Simple = {} - ---@field defaultOverlay integer -- ticks; default 140 (7s) +local Simple = {} ---Concise command registration. The template is rendered with `args.ctx` set ---to the command context (so `{ctx.player.name}` works), and `args.x` etc. @@ -31,44 +30,64 @@ local Simple = {} ---@param overlay? boolean|integer -- true = default overlay; integer = ticks function Simple.register(syntax, template, range, overlay) end +---`require "core:chestgui"`. ---@class ChestGui local ChestGui = {} ----Creates a new chest GUI. Returns an object with a grid API. +---Click callback. Return `false` to cancel the click (items in an open GUI are locked +---either way, but a cancelled click is also not shown to the player as a pickup). +---@alias ChestGuiCallback fun(player: Player, slot: integer, clickType: string, slotItem: Item|nil, cursorItem: Item|nil): boolean|nil + +---Creates a new chest GUI. ---@param rows integer -- 1..6 ---@param title? string ---@return ChestGuiInstance function ChestGui.create(rows, title) end ---@class ChestGuiInstance +---@field inventory Inventory +---@field title string ---@field rows integer ----@field cols integer -- always 9 ----@field onClick? fun(player: Player, slot: integer, clickType: string, slotItem: Item|nil, cursorItem: Item|nil):boolean|nil +---@field slots integer -- rows * 9 local ChestGuiInstance = {} ----Opens the GUI for the given player. ----@param player Player ----@return Container -function ChestGuiInstance:open(player) end - ----@param player Player -function ChestGuiInstance:close(player) end - ----Sets the item in a slot. +---Puts an item at a grid position (1-based) with a click callback. ---@param row integer ---@param col integer ----@param item? Item -function ChestGuiInstance:setItem(row, col, item) end +---@param item Item +---@param callback? ChestGuiCallback +function ChestGuiInstance:set(row, col, item, callback) end + +---Puts an item at a raw slot (1-based) with a click callback. +---@param slot integer +---@param item Item +---@param callback? ChestGuiCallback +function ChestGuiInstance:button(slot, item, callback) end +---Puts an item without a callback; clicks on it are cancelled. ---@param row integer ---@param col integer ----@return Item|nil -function ChestGuiInstance:getItem(row, col) end +---@param item Item +function ChestGuiInstance:decorate(row, col, item) end +---Puts `item` into every slot (callbacks stay; call it before `set`/`button`). +---@param item? Item function ChestGuiInstance:fill(item) end function ChestGuiInstance:clear() end +---Opens the GUI for the player. +---@param player Player +---@return Container|nil +function ChestGuiInstance:open(player) end + +---@param player Player +function ChestGuiInstance:close(player) end + +---Changes the title used by the next `open`. +---@param title string +function ChestGuiInstance:setTitle(title) end + ---`require "async"`. Only `sleep` and `fetch` are typed here; see ---https://ignis.pyxiion.ru/reference/async-api for tasks, promises and mutexes. ---@class AsyncLib diff --git a/lua-types/19-events.lua b/lua-types/19-events.lua index cc2f457..1180eb1 100644 --- a/lua-types/19-events.lua +++ b/lua-types/19-events.lua @@ -99,8 +99,14 @@ function Event:cancel(reason) end ---@param handler fun(...) ---@param opts? EventOptions ---@return integer ----@overload fun(event: '"init"'|'"uninit"'|'"server_start"'|'"server_stop"'|'"tick"', handler: fun(e: Event), opts?: EventOptions): integer ----@overload fun(event: '"player.login"'|'"player.join"'|'"player.leave"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer +---@overload fun(event: '"init"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"uninit"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"server_start"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"server_stop"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"tick"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"player.login"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.join"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.leave"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.respawn"', handler: fun(e: PlayerRespawnEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.chat"', handler: fun(e: PlayerChatEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.move"', handler: fun(e: PlayerMoveEvent), opts?: EventOptions): integer @@ -109,10 +115,14 @@ function Event:cancel(reason) end ---@overload fun(event: '"player.interact"', handler: fun(e: PlayerInteractEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.kill"', handler: fun(e: PlayerKillEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.consume"', handler: fun(e: PlayerItemEvent), opts?: EventOptions): integer ----@overload fun(event: '"player.pickup"'|'"player.drop"', handler: fun(e: PlayerItemCountEvent), opts?: EventOptions): integer ----@overload fun(event: '"block.break"'|'"block.place"', handler: fun(e: BlockEvent), opts?: EventOptions): integer ----@overload fun(event: '"entity.spawn"'|'"entity.despawn"', handler: fun(e: EntityEvent), opts?: EventOptions): integer ----@overload fun(event: '"entity.hurt"'|'"entity.death"', handler: fun(e: EntityDamageEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.pickup"', handler: fun(e: PlayerItemCountEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.drop"', handler: fun(e: PlayerItemCountEvent), opts?: EventOptions): integer +---@overload fun(event: '"block.break"', handler: fun(e: BlockEvent), opts?: EventOptions): integer +---@overload fun(event: '"block.place"', handler: fun(e: BlockEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.spawn"', handler: fun(e: EntityEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.despawn"', handler: fun(e: EntityEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.hurt"', handler: fun(e: EntityDamageEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.death"', handler: fun(e: EntityDamageEvent), opts?: EventOptions): integer ---@overload fun(event: '"entity.damaged"', handler: fun(e: EntityDamagedEvent), opts?: EventOptions): integer ---@overload fun(event: PxIgnisEventName|string, opts: EventOptions, handler: fun(e: Event)): integer -- Nova trailing block order function mc.on(event, handler, opts) end From 93fb2909f1a7dbd8838bc1dddea7d3e65a4d56aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:30:06 +0000 Subject: [PATCH 2/4] Script permissions: operators pass when no permissions mod decides register(syntax, fn, permission) and player:hasPermission used Permissions.check without a default level, which is false when no permissions mod answers. On a server without one, nobody (operators included) could run a script command that has a permission. Fall back to op level 4, like /ignis. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac --- src/main/java/ru/pyxiion/ignis/Utils.kt | 6 +++++- src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/ru/pyxiion/ignis/Utils.kt b/src/main/java/ru/pyxiion/ignis/Utils.kt index 6071e5f..964142f 100644 --- a/src/main/java/ru/pyxiion/ignis/Utils.kt +++ b/src/main/java/ru/pyxiion/ignis/Utils.kt @@ -18,7 +18,11 @@ import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException -fun CommandSource.checkPermission(permission: String): Boolean = Permissions.check(this, permission) +/** Op level that has a script permission when no permissions mod decides it (same as `/ignis`). */ +const val SCRIPT_PERMISSION_LEVEL = 4 + +fun CommandSource.checkPermission(permission: String): Boolean = + Permissions.check(this, permission, SCRIPT_PERMISSION_LEVEL) fun luaTableOf(vararg items: Pair): LuaTable { return LuaTable().apply { diff --git a/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt b/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt index 1417be5..644364f 100644 --- a/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt +++ b/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt @@ -28,6 +28,7 @@ import ru.pyxiion.ignis.PxIgnis import ru.pyxiion.ignis.api.MetaTableRegistry import ru.pyxiion.ignis.api.manager.SidebarManager import ru.pyxiion.ignis.api.util.metaTable +import ru.pyxiion.ignis.SCRIPT_PERMISSION_LEVEL import ru.pyxiion.ignis.unwrap object PlayerWrap { @@ -145,7 +146,8 @@ object PlayerWrap { method("hasPermission") { args -> val self = args.arg(1).checktable() - LuaValue.valueOf(Permissions.check(self.unwrap(), args.arg(2).checkjstring())) + val player = self.unwrap() + LuaValue.valueOf(Permissions.check(player, args.arg(2).checkjstring(), SCRIPT_PERMISSION_LEVEL)) } method("sendMessage") { args -> From cb431b418efbcfd45d7f87f555e5fadb77d9f20b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:38:35 +0000 Subject: [PATCH 3/4] Docs: storage copies assigned tables; fix the whitelist example Assigning a table to mc.data/player.data stores a copy. The whitelist example assigned an empty local table and kept changing the local one, so entries added before the next reload were never saved. Explain the rule in the storage reference and note how permissions behave without a permissions mod. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac --- site/src/content/docs/examples/whitelist.md | 4 ++-- .../content/docs/reference/commands-api.md | 2 ++ site/src/content/docs/reference/storage.md | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/site/src/content/docs/examples/whitelist.md b/site/src/content/docs/examples/whitelist.md index e15ba84..818dbde 100644 --- a/site/src/content/docs/examples/whitelist.md +++ b/site/src/content/docs/examples/whitelist.md @@ -10,8 +10,8 @@ and the cancellable `player.login` event. Save this as `config/ignis/whitelist.lua`: ```lua -local whitelist = mc.data.whitelist or {} -mc.data.whitelist = whitelist +mc.data.whitelist = mc.data.whitelist or {} +local whitelist = mc.data.whitelist mc.on("player.login", function(e) -- Keep operators able to join while setting up the whitelist. diff --git a/site/src/content/docs/reference/commands-api.md b/site/src/content/docs/reference/commands-api.md index 97b48cb..8153e7f 100644 --- a/site/src/content/docs/reference/commands-api.md +++ b/site/src/content/docs/reference/commands-api.md @@ -74,6 +74,8 @@ register("warn ") \{ctx, target, reason -> ## Permissions Pass a permission node as the third argument. Players won't even see the command. +Without a permissions mod, operators (op level 4) have every permission; `player:hasPermission(node)` +follows the same rule. If omitted, all players can use the command. ```lua diff --git a/site/src/content/docs/reference/storage.md b/site/src/content/docs/reference/storage.md index d374c78..6075bc9 100644 --- a/site/src/content/docs/reference/storage.md +++ b/site/src/content/docs/reference/storage.md @@ -39,6 +39,25 @@ data.stats.kills = 5 data.stats.name = "player_stats" ``` +## Assigning a Table Stores a Copy + +Assigning a table to `mc.data` or `player.data` stores a copy of it. Changes to the original table after the +assignment are not saved: + +```lua +-- Wrong: `homes` is the local table, the saved copy stays empty +local homes = {} +player.data.homes = homes +homes.base = { x = 0, y = 64, z = 0 } + +-- Right: assign first, then work with the saved table +player.data.homes = player.data.homes or {} +local homes = player.data.homes +homes.base = { x = 0, y = 64, z = 0 } +``` + +The table you read back from storage is the saved one, so changes to it (at any depth) are saved. + ## Data Validation The following types are **not** allowed in storage and will raise an error: From f7381def688f2a3c3a6f0773364b918e7f392980 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:38:35 +0000 Subject: [PATCH 4/4] Tutorial: build an arena minigame in nine chapters A step-by-step tutorial that grows one script, config/ignis/arena.lua, into a PvP arena: setup commands and mc.data, a region with block protection, a game loop with phases, knockouts via entity.death, boss bar and sidebar, statistics and a leaderboard hologram, kits with a chestgui menu and stashed inventories, an arena:win event with a rewards script, and cleanup on reload. Each chapter ends with the complete file (site/tutorial/arena/NN-*.lua, embedded in the page). site/tutorial/test.sh checks that the snippets in the pages match those files, runs lua-language-server against lua-types on them, and plays the game through a fake API in Lua 5.2; a new CI job runs it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac --- .github/workflows/build.yml | 15 + AGENTS.md | 6 + site/astro.config.mjs | 15 + .../docs/guide/02-events-and-storage.md | 5 + .../src/content/docs/guide/getting-started.md | 6 + .../src/content/docs/tutorial/01-commands.mdx | 167 ++++++ site/src/content/docs/tutorial/02-region.mdx | 129 +++++ site/src/content/docs/tutorial/03-rounds.mdx | 177 ++++++ site/src/content/docs/tutorial/04-fights.mdx | 145 +++++ .../content/docs/tutorial/05-interface.mdx | 108 ++++ site/src/content/docs/tutorial/06-stats.mdx | 153 ++++++ site/src/content/docs/tutorial/07-kits.mdx | 148 +++++ site/src/content/docs/tutorial/08-events.mdx | 94 ++++ .../content/docs/tutorial/09-reliability.mdx | 127 +++++ site/src/content/docs/tutorial/index.mdx | 73 +++ site/tutorial/README.md | 14 + site/tutorial/arena/01-commands.lua | 81 +++ site/tutorial/arena/02-region.lua | 131 +++++ site/tutorial/arena/03-rounds.lua | 216 ++++++++ site/tutorial/arena/04-fights.lua | 280 ++++++++++ site/tutorial/arena/05-interface.lua | 322 +++++++++++ site/tutorial/arena/06-stats.lua | 393 ++++++++++++++ site/tutorial/arena/07-kits.lua | 481 ++++++++++++++++ site/tutorial/arena/08-events.lua | 486 +++++++++++++++++ site/tutorial/arena/08-rewards.lua | 21 + site/tutorial/arena/09-final.lua | 512 ++++++++++++++++++ site/tutorial/check-snippets.py | 38 ++ site/tutorial/test.sh | 43 ++ site/tutorial/test/mock.lua | 270 +++++++++ site/tutorial/test/scenario.lua | 128 +++++ site/tutorial/test/smoke.lua | 60 ++ 31 files changed, 4844 insertions(+) create mode 100644 site/src/content/docs/tutorial/01-commands.mdx create mode 100644 site/src/content/docs/tutorial/02-region.mdx create mode 100644 site/src/content/docs/tutorial/03-rounds.mdx create mode 100644 site/src/content/docs/tutorial/04-fights.mdx create mode 100644 site/src/content/docs/tutorial/05-interface.mdx create mode 100644 site/src/content/docs/tutorial/06-stats.mdx create mode 100644 site/src/content/docs/tutorial/07-kits.mdx create mode 100644 site/src/content/docs/tutorial/08-events.mdx create mode 100644 site/src/content/docs/tutorial/09-reliability.mdx create mode 100644 site/src/content/docs/tutorial/index.mdx create mode 100644 site/tutorial/README.md create mode 100644 site/tutorial/arena/01-commands.lua create mode 100644 site/tutorial/arena/02-region.lua create mode 100644 site/tutorial/arena/03-rounds.lua create mode 100644 site/tutorial/arena/04-fights.lua create mode 100644 site/tutorial/arena/05-interface.lua create mode 100644 site/tutorial/arena/06-stats.lua create mode 100644 site/tutorial/arena/07-kits.lua create mode 100644 site/tutorial/arena/08-events.lua create mode 100644 site/tutorial/arena/08-rewards.lua create mode 100644 site/tutorial/arena/09-final.lua create mode 100644 site/tutorial/check-snippets.py create mode 100755 site/tutorial/test.sh create mode 100644 site/tutorial/test/mock.lua create mode 100644 site/tutorial/test/scenario.lua create mode 100644 site/tutorial/test/smoke.lua diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8c6d6f8..e259703 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,21 @@ jobs: pxluanova/pxluanova-test/build/reports/tests/ pxluanova/pxluanova-test/build/test-results/ + tutorial: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Lua 5.2 and lua-language-server + run: | + sudo apt-get update && sudo apt-get install -y lua5.2 + mkdir -p "$RUNNER_TEMP/luals" + curl -sSL https://github.com/LuaLS/lua-language-server/releases/download/3.13.9/lua-language-server-3.13.9-linux-x64.tar.gz \ + | tar xz -C "$RUNNER_TEMP/luals" + + - name: Check the arena tutorial + run: LUALS="$RUNNER_TEMP/luals/bin/lua-language-server" site/tutorial/test.sh + build: runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index a20401c..161b936 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,12 @@ JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. Two tests have quir Loaded libs, `package.path`, globals, lambda syntax, scheduler tick, built-in `require` libs (`format`, `simple`, `chestgui`). +## Tutorial + +`site/src/content/docs/tutorial/` is a step-by-step arena minigame; its code is in `site/tutorial/arena/` +(one checkpoint file per chapter) and is checked by `site/tutorial/test.sh` (snippets match checkpoints, +LuaLS against `lua-types/`, a fake-API game run). API changes that touch it must keep that script green. + ## Design docs → see `docs/` Changelog (`site/src/content/docs/changelog.md`) says WHAT changed; `docs/` (e.g. `async-suspend-bridge.md`) documents diff --git a/site/astro.config.mjs b/site/astro.config.mjs index efe1e6b..c33a32b 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -25,6 +25,21 @@ export default defineConfig({ { label: "2. Events and storage", slug: "guide/02-events-and-storage" }, ], }, + { + label: "Tutorial: Arena", + items: [ + { label: "Overview", slug: "tutorial" }, + { label: "1. Commands and settings", slug: "tutorial/01-commands" }, + { label: "2. The arena region", slug: "tutorial/02-region" }, + { label: "3. Rounds", slug: "tutorial/03-rounds" }, + { label: "4. Fights", slug: "tutorial/04-fights" }, + { label: "5. Boss bar and scoreboard", slug: "tutorial/05-interface" }, + { label: "6. Statistics and leaderboard", slug: "tutorial/06-stats" }, + { label: "7. Kits and inventories", slug: "tutorial/07-kits" }, + { label: "8. Events for other scripts", slug: "tutorial/08-events" }, + { label: "9. Reloads and crashes", slug: "tutorial/09-reliability" }, + ], + }, { label: "Core API", items: [ diff --git a/site/src/content/docs/guide/02-events-and-storage.md b/site/src/content/docs/guide/02-events-and-storage.md index f3a6a8f..9006afd 100644 --- a/site/src/content/docs/guide/02-events-and-storage.md +++ b/site/src/content/docs/guide/02-events-and-storage.md @@ -208,6 +208,11 @@ mc.on("player.join") \{ e -> Keep going with the topics that match what you want to build: +**Build a whole game** + +- [Tutorial: an arena minigame](/tutorial) — commands, regions, rounds, kits, statistics and more, in one + script that grows chapter by chapter + **More about events and data** - [Events reference](/reference/events) — complete event list and handler signatures diff --git a/site/src/content/docs/guide/getting-started.md b/site/src/content/docs/guide/getting-started.md index b9b3e25..0b7d64f 100644 --- a/site/src/content/docs/guide/getting-started.md +++ b/site/src/content/docs/guide/getting-started.md @@ -77,3 +77,9 @@ Errors in event handlers, commands and scheduled tasks, and warnings such as an the server log and also sent to online players with the `px.ignis.prompt_errors` permission (operators level 4 by default), in the form `event 'block.break': shop.lua:12: attempt to index a nil value`. The same error is repeated in chat at most once every 10 seconds, so a handler failing every tick does not flood it. + +## Next steps + +- [1. Your first command](/guide/01-your-first-command) and [2. Events and storage](/guide/02-events-and-storage): + short recipes for the basics. +- [Tutorial: an arena minigame](/tutorial): build a complete minigame step by step. diff --git a/site/src/content/docs/tutorial/01-commands.mdx b/site/src/content/docs/tutorial/01-commands.mdx new file mode 100644 index 0000000..d3fef72 --- /dev/null +++ b/site/src/content/docs/tutorial/01-commands.mdx @@ -0,0 +1,167 @@ +--- +title: 1. Commands and saved settings +description: "Arena tutorial, chapter 1: admin commands, permissions and settings that survive restarts." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/01-commands.lua?raw'; + +In this chapter the arena learns where its lobby and spawn points are, and players can join and leave a +queue. Nothing fights yet, but the base everything else builds on is here. + +Create `config/ignis/arena.lua`. With `/ignis watch on` each save reloads it. + +## Settings that survive restarts + +An admin sets the arena up once, so the positions have to survive a restart. `mc.data` is a table that +PxIgnis saves to `config/ignis/storage/global.json` by itself: + +```lua +local ADMIN = "arena.admin" + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} +``` + +`mc.data.arena or {}` means "the saved table, or a new empty one the first time". + +:::caution[Assigning a table stores a copy] +The order of those two lines matters. Assigning a table to `mc.data` (or `player.data`) stores a **copy** +of it, so this does not work: + +```lua nocheck +local config = {} +mc.data.arena = config -- saves a copy of the empty table +config.lobby = ... -- changes the local table, not the saved copy +``` + +Assign first, then read the saved table back, as above. Only numbers, strings, booleans and tables of them +can be stored: no players, functions or vectors. +::: + +A position is stored as a plain table with the world name. Two helpers convert between it and a player: + +```lua +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end +``` + +## Admin commands + +`register(syntax, handler, permission)` adds a command. Words in the syntax are literal parts of the +command, so `"arena setlobby"` is `/arena setlobby`. The handler gets `ctx`, and `ctx.player` is the player +who ran it: + +```lua +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) +``` + +The third argument is a permission. Players without `arena.admin` do not see the command at all. Without a +permissions mod, operators (op level 4) have every script permission. Spawn points are a list, and a new one +goes after the last one: + +```lua +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) +``` + +## The game state + +Who is in the arena right now is not worth saving: after a restart nobody is. That state lives in an +ordinary local table, keyed by the player's UUID: + +```lua +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + players = {}, -- uuid -> Player: everyone who joined +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end +``` + +`tell` sends a message to everyone in the arena. `count(t)` (in the checkpoint) counts the keys of a table, +since `#` only works for lists. + +## Joining and leaving + +```lua +local function join(player) + if not config.lobby then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end +``` + +A player who disconnects must leave the arena too, or a round could wait for them forever. The +`player.leave` event fires when a player leaves the server; handlers get an event table `e` with the +player in `e.player`: + +```lua +mc.on("player.leave", function(e) + leave(e.player) +end) +``` + +Finally the player commands. They have no permission, so everyone can use them: + +```lua +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +``` + +## Try it + +1. Stand where the lobby should be and run `/arena setlobby`. +2. Walk into your arena and run `/arena addspawn` in two or more places. +3. Run `/ignis eval mc.data.arena` to see what was saved. +4. `/arena join` teleports you to the lobby; `/arena leave` takes you out of the queue. +5. Restart the server or run `/ignis reload`: the settings are still there, the queue is empty. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 1 + + + +
+ +**Exercise:** add `/arena where`, which tells the player how many spawn points are set and whether the lobby +is set. diff --git a/site/src/content/docs/tutorial/02-region.mdx b/site/src/content/docs/tutorial/02-region.mdx new file mode 100644 index 0000000..0cc8c72 --- /dev/null +++ b/site/src/content/docs/tutorial/02-region.mdx @@ -0,0 +1,129 @@ +--- +title: 2. The arena region +description: "Arena tutorial, chapter 2: a region for the arena and block protection with cancellable events." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/02-region.lua?raw'; + +The arena needs borders: to protect its blocks now, and later to notice who walks in or out. A +[region](/reference/region-api) is a box in a world that knows which entities are inside it. + +## Two corners + +The admin stands in one corner and runs `/arena pos1`, then in the opposite corner `/arena pos2`. Both +commands do the same thing with a different key, so they share a function: + +```lua +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) +``` + +`math.floor` turns the player's position (say `12.7, 64, -3.2`) into the block they stand in +(`12, 64, -4`). + +## Building the region + +Regions, like everything else a script creates, are removed on `/ignis reload`. So the region is not +saved; the corners are, and the script builds the region from them every time it loads: + +```lua +local region -- Region|nil, created by buildRegion() below +``` + +```lua +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) +end + +buildRegion() +``` + +The corners can be given in any order, hence `min` and `max`. The `+ 1` matters: the region box ends +*before* its second corner, and a block at `x = 20` spans from 20 to 21. + +`region` is declared near the top of the file because functions above `buildRegion` need it. The arena is +ready once it has a region, a lobby and a spawn point, and `join` now checks exactly that: + +```lua +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end +``` + +```lua + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") +``` + +## Protecting the blocks + +`block.break` and `block.place` are **cancellable** events: a handler that calls `e:cancel()` stops the +action. `e.pos` is the block and `e.player` the player: + +```lua +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) +``` + +Admins keep building rights, so they can still fix the arena. + +`priority = "high"` makes these handlers run before handlers with the default priority. Once an event is +cancelled, later handlers are not called at all, so a script elsewhere that pays players for mined blocks +never pays for a block the arena refused. See [Events](/reference/events#cancelling) for the details. + +:::tip +If a handler of a cancellable event throws an error, the event is cancelled too. A bug in protection code +blocks too much instead of letting everything through. +::: + +## Try it + +1. Stand in one corner of your arena (on the floor), run `/arena pos1`; in the opposite corner, one block + above the highest point players should reach, run `/arena pos2`. +2. Ask a player who is not an operator to break a block inside the arena and one outside it. (Alone? Run + `/deop ` from the server console and `/op ` afterwards.) +3. `/ignis eval #me.world.regions` shows how many regions the world has. It stays at 1 however often you + reload. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 2 + + + +
+ +**Exercise:** show the arena borders: after `/arena pos2`, spawn `minecraft:end_rod` particles at the four +bottom corners with `world:particle`. diff --git a/site/src/content/docs/tutorial/03-rounds.mdx b/site/src/content/docs/tutorial/03-rounds.mdx new file mode 100644 index 0000000..4cb558c --- /dev/null +++ b/site/src/content/docs/tutorial/03-rounds.mdx @@ -0,0 +1,177 @@ +--- +title: 3. Rounds +description: "Arena tutorial, chapter 3: a game loop with phases, the scheduler and async.sleep." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/03-rounds.lua?raw'; + +Now the arena gets a game loop: when enough players have joined, a countdown starts, everyone is +teleported to a spawn point, and after a while the round ends. Fights come in the next chapter; here the +round simply ends in a draw when time runs out. + +## Phases + +A round goes through four phases: + +- **waiting**: players join, until there are enough of them; +- **countdown**: a few seconds to get ready; +- **playing**: the fight; +- **ending**: the winner celebrates, then everyone returns to the lobby and it is `waiting` again. + +The current phase and a timer go into the game state, together with the set of players still in the fight: + +```lua +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw +``` + +```lua +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} +``` + +## A tick every second + +`mc.scheduleRepeating(delay, interval, fn)` calls a function every `interval` ticks (20 ticks = 1 second). +One function that runs once a second and moves the game along is the whole game loop: + +```lua +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end +end + +mc.scheduleRepeating(20, 20, tick) +``` + +Scheduled tasks are removed on reload, and the file schedules a new one when it loads, so there is always +exactly one. + +:::note[Why not a loop with `async.sleep`?] +A `while true do ... async.sleep(20) end` would work too. A tick function is easier to follow when several +things can happen in any order (players leave during the countdown, someone dies, time runs out): each +second it looks at the state and decides, instead of being somewhere in the middle of a long function. +::: + +## Starting a round + +```lua +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end +``` + +`(i - 1) % #config.spawns + 1` goes through the spawn points one by one and starts over when there are more +players than points: with 3 points, players 1, 2, 3, 4, 5 get points 1, 2, 3, 1, 2. + +While a round is running nobody can join: + +```lua + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") +``` + +## Ending a round + +`finish(winner)` shows the winner (none yet, that is the next chapter) and brings everyone back after a +five-second celebration: + +```lua +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end +``` + +`async.sleep(ticks)` (from `local async = require "async"` at the top of the file) pauses the function +without pausing the server. It only works inside a coroutine; scheduled callbacks, event handlers and +command handlers all run as one, so it works there. `mc.schedule(0, ...)` starts the celebration in its +own coroutine, and `finish` returns right away. + +`winner.pos + vec(0, 1, 0)` is one block above the winner's feet: vectors support `+`, `-` and `*`. + +## Try it + +1. Set `ROUND_TIME` to `30` while testing. +2. Join with two players (a second account, or a friend). After ten seconds both are teleported to the + spawn points; after 30 more the round ends in a draw and you are back in the lobby. +3. Leave during the countdown: it stops. + +Dying does nothing special yet: a dead player respawns as usual and is still "alive" in the round. That is +fixed in the next chapter. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 3 + + + +
+ +**Exercise:** start the round early when the arena is full: if `count(game.players)` reaches +`#config.spawns` during the countdown, skip to 3 seconds. diff --git a/site/src/content/docs/tutorial/04-fights.mdx b/site/src/content/docs/tutorial/04-fights.mdx new file mode 100644 index 0000000..0e6bf43 --- /dev/null +++ b/site/src/content/docs/tutorial/04-fights.mdx @@ -0,0 +1,145 @@ +--- +title: 4. Fights +description: "Arena tutorial, chapter 4: knockouts instead of deaths, a winner, and region enter/leave events." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/04-fights.lua?raw'; + +Time to fight. A player who would die is knocked out instead: they stay alive, are healed and go back to the +lobby. When one player is left, they win. + +## Knockouts instead of deaths + +`entity.death` fires when an entity takes fatal damage, before it dies, and it is cancellable. For players, +`e.player` is set (for other entities it is `nil`), and `e.attacker` is whoever dealt the damage, if +anyone: + +```lua +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) +``` + +Cancelling the death keeps the player alive, but their health is still zero, so the handler must heal them +right away. No death screen, no dropped items, no respawn point to worry about. + +The winner is still in `game.alive` during the celebration. If they fall off something then, the handler +heals them but does not eliminate them: that is what the `game.phase == "playing"` check is for. + +`e.attacker and game.players[e.attacker.uuid]` is the attacker if they are an arena player, otherwise +`nil` (a zombie, fall damage, a player from outside). + +## Knocking a player out + +```lua +local function eliminate(player, killer) + game.alive[player.uuid] = nil + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end +``` + +Knocked-out players stay in `game.players`: they watch from the lobby and play in the next round. + +## Who won? + +```lua +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end +``` + +`next(t)` returns some key of a table; with one entry left, it is the last player standing. + +## Leaving + +A player who leaves the arena (with `/arena leave` or by disconnecting) during a round is knocked out +first, so the round can still end: + +```lua + if game.alive[uuid] then + eliminate(player, nil) + end +``` + +## Region events + +Regions have their own events, subscribed with `region:on`. `leave` and `enter` fire when an entity leaves +or enters the box; like other entity events, `e.player` is set for players. The handlers are added in +`buildRegion`, right after the region is created: + +```lua + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +``` + +`eliminate` teleports the player to the lobby, which is outside the arena, so it fires `leave` again. That +second call does nothing because the player is no longer in `game.alive`. Handlers that react to their own +side effects are common; checking the state first keeps them harmless. + +## No hits from the lobby + +Players waiting in the lobby, and knocked-out ones, should not hit anybody: + +```lua +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) +``` + +## Try it + +With two players: fight until one of them would die. They land in the lobby with full health, the other +one gets the title and the particles, and after five seconds both are in the lobby and the next countdown +starts. Try walking out of the arena and back in during a round, too. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 4 + + + +
+ +**Exercise:** make the final duel visible: when only two players are left, set `player.glowing = true` on +both, and turn it off again when the round ends. diff --git a/site/src/content/docs/tutorial/05-interface.mdx b/site/src/content/docs/tutorial/05-interface.mdx new file mode 100644 index 0000000..5b8dd94 --- /dev/null +++ b/site/src/content/docs/tutorial/05-interface.mdx @@ -0,0 +1,108 @@ +--- +title: 5. Boss bar and scoreboard +description: "Arena tutorial, chapter 5: a boss bar with the countdown and a scoreboard sidebar for each player." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/05-interface.lua?raw'; + +The game works, but players see little of it. This chapter adds a boss bar at the top of the screen with +the phase and the time, and a scoreboard on the right. + +## The boss bar + +One [boss bar](/reference/bossbar-api) is shared by all arena players. It is created when the script loads +(and, like regions, removed on reload): + +```lua +local bar = mc.createBossBar("Arena", "yellow", "progress") +``` + +Players are added when they join and removed when they leave: + +```lua + bar:addPlayer(player) +``` + +```lua +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end +``` + +```lua + hideUI(player) +``` + +## Updating it + +`updateUI` sets the bar's text and fill from the phase. `bar.progress` goes from 0 to 1: + +```lua +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status +``` + +`string.format` fills in `%d` (a whole number) and `%02d` (at least two digits, so `4:05` and not `4:5`). + +It is called at the end of `tick`, so it refreshes once a second, and after each join so the new player sees +it at once: + +```lua + updateUI() +``` + +## The scoreboard + +Every player has their own [sidebar](/reference/sidebar-api). Assigning a table to `player.sidebar` creates +it or updates it; assigning `nil` removes it (see `hideUI` above): + +```lua + for uuid, player in pairs(game.players) do + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + }, + } + end +end +``` + +`a and b or c` is Lua's short form of "if a then b else c" (as long as `b` is not `false` or `nil`). + +The sidebar has only two lines for now; the next chapters add kills, wins and the kit. + +## Try it + +Join and watch the bar fill up as players join, count down, then show the time left. Leave the arena: the +bar and the sidebar disappear. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 5 + + + +
+ +**Exercise:** change the bar color with the phase: `bar.color = "green"` while waiting, `"yellow"` during the +countdown, `"red"` during the fight. diff --git a/site/src/content/docs/tutorial/06-stats.mdx b/site/src/content/docs/tutorial/06-stats.mdx new file mode 100644 index 0000000..6777468 --- /dev/null +++ b/site/src/content/docs/tutorial/06-stats.mdx @@ -0,0 +1,153 @@ +--- +title: 6. Statistics and leaderboard +description: "Arena tutorial, chapter 6: per-player statistics in player.data and a leaderboard hologram." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/06-stats.lua?raw'; + +Wins and kills should count for more than one round. This chapter keeps statistics for every player, shows +them on the scoreboard and with `/arena stats`, and puts a top-5 leaderboard in the lobby. + +## Per-player data + +`player.data` works like `mc.data`, but belongs to one player. It is saved to +`config/ignis/storage/players/.json`, loaded when the player joins and written when they leave. + +The statistics are a table inside it. The same rule as in chapter 1 applies: assign the table, then read it +back from `player.data`: + +```lua +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end +``` + +What `stats` returns is the saved table, so changing a field is enough, no assignment back needed: + +```lua + stats(player).games = stats(player).games + 1 +``` + +```lua + stats(player).deaths = stats(player).deaths + 1 +``` + +Kills also count per round, for the scoreboard. That list is part of the game state, reset in `startRound` +and cleaned up in `leave`: + +```lua + kills = {}, -- uuid -> kills this round +``` + +```lua + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 +``` + +The scoreboard gets three more lines: + +```lua + local s = stats(player) +``` + +```lua + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, +``` + +## `/arena stats` + +An optional argument in `[...]` is `nil` when it is left out, so `/arena stats` shows your own numbers and +`/arena stats Steve` someone else's: + +```lua +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) +``` + +The `player` argument type only accepts players who are online, which is fine here: `player.data` is only +available for them. + +## A leaderboard + +The leaderboard must include players who are offline, and their `player.data` is not loaded. So the wins +are copied into `mc.data` too, when a round is won: + +```lua +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins +``` + +```lua + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() +``` + +It is shown by a [hologram](/reference/hologram-api), floating text in the world. `/arena setleaderboard` +saves its position, and the hologram is created from it when the script loads (holograms are removed on +reload, like regions and boss bars): + +```lua +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end +``` + +```lua +spawnLeaderboard() +``` + +`updateLeaderboard` sorts the entries and writes the top five into `leaderboard.lines`: + +```lua + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +``` + +`table.sort` takes a function that says whether `a` goes before `b`. + +## Try it + +1. Stand in the lobby and run `/arena setleaderboard`. +2. Play a round. The winner appears on the leaderboard; `/arena stats` shows the numbers. +3. Restart the server: statistics and leaderboard are still there. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 6 + + + +
+ +**Exercise:** add a kill/death ratio to `/arena stats`. Mind players with zero deaths. diff --git a/site/src/content/docs/tutorial/07-kits.mdx b/site/src/content/docs/tutorial/07-kits.mdx new file mode 100644 index 0000000..7f78aa0 --- /dev/null +++ b/site/src/content/docs/tutorial/07-kits.mdx @@ -0,0 +1,148 @@ +--- +title: 7. Kits and inventories +description: "Arena tutorial, chapter 7: saving the player's items, giving kits and a chest menu to choose one." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/07-kits.lua?raw'; + +So far players fight with whatever they carry, and a diamond sword wins every round. Now everyone gets a kit +of their choice, and their own items wait in storage until the round is over. + +## Putting items away + +A player's inventory is numbered: slots 0 to 35 are the main inventory and the hotbar, 36 to 39 the armor, +40 the offhand. `item:serialise()` turns an item into a string with all its enchantments and names, and +`mc.deserialise("item", text)` turns it back. Strings can be stored, so the items go into `player.data`: + +```lua +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end +``` + +The slot numbers are stored as strings (`"0"`, `"38"`). Storage is JSON, and a table with gaps in its +numbers comes back from JSON with string keys anyway; converting on both sides keeps it predictable. + +Why `player.data` and not a local table? If the server crashes in the middle of a round, a local table is +gone, and so are the player's items. `player.data` is on disk. Chapter 9 uses that to give the items back. + +Items go away when a round starts, and come back when a player is knocked out and at the end of the round: + +```lua + stash(player) + giveKit(player) +``` + +```lua + restore(player) +``` + +```lua + restore(player) +``` + +## Kits + +A kit is plain data: a name, an icon, items with counts, and a chestplate. + +```lua +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, +``` + +The player's choice is saved in `player.data.arenaKit`; `findKit` falls back to the first kit: + +```lua +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end +``` + +`player.chest` is the chestplate slot; `head`, `legs`, `feet`, `mainhand` and `offhand` work the same way. + +## A chest menu + +The built-in `chestgui` library makes a menu out of a chest window. Load it at the top of the file: + +```lua +local chestgui = require "core:chestgui" +``` + +One menu serves all players. `kitMenu:set(row, column, item, callback)` puts a button in it: + +```lua +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end +``` + +`return false` cancels the click, so the item does not end up on the cursor. The buttons go in columns 3, 5 +and 7 of the single row. + +```lua +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) +``` + +And the scoreboard shows the chosen kit: + +```lua + "Kit: " .. findKit(player.data.arenaKit).name, +``` + +## Try it + +1. Put something recognizable in your inventory, e.g. a named item and some armor. +2. `/arena kit`, choose the archer, join and wait for the round. Your inventory is replaced by the kit. +3. After the round your own items are back, in the same slots. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 7 + + + +
+ +**Exercise:** give each kit an `effect` field (e.g. `"minecraft:speed"` for the archer) and apply it in +`giveKit` with `player:addEffect(kit.effect, 20 * ROUND_TIME)`. diff --git a/site/src/content/docs/tutorial/08-events.mdx b/site/src/content/docs/tutorial/08-events.mdx new file mode 100644 index 0000000..d7f1859 --- /dev/null +++ b/site/src/content/docs/tutorial/08-events.mdx @@ -0,0 +1,94 @@ +--- +title: 8. Events for other scripts +description: "Arena tutorial, chapter 8: a custom event with mc.emit and a second script that rewards winners." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/08-events.lua?raw'; +import rewards from '../../../../tutorial/arena/08-rewards.lua?raw'; + +The arena should not have to know about the server's economy, Discord or quests. Instead it announces what +happened, and other scripts decide what to do about it. This chapter adds an `arena:win` event and a second +script that rewards the winner. + +## Firing a custom event + +`mc.emit(name, ...)` calls every handler of the event with the arguments you pass. Custom event names +contain a `:`; a prefix like `arena:` keeps them apart from other scripts' events. + +The event fires at the end of the celebration, when the winner is back in the lobby: + +```lua + -- Tell other scripts, now that the winner has their own items back. + if winner and game.players[winner.uuid] then + mc.emit("arena:win", winner, game.kills[winner.uuid] or 0) + end +``` + +Why not right away in `finish`? At that moment the winner still carries the kit, and `restore` clears the +inventory before giving their own items back: a reward handed out earlier would vanish with the kit. +`game.players[winner.uuid]` skips the event if the winner left the server during the celebration. + +## A rewards script + +Create a second file, `config/ignis/arena_rewards.lua`. Scripts in `config/ignis/` share the event bus, so +it can listen to the arena's event. Handlers of custom events get the emitted arguments as they are: + +```lua +mc.on("arena:win", function(winner, kills) + winner:give("minecraft:diamond", 1 + kills) + mc.broadcast("§6" .. winner.name .. " §ewon an arena round with " .. kills .. " kills!") +``` + +The arena works without this file, and this file can be replaced by another one (coins, a quest step, a +title) without touching the arena. + +## Posting to Discord + +Event handlers run as coroutines, so they can wait for an HTTP request with `async.fetch` without +stopping the server. With a Discord webhook URL in `WEBHOOK`, winners are announced on Discord too: + +```lua +-- A Discord webhook URL to announce winners there, or nil to skip it. +local WEBHOOK = nil +``` + +```lua + if WEBHOOK then + local response = async.fetch({ + url = WEBHOOK, + method = "POST", + json = { content = winner.name .. " won an arena round with " .. kills .. " kills" }, + }) + if not response.ok then + print("arena webhook failed: " .. tostring(response.error or response.status)) + end + end +``` + +`mc.emit` returns as soon as the handler starts waiting, so the arena does not wait for Discord. +`print` writes to the server console. See the [Async API](/reference/async-api) for more. + +## Try it + +Win a round: diamonds (one plus one per kill) land in your inventory next to your own items, and everyone +reads the announcement. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 8 + + + +
+ +
+config/ignis/arena_rewards.lua + + + +
+ +**Exercise:** add an `arena:eliminated` event, fired in `eliminate` with the player and the killer, and use +it in `arena_rewards.lua` to give the killer an arrow for every knockout. diff --git a/site/src/content/docs/tutorial/09-reliability.mdx b/site/src/content/docs/tutorial/09-reliability.mdx new file mode 100644 index 0000000..4078e4f --- /dev/null +++ b/site/src/content/docs/tutorial/09-reliability.mdx @@ -0,0 +1,127 @@ +--- +title: 9. Reloads and crashes +description: "Arena tutorial, chapter 9: what a reload keeps and throws away, cleaning up in uninit, and debugging." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/09-final.lua?raw'; + +The arena works as long as nothing unexpected happens. This last chapter is about the unexpected: a +`/ignis reload` in the middle of a round, a server crash, a broken setup. It also collects the debugging +tricks that help while writing scripts like this one. + +## What a reload keeps + +`/ignis reload` (and every save with `/ignis watch on`) throws away everything the scripts created and runs +the files again: + +| Removed on reload | Kept | +|---|---| +| local variables, e.g. `game` | `mc.data` and `player.data` | +| event handlers and commands | the world, and what scripts did to it | +| scheduled tasks and sleeping coroutines | players, their positions and inventories | +| regions, holograms, boss bars, sidebars, open menus | | + +The arena's own objects (region, boss bar, leaderboard) are created again when the file loads, from the +settings in `mc.data`. But the round in progress is gone, and its players are left standing in the arena +with a kit instead of their items. + +## Cleaning up in `uninit` + +The `uninit` event fires just before a reload, while the old handlers still work. It is the place to +undo what only the script knows about: + +```lua +-- Before a reload the game state is thrown away: put everyone back first. +mc.on("uninit", function() + for uuid, player in pairs(game.players) do + if game.alive[uuid] then + restore(player) + teleport(player, config.lobby) + end + player.sidebar = nil + player:sendMessage("§eThe arena was reloaded, join again with /arena join.") + end +end) +``` + +A normal server stop needs nothing extra: every player leaves the server, `player.leave` fires, and `leave` +knocks them out and gives their items back. + +## After a crash + +A crash skips all of that. What is left is whatever was written to disk. The stash is in `player.data`, so +the next time the player joins, the script can finish the job: + +```lua +-- Came back after a crash in the middle of a round: return their items. +mc.on("player.join", function(e) + if e.player.data.arenaStash then + restore(e.player) + if config.lobby then teleport(e.player, config.lobby) end + e.player:sendMessage("§eYour items from the last arena round were returned.") + end +end) +``` + +:::caution +PxIgnis writes `player.data` to disk on `/ignis reload`, when the player leaves and when the server stops, +not on a timer. If the server crashes during a round, the stash is on disk only if one of those happened +since the round started. Keep that in mind for anything valuable you keep in storage. +::: + +## Guarding against a broken setup + +An admin can run `/arena clearspawns` while players wait for the countdown. `startRound` would then pick a +spawn point from an empty list and fail. A check at the start turns that into a clear message: + +```lua + if not isReady() then + game.phase = "waiting" + tell("§cThe arena setup is incomplete, the round cannot start.") + return + end +``` + +A rule of thumb: every function that runs later (on a timer, on an event) checks that what it needs still +exists, because a command or a reload may have changed it in between. + +## Debugging + +- **Errors in chat.** When a handler fails, operators see where and why in chat, e.g. + `event 'entity.death': arena.lua:22: attempt to index ? (a nil value) with key 'x'`: line 22 is + `teleport`, and the point it got was `nil` (no lobby set?). The same error is shown at most once every 10 + seconds, so a failing tick does not flood the chat. +- **Syntax errors** stop the reload: the old scripts keep running and the error is reported. +- **`/ignis status`** lists the scripts, event handlers per event, commands, tasks and regions. Two + `tick` tasks where you expect one means something is scheduled twice. +- **`/ignis eval`** shows what the scripts see: `/ignis eval mc.data.arena.lobby`, + `/ignis eval me.data.arenaStats`. Local variables are invisible to it; to look at the game state, add + `arenaDebug = game` at the end of the file for a while and run `/ignis eval arenaDebug.phase`. +- **Your editor** catches most typos before you save: a misspelled method or event field is underlined. + +## Checkpoint + +This is the finished arena. + +
+config/ignis/arena.lua, complete + + + +
+ +## Where to go from here + +Ideas to take it further, each a good exercise on its own: + +- **Several arenas.** Turn `config` and `game` into tables keyed by an arena name, and add a name argument to + the commands: `register("arena join ", ...)`. +- **Teams.** Two teams with colored names, friendly fire off (`player.attack` knows both players), and + a team win instead of a last player standing. +- **Spectators.** Instead of the lobby, knocked-out players become spectators (`player.gamemode = + "spectator"`) until the round ends. +- **Split it up.** Move the kits and the statistics into their own files and load them with `require`; see + [Language](/reference/language) and the [libraries overview](/libraries/overview). + +The [reference](/reference/mc-api) describes everything used here in full, and more. diff --git a/site/src/content/docs/tutorial/index.mdx b/site/src/content/docs/tutorial/index.mdx new file mode 100644 index 0000000..5cc9766 --- /dev/null +++ b/site/src/content/docs/tutorial/index.mdx @@ -0,0 +1,73 @@ +--- +title: "Tutorial: an arena minigame" +description: Build a complete PvP arena minigame with PxIgnis, one feature per chapter. +--- + +In this tutorial you build a small but complete minigame: a PvP arena where the last player standing wins. +Each chapter adds one feature and one part of the PxIgnis API, so by the end you have used most of what a +real server script needs. + +The finished arena has: + +- setup commands for admins: arena corners, spawn points, lobby, leaderboard; +- `/arena join`, `/arena leave`: a queue, a countdown and rounds that start by themselves; +- knockouts instead of deaths, so nobody loses their items or sees a death screen; +- a boss bar with the countdown and time left, and a scoreboard for every player; +- kits chosen from a chest menu, while the player's own items wait safely in storage; +- statistics per player and a leaderboard hologram; +- an `arena:win` event that other scripts use for rewards; +- clean behaviour on `/ignis reload` and after a server crash. + +## Before you start + +You need a server with PxIgnis and operator rights (or the `px.ignis` permission). Three things make the +work much faster; set them up first: + +1. **Editor.** Open `config/ignis/` in VS Code with the Lua extension, as described in + [Editor setup](/guide/getting-started#editor-setup). You get completion for the whole API and your + typos are underlined before you even reload. +2. **Auto-reload.** Run `/ignis watch on`. From now on every save of a `.lua` file reloads the scripts and + the result shows up in chat. +3. **Errors in chat.** Script errors are sent to operators in chat + ([details](/guide/getting-started#script-errors-in-chat)), with the file and line. + +`/ignis eval ` runs a line of Lua in the scripts' environment, handy for quick checks: +`/ignis eval me.pos` prints where you stand, `/ignis eval mc.data.arena` shows the saved arena settings. + +## How the tutorial works + +Everything lives in one file, `config/ignis/arena.lua`, which grows from chapter to chapter. The code +snippets in the text are pieces of it. Each chapter ends with a **checkpoint**: the complete file as it +should look at that point. If something does not work, compare with it; to start from any chapter, copy +the checkpoint of the previous one. + +The arena expects all its points (corners, spawns, lobby) in one area of one world. Build it somewhere +flat first: a fenced 20×20 field is enough, and a lobby a little outside it. + +## Chapters + +1. [Commands and saved settings](/tutorial/01-commands): `register`, permissions, `mc.data`. +2. [The arena region](/tutorial/02-region): regions and cancelling block events. +3. [Rounds](/tutorial/03-rounds): a game loop with the scheduler and `async.sleep`. +4. [Fights](/tutorial/04-fights): knockouts with `entity.death`, region enter and leave. +5. [Boss bar and scoreboard](/tutorial/05-interface): boss bars, sidebars and titles. +6. [Statistics and leaderboard](/tutorial/06-stats): `player.data` and holograms. +7. [Kits and inventories](/tutorial/07-kits): saving items and a chest menu. +8. [Events for other scripts](/tutorial/08-events): `mc.emit` and a second script. +9. [Reloads and crashes](/tutorial/09-reliability): what survives a reload and how to clean up. + +## The Lua you need + +If you have written a bit of JavaScript or Python, Lua reads easily. The differences that matter here: + +- `local` makes a variable local to the file or the block. Use it for everything; globals are shared by all + scripts. +- Tables (`{}`) are both arrays and dictionaries. Arrays start at index **1**; `#t` is the length of an + array; `pairs(t)` iterates over all keys, `ipairs(t)` over array items in order. +- `nil` means "no value". Setting a table key to `nil` removes it. +- `..` joins strings: `"Hello, " .. player.name`. +- `obj:method(x)` is a method call and is the same as `obj.method(obj, x)`. PxIgnis objects use `:` for + methods and `.` for properties: `player:sendMessage("hi")`, `player.health = 20`. +- `§` codes color text in Minecraft: `§a` green, `§c` red, `§e` yellow, `§7` gray, `§6` gold, `§l` bold. + +More in [Language](/reference/language). diff --git a/site/tutorial/README.md b/site/tutorial/README.md new file mode 100644 index 0000000..f4c7f23 --- /dev/null +++ b/site/tutorial/README.md @@ -0,0 +1,14 @@ +# Arena tutorial code + +The pages are in `site/src/content/docs/tutorial/`; the code they show lives here. + +- `arena/NN-*.lua`: the complete `arena.lua` after chapter NN (the "checkpoint" at the end of each page, + embedded with a `?raw` import). `08-rewards.lua` is the second script from chapter 8. +- `check-snippets.py`: every line of a ```` ```lua ```` block in page NN must appear in `arena/NN-*.lua`, so + the text cannot drift from the checkpoints. Mark an intentionally different block ```` ```lua nocheck ````. +- `test/`: a fake PxIgnis API (`mock.lua`) and scripts that play the arena through with it + (`smoke.lua` for each checkpoint, `scenario.lua` for the finished game). +- `test.sh`: runs all of the above plus `lua-language-server --check` against `lua-types/`. CI runs it in + the `tutorial` job. + +When you change a chapter, change its checkpoint and every later one the same way, then run `test.sh`. diff --git a/site/tutorial/arena/01-commands.lua b/site/tutorial/arena/01-commands.lua new file mode 100644 index 0000000..e368287 --- /dev/null +++ b/site/tutorial/arena/01-commands.lua @@ -0,0 +1,81 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. + +local ADMIN = "arena.admin" + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + players = {}, -- uuid -> Player: everyone who joined +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not config.lobby then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Commands ─────────────────────────────────────────────────────────────── + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/02-region.lua b/site/tutorial/arena/02-region.lua new file mode 100644 index 0000000..0f1e1bc --- /dev/null +++ b/site/tutorial/arena/02-region.lua @@ -0,0 +1,131 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. + +local ADMIN = "arena.admin" + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + players = {}, -- uuid -> Player: everyone who joined +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/03-rounds.lua b/site/tutorial/arena/03-rounds.lua new file mode 100644 index 0000000..7861c54 --- /dev/null +++ b/site/tutorial/arena/03-rounds.lua @@ -0,0 +1,216 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/04-fights.lua b/site/tutorial/arena/04-fights.lua new file mode 100644 index 0000000..e28b6d5 --- /dev/null +++ b/site/tutorial/arena/04-fights.lua @@ -0,0 +1,280 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/05-interface.lua b/site/tutorial/arena/05-interface.lua new file mode 100644 index 0000000..e0755cc --- /dev/null +++ b/site/tutorial/arena/05-interface.lua @@ -0,0 +1,322 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/06-stats.lua b/site/tutorial/arena/06-stats.lua new file mode 100644 index 0000000..1af62f7 --- /dev/null +++ b/site/tutorial/arena/06-stats.lua @@ -0,0 +1,393 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/arena/07-kits.lua b/site/tutorial/arena/07-kits.lua new file mode 100644 index 0000000..ec84947 --- /dev/null +++ b/site/tutorial/arena/07-kits.lua @@ -0,0 +1,481 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" +local chestgui = require "core:chestgui" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Inventory ────────────────────────────────────────────────────────────── + +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end + +-- ── Kits ─────────────────────────────────────────────────────────────────── + +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, + { + id = "archer", name = "§aArcher", icon = "minecraft:bow", + items = { + { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, + { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:leather_chestplate", + }, + { + id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", + items = { + { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, + }, + chest = "minecraft:diamond_chestplate", + }, +} + +local function findKit(id) + for _, kit in ipairs(KITS) do + if kit.id == id then return kit end + end + return KITS[1] +end + +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end + +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + "Kit: " .. findKit(player.data.arenaKit).name, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + restore(player) + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + restore(player) + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + stash(player) + giveKit(player) + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/arena/08-events.lua b/site/tutorial/arena/08-events.lua new file mode 100644 index 0000000..882e5eb --- /dev/null +++ b/site/tutorial/arena/08-events.lua @@ -0,0 +1,486 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" +local chestgui = require "core:chestgui" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Inventory ────────────────────────────────────────────────────────────── + +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end + +-- ── Kits ─────────────────────────────────────────────────────────────────── + +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, + { + id = "archer", name = "§aArcher", icon = "minecraft:bow", + items = { + { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, + { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:leather_chestplate", + }, + { + id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", + items = { + { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, + }, + chest = "minecraft:diamond_chestplate", + }, +} + +local function findKit(id) + for _, kit in ipairs(KITS) do + if kit.id == id then return kit end + end + return KITS[1] +end + +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end + +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + "Kit: " .. findKit(player.data.arenaKit).name, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + restore(player) + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + + -- Tell other scripts, now that the winner has their own items back. + if winner and game.players[winner.uuid] then + mc.emit("arena:win", winner, game.kills[winner.uuid] or 0) + end + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + restore(player) + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + stash(player) + giveKit(player) + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/arena/08-rewards.lua b/site/tutorial/arena/08-rewards.lua new file mode 100644 index 0000000..b985f11 --- /dev/null +++ b/site/tutorial/arena/08-rewards.lua @@ -0,0 +1,21 @@ +-- config/ignis/arena_rewards.lua: rewards for arena winners. +local async = require "async" + +-- A Discord webhook URL to announce winners there, or nil to skip it. +local WEBHOOK = nil + +mc.on("arena:win", function(winner, kills) + winner:give("minecraft:diamond", 1 + kills) + mc.broadcast("§6" .. winner.name .. " §ewon an arena round with " .. kills .. " kills!") + + if WEBHOOK then + local response = async.fetch({ + url = WEBHOOK, + method = "POST", + json = { content = winner.name .. " won an arena round with " .. kills .. " kills" }, + }) + if not response.ok then + print("arena webhook failed: " .. tostring(response.error or response.status)) + end + end +end) diff --git a/site/tutorial/arena/09-final.lua b/site/tutorial/arena/09-final.lua new file mode 100644 index 0000000..cedb916 --- /dev/null +++ b/site/tutorial/arena/09-final.lua @@ -0,0 +1,512 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" +local chestgui = require "core:chestgui" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Inventory ────────────────────────────────────────────────────────────── + +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end + +-- ── Kits ─────────────────────────────────────────────────────────────────── + +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, + { + id = "archer", name = "§aArcher", icon = "minecraft:bow", + items = { + { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, + { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:leather_chestplate", + }, + { + id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", + items = { + { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, + }, + chest = "minecraft:diamond_chestplate", + }, +} + +local function findKit(id) + for _, kit in ipairs(KITS) do + if kit.id == id then return kit end + end + return KITS[1] +end + +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end + +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + "Kit: " .. findKit(player.data.arenaKit).name, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + restore(player) + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + + -- Tell other scripts, now that the winner has their own items back. + if winner and game.players[winner.uuid] then + mc.emit("arena:win", winner, game.kills[winner.uuid] or 0) + end + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + restore(player) + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + if not isReady() then + game.phase = "waiting" + tell("§cThe arena setup is incomplete, the round cannot start.") + return + end + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + stash(player) + giveKit(player) + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- Came back after a crash in the middle of a round: return their items. +mc.on("player.join", function(e) + if e.player.data.arenaStash then + restore(e.player) + if config.lobby then teleport(e.player, config.lobby) end + e.player:sendMessage("§eYour items from the last arena round were returned.") + end +end) + +-- Before a reload the game state is thrown away: put everyone back first. +mc.on("uninit", function() + for uuid, player in pairs(game.players) do + if game.alive[uuid] then + restore(player) + teleport(player, config.lobby) + end + player.sidebar = nil + player:sendMessage("§eThe arena was reloaded, join again with /arena join.") + end +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/check-snippets.py b/site/tutorial/check-snippets.py new file mode 100644 index 0000000..6a4d4fd --- /dev/null +++ b/site/tutorial/check-snippets.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Checks that the Lua snippets in the tutorial pages are taken from the chapter's checkpoint files. + +Every ```lua block in site/src/content/docs/tutorial/NN-*.mdx must consist of lines that appear in one of +site/tutorial/arena/NN-*.lua (indentation included). Blocks marked ```lua nocheck are skipped. +""" +import pathlib +import re +import sys + +root = pathlib.Path(__file__).resolve().parent +pages = root.parent / "src" / "content" / "docs" / "tutorial" +code = root / "arena" + +errors = 0 +for page in sorted(pages.glob("[0-9][0-9]-*.mdx")): + chapter = page.name[:2] + files = sorted(code.glob(chapter + "-*.lua")) + if not files: + print(f"{page.name}: no checkpoint file {chapter}-*.lua") + errors += 1 + continue + known = set() + for f in files: + known.update(f.read_text(encoding="utf-8").splitlines()) + text = page.read_text(encoding="utf-8") + for block in re.finditer(r"^```lua([^\n]*)\n(.*?)^```", text, re.M | re.S): + if "nocheck" in block.group(1): + continue + first = text[: block.start()].count("\n") + 2 + for n, line in enumerate(block.group(2).splitlines()): + if line.strip() and line not in known: + print(f"{page.name}:{first + n}: not in {', '.join(f.name for f in files)}: {line.strip()}") + errors += 1 + +if errors: + sys.exit(1) +print("tutorial snippets match their checkpoints") diff --git a/site/tutorial/test.sh b/site/tutorial/test.sh new file mode 100755 index 0000000..2abbc5b --- /dev/null +++ b/site/tutorial/test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Checks the arena tutorial: the snippets in the pages match the checkpoint files, LuaLS finds no problems +# in them, and the game logic runs against a fake API (test/mock.lua). +# Needs python3, lua5.2 and lua-language-server (set LUALS to its path if it is not on PATH). +set -euo pipefail +cd "$(dirname "$0")" +LUALS=${LUALS:-lua-language-server} + +python3 check-snippets.py + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +failed=0 +for file in arena/*.lua; do + ws="$work/$(basename "$file" .lua)" + mkdir -p "$ws" + cp -r ../../lua-types "$ws/.types" + cp "$file" "$ws/" + cat > "$ws/.luarc.json" <<'JSON' +{ + "runtime.version": "Lua 5.2", + "runtime.builtin": { "io": "disable", "os": "disable", "debug": "disable" }, + "workspace.library": [".types"] +} +JSON + "$LUALS" --check="$ws" --checklevel=Hint --check_format=json --check_out_path="$ws/check.json" \ + --logpath="$ws/log" >/dev/null 2>&1 || true + if [ ! -f "$ws/check.json" ]; then + echo "$file: LuaLS produced no report"; failed=1 + elif [ "$(tr -d '[:space:]' < "$ws/check.json")" != "[]" ]; then + echo "$file: LuaLS problems:"; cat "$ws/check.json"; failed=1 + fi +done +[ "$failed" = 0 ] && echo "LuaLS: no problems" + +for n in 1 2 3 4 5 6 7 8 9; do + file=$(ls arena/0$n-*.lua | grep -v rewards) + lua5.2 test/smoke.lua test "$file" "$n" +done +lua5.2 test/scenario.lua test arena/09-final.lua +lua5.2 test/scenario.lua test arena/09-final.lua arena/08-rewards.lua + +exit "$failed" diff --git a/site/tutorial/test/mock.lua b/site/tutorial/test/mock.lua new file mode 100644 index 0000000..adb96e9 --- /dev/null +++ b/site/tutorial/test/mock.lua @@ -0,0 +1,270 @@ +-- A small fake of the PxIgnis API, just enough to run the tutorial scripts in plain Lua 5.2 and check +-- the game logic. It is not a model of the real server: behaviour the tutorial relies on is imitated +-- (storage copies tables on assignment, cancelled events stop later handlers, regions fire enter/leave). +local M = { log = {}, emitted = {}, tickNo = 0 } + +local function log(s) M.log[#M.log + 1] = s end + +-- ── persistent data: assignment copies tables (like DataTable) ───────────── +local function persist(src) + local store = {} + local proxy = setmetatable({}, { + __index = store, + __newindex = function(_, k, v) + assert(type(k) == "string" or type(k) == "number", "bad key " .. tostring(k)) + local t = type(v) + assert(t == "nil" or t == "string" or t == "number" or t == "boolean" or t == "table", + "Cannot store " .. t .. " values in persistent data") + if t == "table" then + if getmetatable(v) and getmetatable(v).__persist then + store[k] = v + else + assert(not getmetatable(v), "cannot store table with metatable (userdata-like)") + store[k] = M.persist(v) + end + else + store[k] = v + end + end, + __len = function() return #store end, + __pairs = function() return next, store, nil end, + __persist = true, + }) + for k, v in pairs(src or {}) do proxy[k] = v end + return proxy +end +M.persist = persist + +-- JSON-like round trip: sequences stay sequences, other keys become strings. +function M.roundtrip(p) + local function copy(t) + local out, n = {}, #t + local isList = n > 0 + for k in pairs(t) do + if type(k) ~= "number" or k < 1 or k > n then isList = false end + end + for k, v in pairs(t) do + local key = isList and k or tostring(k) + out[key] = type(v) == "table" and copy(v) or v + end + return out + end + return persist(copy(p)) +end + +-- ── vec ─────────────────────────────────────────────────────────────────── +local V = {} +V.__index = V +local function vec(x, y, z) return setmetatable({ x = x, y = y, z = z }, V) end +V.__add = function(a, b) + if type(b) == "number" then return vec(a.x + b, a.y + b, a.z + b) end + return vec(a.x + b.x, a.y + b.y, a.z + b.z) +end +V.__tostring = function(a) return string.format("(%g, %g, %g)", a.x, a.y, a.z) end +_G.vec = vec + +-- ── world / regions / holograms ─────────────────────────────────────────── +local worlds = {} +M.regions = {} +local function world(name) + if worlds[name] then return worlds[name] end + local w = { name = name } + function w:particle(id, pos, opts) assert(getmetatable(pos) == V) log("particle " .. id) end + function w:playSound(id, pos) log("sound " .. id) end + function w:spawnHologram(pos, text) + local h = { text = text, lines = { text }, pos = pos, destroyed = false } + function h:destroy() self.destroyed = true end + M.hologram = h + return h + end + function w:createRegion(a, b) + local r = { world = w, a = a, b = b, handlers = {}, inside = {}, destroyed = false } + function r:on(ev, fn) self.handlers[ev] = self.handlers[ev] or {}; table.insert(self.handlers[ev], fn); return 1 end + function r:contains(p) + return p.x >= a.x and p.x < b.x and p.y >= a.y and p.y < b.y and p.z >= a.z and p.z < b.z + end + function r:destroy() self.destroyed = true end + table.insert(M.regions, r) + return r + end + worlds[name] = w + return w +end + +-- ── players ─────────────────────────────────────────────────────────────── +M.players = {} +function M.player(name, opts) + local p = { + uuid = "uuid-" .. name, name = name, pos = vec(0, 64, 0), world = world("overworld"), + health = 20, maxHealth = 20, food = 20, fireTicks = 0, inv = {}, msgs = {}, titles = {}, + perms = opts and opts.perms or {}, online = true, + } + p.data = persist({}) + function p:sendMessage(t) table.insert(self.msgs, t); log(self.name .. " <- " .. t) end + function p:sendTitle(a, b) table.insert(self.titles, a .. " " .. (b or "")) end + function p:playSound() end + function p:hasPermission(perm) return self.perms[perm] == true end + function p:teleport(x, y, z, w) + assert(type(x) == "number" and type(y) == "number" and type(z) == "number", "teleport: numbers expected") + assert(w == nil or type(w) == "string", "teleport: world name") + self.pos = vec(x, y, z); self.world = world(w or self.world.name) + end + function p:give(id, count) + assert(type(id) == "string") + for slot = 0, 35 do + if not self.inv[slot] then self.inv[slot] = { id = id, count = count or 1 }; return end + end + end + function p:has(id) + for _, it in pairs(self.inv) do if it.id == id then return it end end + end + function p:getItem(slot) + assert(slot >= 0 and slot <= 40) + return self.inv[slot] and M.item(self.inv[slot].id) or nil + end + function p:setItem(slot, item) assert(type(slot) == "number"); self.inv[slot] = item and { id = item.id } or nil end + function p:clear() self.inv = {}; self.chestItem = nil end + setmetatable(p, { + __newindex = function(t, k, v) + if k == "chest" then rawset(t, "chestItem", v); return end + if k == "sidebar" then + assert(v == nil or type(v) == "table") + rawset(t, "sidebarValue", v); return + end + rawset(t, k, v) + end, + }) + table.insert(M.players, p) + return p +end + +function M.item(id) + local it = { id = id } + function it:serialise() return "json:" .. self.id end + return it +end + +-- ── events ──────────────────────────────────────────────────────────────── +local handlers = {} +local PRI = { lowest = -200, low = -100, normal = 0, high = 100, highest = 200 } +local mc = {} +function mc.on(name, fn, opts) + local pr = opts and opts.priority or 0 + if type(pr) == "string" then pr = assert(PRI[pr]) end + handlers[name] = handlers[name] or {} + table.insert(handlers[name], { fn = fn, pr = pr }) + table.sort(handlers[name], function(a, b) return a.pr > b.pr end) +end +function M.fire(name, fields, cancellable) + local e = fields or {} + e.name = name; e.cancelled = false + function e:cancel() assert(cancellable, "not cancellable " .. name); self.cancelled = true end + for _, h in ipairs(handlers[name] or {}) do + if e.cancelled then break end + local co = coroutine.create(h.fn) + local ok, err = coroutine.resume(co, e) + if not ok then error("handler of " .. name .. ": " .. tostring(err)) end + end + return e +end +function mc.emit(name, ...) + table.insert(M.emitted, { name, ... }) + for _, h in ipairs(handlers[name] or {}) do + local co = coroutine.create(h.fn) + local ok, err = coroutine.resume(co, ...) + if not ok then error("handler of " .. name .. ": " .. tostring(err)) end + end + return true +end +function mc.broadcast(t) log("broadcast " .. t) end + +-- ── scheduler + async ───────────────────────────────────────────────────── +local tasks = {} +function mc.schedule(delay, fn) table.insert(tasks, { at = M.tickNo + math.max(delay, 0), fn = fn }) end +function mc.scheduleRepeating(delay, interval, fn) table.insert(tasks, { at = M.tickNo + delay, every = interval, fn = fn }) end +local async = {} +function async.sleep(ticks) + local co = assert(coroutine.running(), "async.sleep outside coroutine") + table.insert(tasks, { at = M.tickNo + ticks, co = co }) + coroutine.yield() +end +local function runTask(t) + local co = t.co or coroutine.create(t.fn) + local ok, err = coroutine.resume(co) + if not ok then error("task: " .. tostring(err)) end +end +function M.regionTick() + for _, r in ipairs(M.regions) do + if not r.destroyed then + for _, p in ipairs(M.players) do + if p.online then + local now = p.world == r.world and r:contains(p.pos) + local was = r.inside[p.uuid] or false + if now ~= was then + r.inside[p.uuid] = now + for _, fn in ipairs(r.handlers[now and "enter" or "leave"] or {}) do + local co = coroutine.create(fn) + assert(coroutine.resume(co, { entity = p, player = p, region = r })) + end + end + end + end + end + end +end +function M.tick(n) + for _ = 1, n or 1 do + M.tickNo = M.tickNo + 1 + local due = {} + for i = #tasks, 1, -1 do + if tasks[i].at <= M.tickNo then table.insert(due, 1, table.remove(tasks, i)) end + end + for _, t in ipairs(due) do + if t.every then t.at = M.tickNo + t.every; table.insert(tasks, t) end + runTask(t) + end + M.regionTick() + end +end +function M.seconds(n) M.tick(n * 20) end + +-- ── misc API ────────────────────────────────────────────────────────────── +mc.data = persist({}) +function mc.world(name) return world(name) end +function mc.createItem(id, opts) local it = M.item(id); it.opts = opts; return it end +function mc.deserialise(kind, json) assert(kind == "item"); return M.item(json:sub(6)) end +function mc.createBossBar(title) + local b = { title = title, progress = 0, players = {} } + function b:addPlayer(p) self.players[p.uuid] = true end + function b:removePlayer(p) self.players[p.uuid] = nil end + M.bar = b + return b +end +M.commands = {} +function _G.register(syntax, fn, perm) M.commands[syntax] = { fn = fn, perm = perm } end +function M.run(player, syntax, ...) + local c = assert(M.commands[syntax], "no command " .. syntax) + if c.perm then assert(player:hasPermission(c.perm), "no permission") end + local co = coroutine.create(c.fn) + local ok, err = coroutine.resume(co, { player = player }, ...) + if not ok then error(syntax .. ": " .. tostring(err)) end +end + +local chestgui = {} +function chestgui.create(rows, title) + local g = { rows = rows, title = title, cbs = {} } + function g:set(r, c, item, cb) assert(c >= 1 and c <= 9); self.cbs[(r - 1) * 9 + c] = { item = item, cb = cb } end + function g:open(p) M.openGui = self end + function g:close(p) end + function g:click(p, slot) local s = self.cbs[slot]; return s and s.cb and s.cb(p, slot, "pickup", s.item, nil) end + return g +end + +package.preload["async"] = function() return async end +package.preload["core:chestgui"] = function() return chestgui end +_G.mc = mc +M.mc = mc +function M.reset() + handlers = {}; tasks = {}; M.regions = {}; M.commands = {} +end +return M diff --git a/site/tutorial/test/scenario.lua b/site/tutorial/test/scenario.lua new file mode 100644 index 0000000..458ce78 --- /dev/null +++ b/site/tutorial/test/scenario.lua @@ -0,0 +1,128 @@ +-- Plays the finished arena through: setup, kits, rounds, knockouts, leaving, reloads, a restart. +-- lua5.2 scenario.lua [arena_rewards.lua] +package.path = arg[1] .. "/?.lua;" .. package.path +local M = require "mock" +local script = arg[2] + +local admin = M.player("Admin", { perms = { ["arena.admin"] = true } }) +local alice = M.player("Alice") +local bob = M.player("Bob") +for _, p in ipairs({ alice, bob }) do p.pos = vec(100, 64, 100) end + +assert(loadfile(script))() +if arg[3] then assert(loadfile(arg[3]))() end + +-- setup +admin.pos = vec(0.5, 60, 0.5); M.run(admin, "arena pos1") +admin.pos = vec(20.2, 70, 20.9); M.run(admin, "arena pos2") +assert(#M.regions == 1, "region after both corners") +local r = M.regions[1] +assert(r:contains(vec(20.5, 70.5, 20.5)) and not r:contains(vec(21, 70, 20)), "max block included") +M.run(alice, "arena join") +assert(alice.msgs[#alice.msgs]:find("not set up"), "join before setup refused") +admin.pos = vec(2, 61, 2); M.run(admin, "arena addspawn") +admin.pos = vec(18, 61, 18); M.run(admin, "arena addspawn") +admin.pos = vec(50, 64, 50); M.run(admin, "arena setlobby") +admin.pos = vec(52, 64, 50); M.run(admin, "arena setleaderboard") +assert(M.hologram and M.hologram.lines[2]:find("no winners"), "empty leaderboard") +assert(#mc.data.arena.spawns == 2) + +-- kits +M.run(alice, "arena kit") +M.openGui:click(alice, 5) -- second kit (col 5) +assert(alice.data.arenaKit == "archer", tostring(alice.data.arenaKit)) + +-- inventories to stash +alice.inv = { [0] = { id = "minecraft:diamond" }, [38] = { id = "minecraft:elytra" } } +bob.inv = { [5] = { id = "minecraft:stone" } } + +M.run(alice, "arena join") +assert(alice.pos.x == 50, "teleported to lobby") +M.seconds(2) +assert(M.bar.title:find("Waiting"), M.bar.title) +M.run(bob, "arena join") +M.seconds(1) +assert(M.bar.title:find("Starting in"), M.bar.title) +M.seconds(10) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +assert(alice.data.arenaStash["0"] == "json:minecraft:diamond" and alice.data.arenaStash["38"] == "json:minecraft:elytra") +assert(alice:has("minecraft:bow") and not alice:has("minecraft:diamond"), "archer kit, own items stashed") +assert(bob:has("minecraft:iron_sword"), "warrior kit default") +assert(alice.chestItem.id == "minecraft:leather_chestplate") +assert(r:contains(alice.pos) and r:contains(bob.pos), "players in arena") +assert(alice.sidebarValue.lines[1]:find("fighting")) + +-- block protection +local e = M.fire("block.break", { player = alice, pos = vec(5, 61, 5) }, true) +assert(e.cancelled, "arena protected") +e = M.fire("block.break", { player = admin, pos = vec(5, 61, 5) }, true) +assert(not e.cancelled, "admin can build") +e = M.fire("block.break", { player = alice, pos = vec(500, 61, 5) }, true) +assert(not e.cancelled, "outside not protected") + +-- alice kills bob +bob.health = 0 +e = M.fire("entity.death", { entity = bob, player = bob, attacker = alice, source = "player_attack", amount = 5 }, true) +assert(e.cancelled and bob.health == 20, "death cancelled") +assert(bob.pos.x == 50, "bob back in lobby") +assert(bob.inv[5].id == "minecraft:stone" and bob.data.arenaStash == nil, "bob items back") +assert(bob.data.arenaStats.deaths == 1 and alice.data.arenaStats.kills == 1) +assert(#M.emitted == 0, "win announced after the celebration") +assert(alice.titles[1]:find("Alice")) +assert(M.hologram.lines[2]:find("Alice") and M.hologram.lines[2]:find("1"), M.hologram.lines[2]) +-- winner leaves region during celebration: no elimination +alice.pos = vec(100, 64, 100); M.tick(1) +alice.pos = vec(5, 61, 5) +M.seconds(6) +assert(alice.pos.x == 50 and alice.inv[38].id == "minecraft:elytra" and not alice:has("minecraft:bow"), "alice restored") +assert(#M.emitted == 1 and M.emitted[1][1] == "arena:win" and M.emitted[1][2] == alice and M.emitted[1][3] == 1) +if arg[3] then + local d = alice.inv[1] + assert(d and d.id == "minecraft:diamond" and d.count == 2, "reward kept after restore") +end +assert(alice.data.arenaStats.deaths == 0 and alice.data.arenaStats.wins == 1) + +-- next round starts automatically, bob walks out of the arena +M.seconds(11) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +bob.pos = vec(100, 64, 100); M.tick(1) +assert(bob.msgs[#bob.msgs - 1]:find("left the arena") or bob.msgs[#bob.msgs]:find("eliminated"), bob.msgs[#bob.msgs]) +assert(bob.pos.x == 50 and alice.data.arenaStats.wins == 2 or alice.data.arenaStats.wins == 2) +M.seconds(6) + +-- third round: bob disconnects mid-round +M.seconds(11) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +M.fire("player.leave", { player = bob }) +bob.online = false +assert(alice.data.arenaStats.wins == 3) +M.seconds(6) + +-- a stranger walks into a running round +local carol = M.player("Carol"); carol.pos = vec(100, 64, 100) +M.run(carol, "arena join") +M.seconds(11) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +local dave = M.player("Dave"); dave.pos = vec(100, 64, 100); M.tick(1) +dave.pos = vec(3, 61, 3); M.tick(1) +assert(dave.pos.x == 50, "stranger teleported out") + +-- crash: carol has a stash; simulate a lost state +local stash = carol.data.arenaStash +assert(stash, "carol stashed") +M.fire("uninit", {}) +assert(carol.data.arenaStash == nil and carol.pos.x == 50, "uninit restores") + +-- persisted data survives a JSON round trip (restart) +mc.data = M.roundtrip(mc.data) +M.reset() +assert(loadfile(script))() +assert(#M.regions == 1 and M.regions[1]:contains(vec(20.5, 70.5, 20.5)), "region rebuilt after restart") +local eve = M.player("Eve"); eve.pos = vec(100, 64, 100) +eve.data = M.roundtrip(M.persist({ arenaStash = { ["3"] = "json:minecraft:apple" } })) +M.fire("player.join", { player = eve }) +assert(eve.inv[3].id == "minecraft:apple" and eve.pos.x == 50, "crash recovery") +M.run(eve, "arena stats []") +assert(eve.msgs[#eve.msgs]:find("0 wins"), eve.msgs[#eve.msgs]) +assert(M.hologram.lines[2]:find("Alice"), "leaderboard after restart") +print("OK: " .. #M.log .. " log lines") diff --git a/site/tutorial/test/smoke.lua b/site/tutorial/test/smoke.lua new file mode 100644 index 0000000..4fbe8dc --- /dev/null +++ b/site/tutorial/test/smoke.lua @@ -0,0 +1,60 @@ +-- Smoke test for a tutorial checkpoint: lua5.2 smoke.lua +package.path = arg[1] .. "/?.lua;" .. package.path +local M = require "mock" +local ch = tonumber(arg[3]) + +local admin = M.player("Admin", { perms = { ["arena.admin"] = true } }) +local alice = M.player("Alice") +local bob = M.player("Bob") +alice.pos = vec(100, 64, 100); bob.pos = vec(100, 64, 100) +alice.inv = { [0] = { id = "minecraft:diamond" } } + +assert(loadfile(arg[2]))() + +if ch >= 2 then + admin.pos = vec(0, 60, 0); M.run(admin, "arena pos1") + admin.pos = vec(20, 70, 20); M.run(admin, "arena pos2") + assert(#M.regions == 1) + local e = M.fire("block.place", { player = alice, pos = vec(1, 61, 1) }, true) + assert(e.cancelled, "protected") +end +admin.pos = vec(2, 61, 2); M.run(admin, "arena addspawn") +admin.pos = vec(18, 61, 18); M.run(admin, "arena addspawn") +admin.pos = vec(50, 64, 50); M.run(admin, "arena setlobby") + +M.run(alice, "arena join"); M.run(bob, "arena join") +assert(alice.pos.x == 50 and bob.pos.x == 50, "in lobby") + +if ch == 1 then + M.run(bob, "arena leave") + assert(bob.msgs[#bob.msgs]:find("left")) + print("OK ch1"); return +end +if ch == 2 then print("OK ch2"); return end + +M.seconds(12) +assert(alice.pos.x ~= 50 and bob.pos.x ~= 50, "round started, players at spawns") +if ch >= 7 then assert(alice.data.arenaStash and not alice:has("minecraft:diamond"), "stashed") end +if ch >= 5 then assert(M.bar.title:find("Alive"), M.bar.title); assert(alice.sidebarValue) end + +if ch == 3 then + M.seconds(300) + assert(alice.msgs[#alice.msgs]:find("draw"), alice.msgs[#alice.msgs]) + M.seconds(6) + assert(alice.pos.x == 50 and bob.pos.x == 50, "back in lobby after draw") + print("OK ch3"); return +end + +bob.health = 0 +local e = M.fire("entity.death", { entity = bob, player = bob, attacker = alice, source = "player_attack", amount = 5 }, true) +assert(e.cancelled and bob.pos.x == 50) +assert(alice.titles[1]:find("Alice")) +M.seconds(6) +assert(alice.pos.x == 50, "winner back in lobby") +if ch >= 6 then + M.run(alice, "arena stats []") + assert(alice.msgs[#alice.msgs]:find("1 wins, 1 kills"), alice.msgs[#alice.msgs]) +end +if ch >= 7 then assert(alice:has("minecraft:diamond") and not alice.data.arenaStash, "restored") end +if ch >= 8 then assert(#M.emitted == 1) else assert(#M.emitted == 0) end +print("OK ch" .. ch)