mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb7be3765 | ||
|
|
bad95e4f6a | ||
|
|
b03d1411d1 | ||
|
|
a24a6fa26c | ||
|
|
6d9bb375c5 | ||
|
|
10c9d8440c | ||
|
|
7f6d556e68 | ||
|
|
d33f547bf7 | ||
|
|
2e7283d01c | ||
|
|
9a7f955a77 | ||
|
|
6b7617c277 | ||
|
|
7c5efff307 | ||
|
|
1f6e498238 | ||
|
|
32f6079134 | ||
|
|
ce1db1b0c3 | ||
|
|
ad3ddeb43f | ||
|
|
34f4d3b451 | ||
|
|
27de895fdb |
No files matched your search
Generated
+115
-2
@@ -477,6 +477,13 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud-lua"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-lua",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -1100,6 +1107,30 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-arena"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"gc-arena-derive",
|
||||
"hashbrown 0.14.5",
|
||||
"sptr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-arena-derive"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1264,6 +1295,16 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1468,6 +1509,16 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-lua"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-mod-sdk",
|
||||
"piccolo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
@@ -2744,6 +2795,21 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "piccolo"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
"anyhow",
|
||||
"gc-arena",
|
||||
"hashbrown 0.14.5",
|
||||
"rand",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2871,6 +2937,15 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
@@ -2971,6 +3046,27 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3450,6 +3546,12 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sptr"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3567,6 +3669,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -5228,7 +5341,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5269,7 +5382,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -30,6 +30,7 @@ members = [
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud-lua",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
@@ -40,6 +41,7 @@ members = [
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
"crates/horizon-mod-lua",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
/// The Lua runtime from the same repository, with the template's capability.
|
||||
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
let (sdk, lua) = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
|
||||
(
|
||||
format!("{{ path = {:?} }}", p.display().to_string()),
|
||||
format!(
|
||||
"{{ path = {:?}, features = [\"chat\"] }}",
|
||||
lua.display().to_string()
|
||||
),
|
||||
)
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
.replace("{lua}", &lua)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
// The code template's mod, written in Lua.
|
||||
let lua_files = [
|
||||
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
|
||||
("main.lua", include_str!("../template/lua/main.lua")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
"lua" => &lua_files,
|
||||
other => return Err(format!("no template {other}: code, content or lua")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
|
||||
// takes a custom source only with this cfg.
|
||||
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
|
||||
cmd.env(
|
||||
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
|
||||
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
|
||||
);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
@@ -386,6 +412,26 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lua_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
|
||||
let path = dir.join("hello-lua");
|
||||
new(&[
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into(),
|
||||
])
|
||||
.unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
|
||||
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
|
||||
assert!(script.contains("horizon.command(\"hello\")"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -424,7 +470,7 @@ mod tests {
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
"python".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# The Lua interpreter, built into the mod with main.lua. Its features are the
|
||||
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
|
||||
# the two lists the same.
|
||||
[dependencies]
|
||||
horizon-mod-lua = {lua}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -0,0 +1,12 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the Lua runtime targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -0,0 +1,12 @@
|
||||
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
|
||||
-- Commands come from horizon.command; hooks are global functions:
|
||||
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
|
||||
|
||||
local greeted = 0
|
||||
|
||||
horizon.command("hello")
|
||||
|
||||
function on_command(name, args)
|
||||
greeted = greeted + 1
|
||||
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
// The mod is main.lua: this builds it into the mod with its interpreter.
|
||||
horizon_mod_lua::lua_mod!("main.lua");
|
||||
@@ -124,18 +124,6 @@ impl Clicks {
|
||||
self.skip_next_release = true;
|
||||
}
|
||||
|
||||
/// The screen that takes the pointer over starts with its own state:
|
||||
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen, so a screen that opens (or closes) drops
|
||||
/// them. A press whose release never arrived -- the pointer left the
|
||||
/// window -- would otherwise keep the drag running, and `press` answers
|
||||
/// `None` for every later press, so the quick move and the clicks go
|
||||
/// dead on every screen after it.
|
||||
pub fn cancel_drag(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = false;
|
||||
}
|
||||
|
||||
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
|
||||
/// `target`, if any. A press holding a stack starts a quick craft drag
|
||||
/// instead. `cloning` is `keyPickItem` with infinite materials.
|
||||
@@ -313,35 +301,4 @@ mod tests {
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
|
||||
/// #1737: a press whose release never arrives (the pointer left the
|
||||
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
|
||||
/// every later press, so the Shift quick move and the clicks on the
|
||||
/// inventory go dead. The drag belongs to the screen that has it
|
||||
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
|
||||
/// that takes the pointer over drops it and the quick move works again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_quick_move() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release never arrives.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
// The stuck drag swallows the next press: the Shift quick move stops
|
||||
// working and the inventory stops answering.
|
||||
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
|
||||
// The screen that takes the pointer over starts with its own state.
|
||||
clicks.cancel_drag();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
// The quick move works again.
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Right, true, true, false),
|
||||
Some(Input::QuickMove)
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5157,13 +5157,10 @@ impl Game<'_> {
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
}
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
// that takes the pointer over starts with its own state, as
|
||||
// `AbstractContainerScreen` does, so a press whose release never
|
||||
// arrived (the pointer left the window) cannot wedge the next
|
||||
// screen's quick move and clicks.
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
if capture {
|
||||
// The inventory's screens are behind us: no drag survives them.
|
||||
self.reset_inventory_clicks();
|
||||
} else {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
@@ -6012,14 +6009,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// belongs to on the screen; Horizon keeps them on the game, so they are
|
||||
/// dropped when a screen closes. A press whose release never reaches the
|
||||
/// screen (the pointer left the window, or the screen closed first) would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_clicks = Default::default();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
|
||||
@@ -3211,64 +3211,4 @@ pub(crate) mod tests {
|
||||
assert_eq!(menu.slot(0), stack("dirt", 1).as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
/// #1737: a Shift+Right-Click quick move in a chest (a double chest's
|
||||
/// 54 slots) moves the stack and leaves the menu's state clean, so the
|
||||
/// next quick move still moves its stack and the inventory still
|
||||
/// answers.
|
||||
#[test]
|
||||
fn a_chest_quick_move_keeps_the_next_one_working() {
|
||||
with_menu(Kind::Chest(6), |env, menu| {
|
||||
menu.creative = false;
|
||||
// The hotbar holds two stacks; the chest is empty.
|
||||
menu.set_slot(env, 81, stack("cobblestone", 64));
|
||||
menu.set_slot(env, 82, stack("dirt", 32));
|
||||
// Shift+Right-Click on the hotbar: the stack goes into the chest.
|
||||
menu.click(env, 81, 1, Input::QuickMove).unwrap();
|
||||
assert_eq!(menu.slot(81), None);
|
||||
assert_eq!(menu.slot(0).map(|s| s.count), Some(64));
|
||||
// Nothing is left on the cursor and no quick craft is running.
|
||||
assert_eq!(menu.carried, None);
|
||||
assert_eq!(menu.quick_status, 0);
|
||||
// The next quick move still works.
|
||||
menu.click(env, 82, 1, Input::QuickMove).unwrap();
|
||||
assert_eq!(menu.slot(82), None);
|
||||
assert_eq!(menu.slot(1).map(|s| s.count), Some(32));
|
||||
assert_eq!(menu.carried, None);
|
||||
assert_eq!(menu.quick_status, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// #1832: a potion taken from the creative inventory and put in the
|
||||
/// hotbar keeps its `PotionContents` (the brew), as vanilla's
|
||||
/// `PotionContents.createItemStack` builds it: the item tab's stack is
|
||||
/// the real one, and the hotbar slot holds it as it is.
|
||||
#[test]
|
||||
fn a_creative_potion_keeps_its_contents_in_the_hotbar() {
|
||||
let snbt = r#"{components:{"minecraft:potion_contents":{potion:"minecraft:swiftness"}},count:1,id:"minecraft:potion"}"#;
|
||||
let potion = crate::item_data::parse_stack(snbt).unwrap().unwrap();
|
||||
assert_eq!(potion.potion(), Some("minecraft:swiftness"));
|
||||
// The creative screen hands the stack over...
|
||||
let mut carried = None;
|
||||
crate::creative::click_display(
|
||||
&mut carried,
|
||||
Some(&potion),
|
||||
|_: &ItemStack| 1,
|
||||
0,
|
||||
Input::Pickup,
|
||||
);
|
||||
assert_eq!(
|
||||
carried.as_ref().and_then(|s| s.potion()),
|
||||
Some("minecraft:swiftness")
|
||||
);
|
||||
// ... and the hotbar slot keeps it: it is not the uncraftable
|
||||
// potion, which is a `minecraft:potion` with no contents at all.
|
||||
with_menu(Kind::ItemPicker, |env, menu| {
|
||||
menu.carried = carried;
|
||||
menu.click(env, 45, 0, Input::Pickup).unwrap();
|
||||
let in_hand = menu.slot(45).expect("the hotbar slot");
|
||||
assert_eq!(in_hand.potion(), Some("minecraft:swiftness"));
|
||||
assert_eq!(&*in_hand.item, "minecraft:potion");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4043,190 +4043,4 @@ mod tests {
|
||||
"{walked} steps over {metres} step-metres"
|
||||
);
|
||||
}
|
||||
/// Vanilla sprint is a state, not a held key: `LocalPlayer.aiStep` starts
|
||||
/// it with the sprint key and stops it only on a vanilla stop condition.
|
||||
/// Releasing the key while swimming keeps the sprint and its swim speed.
|
||||
#[test]
|
||||
fn a_sprint_survives_the_sprint_key_coming_up_while_swimming() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let forward = crate::sprint::Keys {
|
||||
forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
let sprinting = crate::sprint::Keys {
|
||||
sprint: true,
|
||||
..forward
|
||||
};
|
||||
// The same swim with the sprint key held, and with the key released
|
||||
// once the sprint has started: the speed must not change.
|
||||
let mut swims = Vec::new();
|
||||
for release in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
|
||||
for x in 19..=21 {
|
||||
for y in 4..=8 {
|
||||
for z in 19..=31 {
|
||||
scene.set(x, y, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
let mut speed = Vec::new();
|
||||
for tick in 0..12 {
|
||||
// The key comes up on the fourth tick, after the sprint has
|
||||
// started and the player swims.
|
||||
let keys = if release && tick >= 4 {
|
||||
forward
|
||||
} else {
|
||||
sprinting
|
||||
};
|
||||
w.controlled_simulation_tick(keys).unwrap();
|
||||
speed.push(w.player.velocity.z);
|
||||
}
|
||||
assert!(
|
||||
w.player.sprinting,
|
||||
"the sprint state survives the sprint key in water"
|
||||
);
|
||||
assert!(w.player.swimming, "a sprinting player in water swims");
|
||||
swims.push(speed);
|
||||
}
|
||||
assert_eq!(
|
||||
swims[0], swims[1],
|
||||
"the swim speed must not drop when the sprint key comes up"
|
||||
);
|
||||
}
|
||||
|
||||
/// Vanilla: `LivingEntity.jumpFromGround` adds its 0.2 forward impulse
|
||||
/// while the sprint state holds, so a jump taken after the sprint key
|
||||
/// comes up is still a sprint jump.
|
||||
#[test]
|
||||
fn a_sprint_jump_keeps_its_impulse_when_the_sprint_key_comes_up() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
let forward = crate::sprint::Keys {
|
||||
forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
let sprinting = crate::sprint::Keys {
|
||||
sprint: true,
|
||||
..forward
|
||||
};
|
||||
for _ in 0..20 {
|
||||
w.controlled_simulation_tick(sprinting).unwrap();
|
||||
}
|
||||
assert!(w.player.ground.on_ground, "the player settles on the floor");
|
||||
assert!(w.player.sprinting, "the sprint key starts the sprint");
|
||||
let running = w.player.velocity.z;
|
||||
// The sprint key comes up and the player jumps.
|
||||
w.controlled_simulation_tick(crate::sprint::Keys {
|
||||
jump: true,
|
||||
..forward
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
w.player.sprinting,
|
||||
"the sprint state survives the sprint key in the air"
|
||||
);
|
||||
assert!(
|
||||
w.player.velocity.z > running + 0.05,
|
||||
"the sprint jump keeps its forward impulse: {:?} vs {running}",
|
||||
w.player.velocity
|
||||
);
|
||||
}
|
||||
|
||||
/// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability
|
||||
/// speed, doubled while the sprint state holds. The sprint key starts the
|
||||
/// state; releasing it does not change the speed.
|
||||
#[test]
|
||||
fn the_flight_speed_follows_the_sprint_state_not_the_sprint_key() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.player.may_fly = true;
|
||||
w.player.flying = true;
|
||||
let forward = crate::sprint::Keys {
|
||||
forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
let sprinting = crate::sprint::Keys {
|
||||
sprint: true,
|
||||
..forward
|
||||
};
|
||||
assert_eq!(
|
||||
w.flying_speed().unwrap(),
|
||||
0.05,
|
||||
"a flying player without a sprint moves at the ability speed"
|
||||
);
|
||||
// The sprint key starts the sprint, and the state doubles the speed.
|
||||
w.controlled_simulation_tick(sprinting).unwrap();
|
||||
assert!(w.player.sprinting, "the sprint key starts the sprint");
|
||||
assert_eq!(
|
||||
w.flying_speed().unwrap(),
|
||||
0.1,
|
||||
"a sprinting player flies at twice the ability speed"
|
||||
);
|
||||
// Releasing the sprint key keeps the state, so the speed stays.
|
||||
w.controlled_simulation_tick(forward).unwrap();
|
||||
assert!(
|
||||
w.player.sprinting,
|
||||
"the sprint state survives the sprint key in the air"
|
||||
);
|
||||
assert_eq!(
|
||||
w.flying_speed().unwrap(),
|
||||
0.1,
|
||||
"the flight speed follows the sprint state, not the sprint key"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "horizon-mod-lua"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Write Horizon mods in Lua: a Lua interpreter (piccolo) running inside the mod's WebAssembly sandbox"
|
||||
|
||||
# One feature per capability, as the mod's horizon-mod.toml lists them: the
|
||||
# game refuses a mod whose component imports an interface it didn't declare,
|
||||
# so only the `horizon.<interface>` tables a feature names are compiled in.
|
||||
[features]
|
||||
game-read = []
|
||||
chat = []
|
||||
hud = []
|
||||
storage = []
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../horizon-mod-sdk" }
|
||||
# Pure Rust, so it builds for wasm32-unknown-unknown (C Lua needs setjmp). MIT.
|
||||
piccolo = "0.3.3"
|
||||
|
||||
# piccolo's hash tables (ahash) and math.random (rand) ask getrandom for
|
||||
# seeds; a mod has no entropy source, so they get a fixed sequence (below).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom02 = { package = "getrandom", version = "0.2", features = ["custom"] }
|
||||
getrandom03 = { package = "getrandom", version = "0.3" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Horizon mods in Lua (`docs/mods.md`, "Lua"): a Lua 5.4 interpreter
|
||||
//! ([piccolo](https://github.com/kyren/piccolo), pure Rust) compiled into the
|
||||
//! mod's WebAssembly component with the mod's script, so a Lua mod runs in
|
||||
//! the same sandbox, with the same capabilities and time budgets, as a Rust
|
||||
//! one. The mod's crate is one line:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! horizon_mod_lua::lua_mod!("main.lua");
|
||||
//! ```
|
||||
//!
|
||||
//! The script registers its commands with `horizon.command(name)` and
|
||||
//! defines the hooks it wants as globals: `on_tick(tick)`,
|
||||
//! `on_frame(seconds)`, `on_command(name, args)` (a string returned is the
|
||||
//! feedback, `error(text)` the red error) and `on_chat(text)`. The
|
||||
//! interfaces are tables of `horizon`: `log` always, and `game`, `chat`,
|
||||
//! `hud`, `storage` with this crate's feature of the same capability
|
||||
//! (`game-read` for `game`), as the mod's `horizon-mod.toml` lists them.
|
||||
#[cfg(feature = "chat")]
|
||||
use horizon_mod_sdk::chat;
|
||||
#[cfg(feature = "game-read")]
|
||||
use horizon_mod_sdk::game;
|
||||
#[cfg(feature = "hud")]
|
||||
use horizon_mod_sdk::hud;
|
||||
#[cfg(feature = "storage")]
|
||||
use horizon_mod_sdk::storage;
|
||||
use horizon_mod_sdk::{Registration, log};
|
||||
use piccolo::{
|
||||
Callback, CallbackReturn, Closure, Context, Error, Executor, Lua, StashedExecutor, StaticError,
|
||||
Table, Value, Variadic,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
|
||||
mod stdlib;
|
||||
|
||||
/// Seeds for piccolo's hash tables and `math.random`: a mod's sandbox has
|
||||
/// no entropy source, so they come from a fixed sequence (SplitMix64).
|
||||
/// `getrandom` 0.3 takes it with `--cfg getrandom_backend="custom"`, which
|
||||
/// `cargo horizon-mod build` passes.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod entropy {
|
||||
use std::cell::Cell;
|
||||
|
||||
std::thread_local! {
|
||||
static STATE: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
|
||||
}
|
||||
|
||||
fn fill(dest: &mut [u8]) {
|
||||
for chunk in dest.chunks_mut(8) {
|
||||
let z = STATE.with(|s| {
|
||||
let z = s.get().wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
s.set(z);
|
||||
z
|
||||
});
|
||||
let mut z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^= z >> 31;
|
||||
chunk.copy_from_slice(&z.to_le_bytes()[..chunk.len()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn fill02(dest: &mut [u8]) -> Result<(), getrandom02::Error> {
|
||||
fill(dest);
|
||||
Ok(())
|
||||
}
|
||||
getrandom02::register_custom_getrandom!(fill02);
|
||||
|
||||
/// getrandom 0.3's custom backend.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dest` points to `len` writable bytes (getrandom's contract).
|
||||
#[unsafe(no_mangle)]
|
||||
unsafe extern "Rust" fn __getrandom_v03_custom(
|
||||
dest: *mut u8,
|
||||
len: usize,
|
||||
) -> Result<(), getrandom03::Error> {
|
||||
// SAFETY: getrandom passes a buffer of `len` bytes it owns.
|
||||
fill(unsafe { std::slice::from_raw_parts_mut(dest, len) });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub use horizon_mod_sdk as __sdk;
|
||||
|
||||
std::thread_local! {
|
||||
/// What `horizon.command` registered while the script's chunk ran.
|
||||
static COMMANDS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A hook's argument.
|
||||
pub enum Arg {
|
||||
Integer(i64),
|
||||
Number(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// A running Lua mod: the interpreter with the script loaded.
|
||||
pub struct LuaMod {
|
||||
lua: Lua,
|
||||
/// One executor, restarted for each call (a new one per call costs a
|
||||
/// thread's allocation).
|
||||
executor: StashedExecutor,
|
||||
}
|
||||
|
||||
/// A Lua error as the player reads it: the message alone for `error(text)`.
|
||||
fn message(e: StaticError) -> String {
|
||||
match e {
|
||||
StaticError::Lua(e) => e.to_string(),
|
||||
StaticError::Runtime(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl LuaMod {
|
||||
/// Runs the script's chunk; the hooks it defined and the commands it
|
||||
/// registered make the [`Registration`].
|
||||
pub fn start(name: &str, source: &str) -> Result<(Self, Registration), String> {
|
||||
let mut lua = Lua::core();
|
||||
lua.try_enter(|ctx| {
|
||||
stdlib::install(ctx)?;
|
||||
install(ctx)
|
||||
})
|
||||
.map_err(message)?;
|
||||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||||
let chunk = lua
|
||||
.try_enter(|ctx| {
|
||||
let closure = Closure::load(ctx, Some(name), source.as_bytes())?;
|
||||
Ok(ctx.stash(Executor::start(ctx, closure.into(), ())))
|
||||
})
|
||||
.map_err(message)?;
|
||||
lua.execute::<()>(&chunk).map_err(message)?;
|
||||
let mut registration = Registration::new();
|
||||
lua.enter(|ctx| {
|
||||
let defined = |hook: &'static str| matches!(ctx.get_global(hook), Value::Function(_));
|
||||
registration.tick = defined("on_tick");
|
||||
registration.frame = defined("on_frame");
|
||||
registration.chat = defined("on_chat");
|
||||
});
|
||||
registration.commands = COMMANDS.with(|c| std::mem::take(&mut *c.borrow_mut()));
|
||||
let executor = lua.enter(|ctx| ctx.stash(Executor::new(ctx)));
|
||||
Ok((LuaMod { lua, executor }, registration))
|
||||
}
|
||||
|
||||
/// Calls the global function `hook` with `args`: what it returned, as
|
||||
/// text (`None` for nothing or nil), or the error it raised. A hook the
|
||||
/// script didn't define returns `None`.
|
||||
pub fn call(&mut self, hook: &'static str, args: Vec<Arg>) -> Result<Option<String>, String> {
|
||||
let executor = &self.executor;
|
||||
let found = self
|
||||
.lua
|
||||
.try_enter(|ctx| {
|
||||
let Value::Function(f) = ctx.get_global(hook) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let args: Vec<Value> = args
|
||||
.into_iter()
|
||||
.map(|a| match a {
|
||||
Arg::Integer(i) => Value::Integer(i),
|
||||
Arg::Number(n) => Value::Number(n),
|
||||
Arg::Text(s) => ctx.intern(s.as_bytes()).into(),
|
||||
})
|
||||
.collect();
|
||||
ctx.fetch(executor).restart(ctx, f, Variadic(args));
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(message)?;
|
||||
if !found {
|
||||
return Ok(None);
|
||||
}
|
||||
self.lua.finish(executor);
|
||||
self.lua
|
||||
.try_enter(|ctx| {
|
||||
let value = ctx.fetch(executor).take_result::<Value>(ctx)??;
|
||||
Ok(match value {
|
||||
Value::Nil => None,
|
||||
Value::String(s) => Some(s.to_str_lossy().into_owned()),
|
||||
other => Some(other.to_string()),
|
||||
})
|
||||
})
|
||||
.map_err(message)
|
||||
}
|
||||
|
||||
/// A hook that returns nothing: an error goes to the mod's log.
|
||||
pub fn run(&mut self, hook: &'static str, args: Vec<Arg>) {
|
||||
if let Err(e) = self.call(hook, args) {
|
||||
log::write(log::Level::Error, &format!("{hook}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fn(args) -> results` as a Lua function.
|
||||
fn function<'gc>(
|
||||
ctx: Context<'gc>,
|
||||
f: impl Fn(Context<'gc>, Vec<Value<'gc>>) -> Result<Vec<Value<'gc>>, Error<'gc>> + 'static,
|
||||
) -> Callback<'gc> {
|
||||
Callback::from_fn(&ctx, move |ctx, _, mut stack| {
|
||||
let args: Vec<Value> = stack.drain(..).collect();
|
||||
let out = f(ctx, args)?;
|
||||
stack.extend(out);
|
||||
Ok(CallbackReturn::Return)
|
||||
})
|
||||
}
|
||||
|
||||
fn text<'gc>(ctx: Context<'gc>, s: &str) -> Value<'gc> {
|
||||
ctx.intern(s.as_bytes()).into()
|
||||
}
|
||||
|
||||
/// The argument at `i` as text (numbers convert, as Lua's `tostring`).
|
||||
fn arg_text(args: &[Value], i: usize, what: &str) -> Result<String, String> {
|
||||
match args.get(i).copied().unwrap_or(Value::Nil) {
|
||||
Value::String(s) => Ok(s.to_str_lossy().into_owned()),
|
||||
Value::Integer(n) => Ok(n.to_string()),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(format!(
|
||||
"{what} must be a string, not {}",
|
||||
other.type_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_error<'gc>(ctx: Context<'gc>, message: String) -> Error<'gc> {
|
||||
text(ctx, &message).into()
|
||||
}
|
||||
|
||||
#[cfg(feature = "game-read")]
|
||||
fn table<'gc>(
|
||||
ctx: Context<'gc>,
|
||||
fields: &[(&'static str, Value<'gc>)],
|
||||
) -> Result<Table<'gc>, Error<'gc>> {
|
||||
let t = Table::new(&ctx);
|
||||
for &(k, v) in fields {
|
||||
t.set(ctx, k, v)?;
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The `horizon` table and `print` (to the mod's log).
|
||||
fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
|
||||
let horizon = Table::new(&ctx);
|
||||
horizon.set(
|
||||
ctx,
|
||||
"command",
|
||||
function(ctx, |ctx, args| {
|
||||
let name = arg_text(&args, 0, "the command's name").map_err(|e| lua_error(ctx, e))?;
|
||||
COMMANDS.with(|c| c.borrow_mut().push(name));
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
let log_table = Table::new(&ctx);
|
||||
for (name, level) in [
|
||||
("debug", log::Level::Debug),
|
||||
("info", log::Level::Info),
|
||||
("warn", log::Level::Warn),
|
||||
("error", log::Level::Error),
|
||||
] {
|
||||
log_table.set(
|
||||
ctx,
|
||||
name,
|
||||
function(ctx, move |ctx, args| {
|
||||
let message = arg_text(&args, 0, "the message").map_err(|e| lua_error(ctx, e))?;
|
||||
log::write(level, &message);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
horizon.set(ctx, "log", log_table)?;
|
||||
ctx.set_global(
|
||||
"print",
|
||||
function(ctx, |_, args| {
|
||||
let line: Vec<String> = args
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => s.to_str_lossy().into_owned(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
log::write(log::Level::Info, &line.join("\t"));
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
|
||||
#[cfg(feature = "game-read")]
|
||||
{
|
||||
let game_table = Table::new(&ctx);
|
||||
game_table.set(
|
||||
ctx,
|
||||
"player",
|
||||
function(ctx, |ctx, _| {
|
||||
let Some(p) = game::get_player() else {
|
||||
return Ok(vec![Value::Nil]);
|
||||
};
|
||||
let t = table(
|
||||
ctx,
|
||||
&[
|
||||
("x", Value::Number(p.x)),
|
||||
("y", Value::Number(p.y)),
|
||||
("z", Value::Number(p.z)),
|
||||
("yaw", Value::Number(p.yaw.into())),
|
||||
("pitch", Value::Number(p.pitch.into())),
|
||||
("health", Value::Number(p.health.into())),
|
||||
("food", Value::Integer(p.food.into())),
|
||||
("dimension", text(ctx, &p.dimension)),
|
||||
],
|
||||
)?;
|
||||
Ok(vec![t.into()])
|
||||
}),
|
||||
)?;
|
||||
game_table.set(
|
||||
ctx,
|
||||
"clock",
|
||||
function(ctx, |ctx, _| {
|
||||
let Some(c) = game::get_clock() else {
|
||||
return Ok(vec![Value::Nil]);
|
||||
};
|
||||
let t = table(
|
||||
ctx,
|
||||
&[
|
||||
("game_time", Value::Integer(c.game_time as i64)),
|
||||
("day_time", Value::Integer(c.day_time as i64)),
|
||||
],
|
||||
)?;
|
||||
Ok(vec![t.into()])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "game", game_table)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "chat")]
|
||||
{
|
||||
let chat_table = Table::new(&ctx);
|
||||
chat_table.set(
|
||||
ctx,
|
||||
"show",
|
||||
function(ctx, |ctx, args| {
|
||||
let line = arg_text(&args, 0, "the line").map_err(|e| lua_error(ctx, e))?;
|
||||
chat::show(&line);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "chat", chat_table)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "hud")]
|
||||
{
|
||||
let hud_table = Table::new(&ctx);
|
||||
hud_table.set(
|
||||
ctx,
|
||||
"set_panel",
|
||||
function(ctx, |ctx, args| {
|
||||
let corner = match arg_text(&args, 0, "the corner").map_err(|e| lua_error(ctx, e))?.as_str() {
|
||||
"top-left" => hud::Corner::TopLeft,
|
||||
"top-right" => hud::Corner::TopRight,
|
||||
"bottom-left" => hud::Corner::BottomLeft,
|
||||
"bottom-right" => hud::Corner::BottomRight,
|
||||
other => {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
format!("no corner {other:?}: top-left, top-right, bottom-left or bottom-right"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(Value::Table(rows)) = args.get(1).copied() else {
|
||||
return Err(lua_error(ctx, "the lines must be a table".into()));
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
for i in 1..=rows.length() {
|
||||
let line = match rows.get(ctx, i) {
|
||||
Value::Table(row) => hud::Line {
|
||||
text: arg_text(&[row.get(ctx, "text")], 0, "a line's text")
|
||||
.map_err(|e| lua_error(ctx, e))?,
|
||||
color: row.get(ctx, "color").to_integer().unwrap_or(0xFFFFFF) as u32,
|
||||
},
|
||||
v => hud::Line {
|
||||
text: arg_text(&[v], 0, "a line").map_err(|e| lua_error(ctx, e))?,
|
||||
color: 0xFFFFFF,
|
||||
},
|
||||
};
|
||||
lines.push(line);
|
||||
}
|
||||
hud::set_panel(corner, &lines);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
hud_table.set(
|
||||
ctx,
|
||||
"clear",
|
||||
function(ctx, |_, _| {
|
||||
hud::clear();
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "hud", hud_table)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "storage")]
|
||||
{
|
||||
let storage_table = Table::new(&ctx);
|
||||
storage_table.set(
|
||||
ctx,
|
||||
"get",
|
||||
function(ctx, |ctx, args| {
|
||||
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
|
||||
Ok(vec![match storage::get(&key) {
|
||||
Some(bytes) => ctx.intern(&bytes).into(),
|
||||
None => Value::Nil,
|
||||
}])
|
||||
}),
|
||||
)?;
|
||||
storage_table.set(
|
||||
ctx,
|
||||
"set",
|
||||
function(ctx, |ctx, args| {
|
||||
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
|
||||
let value = match args.get(1).copied().unwrap_or(Value::Nil) {
|
||||
Value::String(s) => s.as_bytes().to_vec(),
|
||||
v => arg_text(&[v], 0, "the value")
|
||||
.map_err(|e| lua_error(ctx, e))?
|
||||
.into_bytes(),
|
||||
};
|
||||
// Lua's way: true, or nil and why.
|
||||
Ok(match storage::set(&key, &value) {
|
||||
Ok(()) => vec![Value::Boolean(true)],
|
||||
Err(e) => vec![Value::Nil, text(ctx, &e)],
|
||||
})
|
||||
}),
|
||||
)?;
|
||||
storage_table.set(
|
||||
ctx,
|
||||
"delete",
|
||||
function(ctx, |ctx, args| {
|
||||
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
|
||||
storage::delete(&key);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "storage", storage_table)?;
|
||||
}
|
||||
ctx.set_global("horizon", horizon)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Lua mod: the crate's `src/lib.rs` is `horizon_mod_lua::lua_mod!("main.lua");`,
|
||||
/// the script's path from the crate's folder.
|
||||
#[macro_export]
|
||||
macro_rules! lua_mod {
|
||||
($script:literal) => {
|
||||
const _: () = {
|
||||
// The SDK's export macro names the SDK's crate.
|
||||
use $crate::__sdk as horizon_mod_sdk;
|
||||
struct LuaMod($crate::LuaMod);
|
||||
impl horizon_mod_sdk::Mod for LuaMod {
|
||||
fn init() -> Result<(Self, horizon_mod_sdk::Registration), String> {
|
||||
let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $script));
|
||||
let (m, registration) = $crate::LuaMod::start($script, source)?;
|
||||
Ok((LuaMod(m), registration))
|
||||
}
|
||||
fn tick(&mut self, tick: u64) {
|
||||
self.0
|
||||
.run("on_tick", vec![$crate::Arg::Integer(tick as i64)]);
|
||||
}
|
||||
fn frame(&mut self, seconds: f32) {
|
||||
self.0
|
||||
.run("on_frame", vec![$crate::Arg::Number(seconds.into())]);
|
||||
}
|
||||
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.0.call(
|
||||
"on_command",
|
||||
vec![
|
||||
$crate::Arg::Text(name.into()),
|
||||
$crate::Arg::Text(args.into()),
|
||||
],
|
||||
)
|
||||
}
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.0.run("on_chat", vec![$crate::Arg::Text(text.into())]);
|
||||
}
|
||||
}
|
||||
horizon_mod_sdk::export_mod!(LuaMod);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Loads `source` and calls its global `f`.
|
||||
fn run(source: &str) -> Result<Option<String>, String> {
|
||||
let (mut m, _) = LuaMod::start("test.lua", source)?;
|
||||
m.call("on_command", vec![])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_makes_the_registration() {
|
||||
let (_, r) = LuaMod::start(
|
||||
"test.lua",
|
||||
r#"horizon.command("where") horizon.command("coords")
|
||||
function on_tick(t) end
|
||||
function on_command(name, args) end"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.commands, ["where", "coords"]);
|
||||
assert!(r.tick && !r.frame && !r.chat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_answers_or_errs() {
|
||||
assert_eq!(
|
||||
run("function on_command() return 'done' end"),
|
||||
Ok(Some("done".into()))
|
||||
);
|
||||
assert_eq!(run("function on_command() end"), Ok(None));
|
||||
assert_eq!(
|
||||
run("function on_command() error('Not in a world') end"),
|
||||
Err("Not in a world".into())
|
||||
);
|
||||
assert!(run("function on_command() return nil + 1 end").is_err());
|
||||
assert!(LuaMod::start("test.lua", "this is not lua").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_format_as_c_prints() {
|
||||
let f = |args: &str| {
|
||||
run(&format!(
|
||||
"function on_command() return string.format({args}) end"
|
||||
))
|
||||
};
|
||||
assert_eq!(
|
||||
f("'XYZ %.1f / %.1f', 12.5, -3.25"),
|
||||
Ok(Some("XYZ 12.5 / -3.2".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
f("'Day %d, %02d:%02d', 2, 6, 5"),
|
||||
Ok(Some("Day 2, 06:05".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
f("'[%5s|%-4d|%x|%X|%+d]', 'ab', 7, 255, 255, 3"),
|
||||
Ok(Some("[ ab|7 |ff|FF|+3]".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
f("'%g %g %e', 0.0001, 100000, 1234.5"),
|
||||
Ok(Some("0.0001 100000 1.234500e+03".into()))
|
||||
);
|
||||
assert_eq!(f("'%.0f%%', 99.6"), Ok(Some("100%".into())));
|
||||
assert_eq!(f("'%s %s', 1.0, nil"), Ok(Some("1.0 nil".into())));
|
||||
assert!(
|
||||
f("'%d', 1.5")
|
||||
.unwrap_err()
|
||||
.contains("no integer representation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_insert_remove_concat() {
|
||||
let script = r#"function on_command()
|
||||
local t = {}
|
||||
table.insert(t, "b")
|
||||
table.insert(t, "c")
|
||||
table.insert(t, 1, "a")
|
||||
local last = table.remove(t)
|
||||
return table.concat(t, ",") .. "|" .. last .. "|" .. string.rep("ab", 3, "-")
|
||||
end"#;
|
||||
assert_eq!(run(script), Ok(Some("a,b|c|ab-ab-ab".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rep_and_concat_keep_bytes() {
|
||||
let script = r#"function on_command()
|
||||
local r = string.rep("\255", 2, "\0")
|
||||
local c = table.concat({"\254", 7, "\128"}, "\255")
|
||||
return tostring(r == "\255\0\255" and #r == 3) .. " " .. tostring(c == "\254\255" .. "7\255\128" and #c == 5)
|
||||
end"#;
|
||||
assert_eq!(run(script), Ok(Some("true true".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! What piccolo's standard library leaves out that mods reach for:
|
||||
//! `string.format`, `string.rep`, `table.insert`, `table.remove` and
|
||||
//! `table.concat`, as Lua 5.4 defines them (the subset of `format` below).
|
||||
use crate::{function, lua_error, text};
|
||||
use piccolo::{Context, Error, Table, Value};
|
||||
|
||||
/// Adds them to the `string` and `table` tables.
|
||||
pub fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
|
||||
let string = match ctx.get_global("string") {
|
||||
Value::Table(t) => t,
|
||||
_ => {
|
||||
let t = Table::new(&ctx);
|
||||
ctx.set_global("string", t)?;
|
||||
t
|
||||
}
|
||||
};
|
||||
string.set(
|
||||
ctx,
|
||||
"format",
|
||||
function(ctx, |ctx, args| {
|
||||
let out = format(&args)
|
||||
.map_err(|e| lua_error(ctx, format!("bad argument to 'format' ({e})")))?;
|
||||
Ok(vec![text(ctx, &out)])
|
||||
}),
|
||||
)?;
|
||||
string.set(
|
||||
ctx,
|
||||
"rep",
|
||||
function(ctx, |ctx, args| {
|
||||
let s = bytes_arg(&args, 0).ok_or_else(|| {
|
||||
lua_error(ctx, "bad argument #1 to 'rep' (string expected)".into())
|
||||
})?;
|
||||
let n = args.get(1).and_then(|v| v.to_integer()).unwrap_or(0).max(0) as usize;
|
||||
let sep = bytes_arg(&args, 2).unwrap_or_default();
|
||||
if (s.len() + sep.len()).saturating_mul(n) > 1 << 20 {
|
||||
return Err(lua_error(ctx, "resulting string too large".into()));
|
||||
}
|
||||
Ok(vec![bytes(ctx, &vec![s; n].join(&sep[..]))])
|
||||
}),
|
||||
)?;
|
||||
let table = match ctx.get_global("table") {
|
||||
Value::Table(t) => t,
|
||||
_ => {
|
||||
let t = Table::new(&ctx);
|
||||
ctx.set_global("table", t)?;
|
||||
t
|
||||
}
|
||||
};
|
||||
table.set(
|
||||
ctx,
|
||||
"insert",
|
||||
function(ctx, |ctx, args| {
|
||||
let Some(Value::Table(t)) = args.first().copied() else {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #1 to 'insert' (table expected)".into(),
|
||||
));
|
||||
};
|
||||
let n = t.length();
|
||||
match args.len() {
|
||||
2 => {
|
||||
t.set(ctx, n + 1, args[1])?;
|
||||
}
|
||||
3 => {
|
||||
let at = args[1]
|
||||
.to_integer()
|
||||
.filter(|&i| (1..=n + 1).contains(&i))
|
||||
.ok_or_else(|| {
|
||||
lua_error(
|
||||
ctx,
|
||||
"bad argument #2 to 'insert' (position out of bounds)".into(),
|
||||
)
|
||||
})?;
|
||||
for i in (at..=n).rev() {
|
||||
t.set(ctx, i + 1, t.get(ctx, i))?;
|
||||
}
|
||||
t.set(ctx, at, args[2])?;
|
||||
}
|
||||
_ => {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"wrong number of arguments to 'insert'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
table.set(
|
||||
ctx,
|
||||
"remove",
|
||||
function(ctx, |ctx, args| {
|
||||
let Some(Value::Table(t)) = args.first().copied() else {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #1 to 'remove' (table expected)".into(),
|
||||
));
|
||||
};
|
||||
let n = t.length();
|
||||
let at = args.get(1).and_then(|v| v.to_integer()).unwrap_or(n);
|
||||
if n == 0 && args.len() < 2 {
|
||||
return Ok(vec![Value::Nil]);
|
||||
}
|
||||
if !(1..=n + 1).contains(&at) {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #2 to 'remove' (position out of bounds)".into(),
|
||||
));
|
||||
}
|
||||
let removed = t.get(ctx, at);
|
||||
for i in at..n {
|
||||
t.set(ctx, i, t.get(ctx, i + 1))?;
|
||||
}
|
||||
if at <= n {
|
||||
t.set(ctx, n, Value::Nil)?;
|
||||
}
|
||||
Ok(vec![removed])
|
||||
}),
|
||||
)?;
|
||||
table.set(
|
||||
ctx,
|
||||
"concat",
|
||||
function(ctx, |ctx, args| {
|
||||
let Some(Value::Table(t)) = args.first().copied() else {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #1 to 'concat' (table expected)".into(),
|
||||
));
|
||||
};
|
||||
let sep = bytes_arg(&args, 1).unwrap_or_default();
|
||||
let first = args.get(2).and_then(|v| v.to_integer()).unwrap_or(1);
|
||||
let last = args
|
||||
.get(3)
|
||||
.and_then(|v| v.to_integer())
|
||||
.unwrap_or_else(|| t.length());
|
||||
let mut parts = Vec::new();
|
||||
for i in first..=last {
|
||||
let v = t.get(ctx, i);
|
||||
parts.push(bytes_arg(&[v], 0).ok_or_else(|| {
|
||||
lua_error(
|
||||
ctx,
|
||||
format!("invalid value (at index {i}) in table for 'concat'"),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
Ok(vec![bytes(ctx, &parts.join(&sep[..]))])
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A string or number argument as bytes: Lua strings are byte strings, so `rep` and `concat`
|
||||
/// keep bytes that are not UTF-8 (binary values from `horizon.storage`, for one) as they are.
|
||||
fn bytes_arg(args: &[Value], i: usize) -> Option<Vec<u8>> {
|
||||
match args.get(i).copied()? {
|
||||
Value::String(s) => Some(s.as_bytes().to_vec()),
|
||||
_ => string_arg(args, i).map(String::into_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes<'gc>(ctx: Context<'gc>, b: &[u8]) -> Value<'gc> {
|
||||
Value::String(piccolo::String::from_slice(&ctx, b))
|
||||
}
|
||||
|
||||
/// A string or number argument as text, as Lua coerces them.
|
||||
fn string_arg(args: &[Value], i: usize) -> Option<String> {
|
||||
match args.get(i).copied()? {
|
||||
Value::String(s) => Some(s.to_str_lossy().into_owned()),
|
||||
Value::Integer(n) => Some(n.to_string()),
|
||||
Value::Number(n) => Some(number_text(n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `tostring` of a float: `1.0`, `0.5`, `1e+100`.
|
||||
fn number_text(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
format!("{n:.1}")
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `string.format` for `%d %i %u %c %x %X %o %f %F %e %E %g %G %s %q %%` with
|
||||
/// flags `-0+ #`, a width and a precision.
|
||||
fn format(args: &[Value]) -> Result<String, String> {
|
||||
let template = string_arg(args, 0).ok_or("#1: string expected")?;
|
||||
let mut out = String::new();
|
||||
let mut next = 1;
|
||||
let mut chars = template.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '%' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
if chars.peek() == Some(&'%') {
|
||||
chars.next();
|
||||
out.push('%');
|
||||
continue;
|
||||
}
|
||||
let mut flags = String::new();
|
||||
while let Some(&f) = chars.peek().filter(|f| "-0+ #".contains(**f)) {
|
||||
flags.push(f);
|
||||
chars.next();
|
||||
}
|
||||
let mut width = String::new();
|
||||
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
|
||||
width.push(d);
|
||||
chars.next();
|
||||
}
|
||||
let mut precision = None;
|
||||
if chars.peek() == Some(&'.') {
|
||||
chars.next();
|
||||
let mut p = String::new();
|
||||
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
|
||||
p.push(d);
|
||||
chars.next();
|
||||
}
|
||||
precision = Some(p.parse::<usize>().unwrap_or(0));
|
||||
}
|
||||
let conversion = chars.next().ok_or("invalid conversion '%' to 'format'")?;
|
||||
let arg = args.get(next).copied().unwrap_or(Value::Nil);
|
||||
let what = format!("#{}", next + 1);
|
||||
next += 1;
|
||||
let integer = || -> Result<i64, String> {
|
||||
match arg {
|
||||
Value::Integer(i) => Ok(i),
|
||||
Value::Number(n) if n.fract() == 0.0 => Ok(n as i64),
|
||||
Value::Number(_) => Err(format!("{what}: number has no integer representation")),
|
||||
_ => Err(format!("{what}: number expected, got {}", arg.type_name())),
|
||||
}
|
||||
};
|
||||
let float = || -> Result<f64, String> {
|
||||
arg.to_number()
|
||||
.ok_or_else(|| format!("{what}: number expected, got {}", arg.type_name()))
|
||||
};
|
||||
let plus = flags.contains('+');
|
||||
let body = match conversion {
|
||||
'd' | 'i' | 'u' => {
|
||||
let n = integer()?;
|
||||
if plus && n >= 0 {
|
||||
format!("+{n}")
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
'c' => char::from_u32(integer()? as u32).unwrap_or('?').to_string(),
|
||||
'x' => format!("{:x}", integer()?),
|
||||
'X' => format!("{:X}", integer()?),
|
||||
'o' => format!("{:o}", integer()?),
|
||||
'f' | 'F' => {
|
||||
let n = float()?;
|
||||
let s = format!("{n:.*}", precision.unwrap_or(6));
|
||||
if plus && n >= 0.0 { format!("+{s}") } else { s }
|
||||
}
|
||||
'e' | 'E' => {
|
||||
let s = c_exponent(float()?, precision.unwrap_or(6));
|
||||
if conversion == 'E' {
|
||||
s.to_uppercase()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
'g' | 'G' => {
|
||||
let n = float()?;
|
||||
let p = precision.unwrap_or(6).max(1);
|
||||
let exp = if n == 0.0 {
|
||||
0
|
||||
} else {
|
||||
n.abs().log10().floor() as i32
|
||||
};
|
||||
let s = if exp < -4 || exp >= p as i32 {
|
||||
c_exponent(n, p - 1)
|
||||
} else {
|
||||
let s = format!("{n:.*}", (p as i32 - 1 - exp).max(0) as usize);
|
||||
if s.contains('.') && !flags.contains('#') {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
};
|
||||
if conversion == 'G' {
|
||||
s.to_uppercase()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
's' => {
|
||||
let s = match arg {
|
||||
Value::String(s) => s.to_str_lossy().into_owned(),
|
||||
Value::Number(n) => number_text(n),
|
||||
Value::Nil => "nil".into(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
match precision {
|
||||
Some(p) => s.chars().take(p).collect(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
'q' => format!(
|
||||
"{:?}",
|
||||
string_arg(&[arg], 0).ok_or(format!("{what}: string expected"))?
|
||||
),
|
||||
other => return Err(format!("invalid conversion '%{other}' to 'format'")),
|
||||
};
|
||||
let width: usize = width.parse().unwrap_or(0);
|
||||
let pad = width.saturating_sub(body.chars().count());
|
||||
if flags.contains('-') {
|
||||
out.push_str(&body);
|
||||
out.extend(std::iter::repeat_n(' ', pad));
|
||||
} else if flags.contains('0') && !matches!(conversion, 's' | 'q' | 'c') {
|
||||
let (sign, digits) = match body.strip_prefix(['-', '+']) {
|
||||
Some(rest) => (&body[..1], rest),
|
||||
None => ("", body.as_str()),
|
||||
};
|
||||
out.push_str(sign);
|
||||
out.extend(std::iter::repeat_n('0', pad));
|
||||
out.push_str(digits);
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(' ', pad));
|
||||
out.push_str(&body);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// C's `%e`: `1.500000e+02`.
|
||||
fn c_exponent(n: f64, precision: usize) -> String {
|
||||
let s = format!("{n:.*e}", precision);
|
||||
let (mantissa, exp) = s.split_once('e').unwrap_or((&s, "0"));
|
||||
let exp: i32 = exp.parse().unwrap_or(0);
|
||||
format!(
|
||||
"{mantissa}e{}{:02}",
|
||||
if exp < 0 { '-' } else { '+' },
|
||||
exp.abs()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "coords-hud-lua"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon mod in Lua: the coords-hud example, line for line"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-lua = { path = "../../../horizon-mod-lua", features = ["game-read", "hud", "chat"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,8 @@
|
||||
id = "coords_hud_lua"
|
||||
name = "Coordinates HUD (Lua)"
|
||||
version = "0.1.0"
|
||||
description = "The Coordinates HUD example written in Lua: your coordinates, facing and the time of day in a corner of the screen. /where puts them in chat, /coords hides or shows the panel."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["game-read", "hud", "chat"]
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Example Horizon mod in Lua (docs/mods.md, "Lua"): coordinates, facing and
|
||||
-- the time of day in the top-left corner, /where to put them in chat and
|
||||
-- /coords to hide or show the panel. The same mod as examples/coords-hud
|
||||
-- in Rust, and the same test checks both.
|
||||
|
||||
local shown = true
|
||||
-- The panel last sent, so an unchanged one costs no call.
|
||||
local last = ""
|
||||
|
||||
horizon.command("where")
|
||||
horizon.command("coords")
|
||||
|
||||
-- Direction.fromYRot: yaw 0 faces south, 90 west.
|
||||
local FACING = { "South (+Z)", "West (-X)", "North (-Z)", "East (+X)" }
|
||||
local function facing(yaw)
|
||||
return FACING[(math.floor(yaw / 90 + 0.5) & 3) + 1]
|
||||
end
|
||||
|
||||
-- "Day 3, 14:05": day time 0 is 6:00 on day 1.
|
||||
local function time_of_day(clock)
|
||||
local day = clock.day_time // 24000 + 1
|
||||
local ticks = clock.day_time % 24000
|
||||
local hours = (ticks // 1000 + 6) % 24
|
||||
local minutes = ticks % 1000 * 60 // 1000
|
||||
return string.format("Day %d, %02d:%02d", day, hours, minutes)
|
||||
end
|
||||
|
||||
local function lines(p, clock)
|
||||
local out = {
|
||||
string.format("XYZ %.1f / %.1f / %.1f", p.x, p.y, p.z),
|
||||
"Facing " .. facing(p.yaw),
|
||||
}
|
||||
if clock then
|
||||
table.insert(out, time_of_day(clock))
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function on_tick(tick)
|
||||
local p = shown and horizon.game.player()
|
||||
if not p then
|
||||
if last ~= "" then
|
||||
horizon.hud.clear()
|
||||
last = ""
|
||||
end
|
||||
return
|
||||
end
|
||||
local now = lines(p, horizon.game.clock())
|
||||
local key = table.concat(now, "\n")
|
||||
if key ~= last then
|
||||
horizon.hud.set_panel("top-left", now)
|
||||
last = key
|
||||
end
|
||||
end
|
||||
|
||||
function on_command(name, args)
|
||||
if name == "coords" then
|
||||
shown = not shown
|
||||
return "Coordinates panel " .. (shown and "shown" or "hidden")
|
||||
end
|
||||
local p = horizon.game.player()
|
||||
if not p then
|
||||
error("Not in a world")
|
||||
end
|
||||
horizon.chat.show(string.format("%.0f, %.0f, %.0f in %s",
|
||||
math.floor(p.x), math.floor(p.y), math.floor(p.z), p.dimension))
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
//! The coords-hud example in Lua: the mod is `main.lua`.
|
||||
horizon_mod_lua::lua_mod!("main.lua");
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Loads real mods built with the SDK: the coords-hud and predator-missile
|
||||
//! examples and a guest
|
||||
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
//! Loads real mods built with the SDK: the coords-hud example (in Rust and
|
||||
//! in Lua), the predator-missile example and a guest that misbehaves on
|
||||
//! command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
use horizon_mods::{
|
||||
BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs,
|
||||
ServerPlayer, Shape, Snapshot, Status,
|
||||
@@ -39,10 +39,17 @@ fn mods_dir() -> &'static Path {
|
||||
let status = Command::new(env!("CARGO"))
|
||||
.current_dir(&root)
|
||||
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
|
||||
// As `cargo horizon-mod build` does, for the Lua runtime's seeds.
|
||||
.env(
|
||||
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
|
||||
"--cfg getrandom_backend=\"custom\"",
|
||||
)
|
||||
.args([
|
||||
"-p",
|
||||
"coords-hud",
|
||||
"-p",
|
||||
"coords-hud-lua",
|
||||
"-p",
|
||||
"predator-missile",
|
||||
"-p",
|
||||
"welcome",
|
||||
@@ -70,6 +77,7 @@ fn mods_dir() -> &'static Path {
|
||||
};
|
||||
let mods = [
|
||||
("coords_hud", example("coords-hud")),
|
||||
("coords_hud_lua", example("coords-hud-lua")),
|
||||
("predator_missile", example("predator-missile")),
|
||||
("welcome", example("welcome")),
|
||||
("beacons", example("beacons")),
|
||||
@@ -140,9 +148,19 @@ fn predator_missile_runs_commands() {
|
||||
assert!(blast.iter().all(|l| l.contains("summon tnt")));
|
||||
}
|
||||
|
||||
/// The same example in Rust and in Lua: the same checks pass for both.
|
||||
#[test]
|
||||
fn coords_hud_draws_and_answers() {
|
||||
let mut mods = load("coords_hud.hmod");
|
||||
for (hmod, name) in [
|
||||
("coords_hud.hmod", "Coordinates HUD"),
|
||||
("coords_hud_lua.hmod", "Coordinates HUD (Lua)"),
|
||||
] {
|
||||
coords_hud_checks(hmod, name);
|
||||
}
|
||||
}
|
||||
|
||||
fn coords_hud_checks(hmod: &str, name: &str) {
|
||||
let mut mods = load(hmod);
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
assert_eq!(mods.commands().collect::<Vec<_>>(), ["where", "coords"]);
|
||||
mods.set_snapshot(player());
|
||||
@@ -161,7 +179,7 @@ fn coords_hud_draws_and_answers() {
|
||||
|
||||
assert!(mods.command("where"));
|
||||
let chat = Output::Chat {
|
||||
name: "Coordinates HUD".into(),
|
||||
name: name.into(),
|
||||
text: "12, 64, -4 in minecraft:overworld".into(),
|
||||
};
|
||||
assert_eq!(mods.drain(), [chat]);
|
||||
@@ -174,6 +192,44 @@ fn coords_hud_draws_and_answers() {
|
||||
};
|
||||
assert_eq!(mods.drain(), [hidden]);
|
||||
assert!(!mods.command("gamemode creative"));
|
||||
// On the title screen /where is an error, `error()` in Lua.
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
assert!(mods.command("where"));
|
||||
let error = Output::Feedback {
|
||||
text: "Not in a world".into(),
|
||||
error: true,
|
||||
};
|
||||
assert_eq!(mods.drain(), [error], "{hmod}");
|
||||
}
|
||||
|
||||
/// What a tick of the HUD example costs, in Rust and in Lua: the player
|
||||
/// moves every tick, so each one formats and sends a new panel.
|
||||
#[test]
|
||||
fn coords_hud_tick_cost_in_rust_and_lua() {
|
||||
for hmod in ["coords_hud.hmod", "coords_hud_lua.hmod"] {
|
||||
let mut mods = load(hmod);
|
||||
let mut snapshot = player();
|
||||
// Warm up: the first calls compile and fill caches.
|
||||
for t in 0..100 {
|
||||
snapshot.player.as_mut().unwrap().x += 0.1;
|
||||
mods.set_snapshot(snapshot.clone());
|
||||
mods.tick(t);
|
||||
}
|
||||
let ticks = 2000;
|
||||
let started = Instant::now();
|
||||
for t in 0..ticks {
|
||||
snapshot.player.as_mut().unwrap().x += 0.1;
|
||||
mods.set_snapshot(snapshot.clone());
|
||||
mods.tick(t);
|
||||
}
|
||||
let per_tick = started.elapsed() / ticks as u32;
|
||||
assert_eq!(mods.entries()[0].status, Status::Running, "{hmod}");
|
||||
println!("{hmod}: {per_tick:?} per tick (a new panel each tick)");
|
||||
assert!(
|
||||
per_tick.as_micros() < 1000,
|
||||
"{hmod} took {per_tick:?} per tick"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -121,5 +121,7 @@ What real mods need next, in the order modpacks and servers need it:
|
||||
use-stop, key-down, key-up).
|
||||
- Experimental `render` (`experimental = ["render"]`): HUD shapes (fill, quad, sprite, item, text), `gui-size`, camera
|
||||
`shake`, drawn by the game's own renderer.
|
||||
- SDK: `horizon-mod-lua`, mods written in Lua 5.4 (the interpreter built into the mod, same sandbox and API);
|
||||
`cargo horizon-mod new --template lua`; example `coords-hud-lua`.
|
||||
- API 0.2 (content): data-driven blocks and items, tags, `horizon:add_to`, terrain adaptation.
|
||||
- API 0.1.0: `log`, `game`, `chat`, `hud`, `storage`; `init`, `tick`, `frame`, `command`, `chat`.
|
||||
@@ -109,7 +109,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
|
||||
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
|
||||
| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | experimental | manifest `api = "0.3"` |
|
||||
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
|
||||
| Script mods: Lua (`horizon-mod-lua`, an interpreter built into the mod; `cargo horizon-mod new --template lua`) | experimental | `horizon-mod-lua` 0.1.0 |
|
||||
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
|
||||
| Shader API | not started | |
|
||||
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
|
||||
|
||||
+62
-1
@@ -132,7 +132,8 @@ There is no file, socket, process or raw-input interface, on purpose.
|
||||
| **wasmtime + component model** | yes: memory, fuel, no imports but ours | yes | WIT → Rust on both sides | 85 ns per empty hook call; 50 ms to compile a 64 KB mod on load | **chosen** |
|
||||
| wasmtime, core modules with a hand ABI | yes | yes | no: pointers and lengths by hand | a few ns less per call | not worth the fragile ABI |
|
||||
| wasmi (interpreter) | yes | yes | no components | no compile time, execution ~10-50x slower than cranelift code | kept in mind for a platform without JIT |
|
||||
| Lua (mlua) or another script | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
|
||||
| Lua (mlua) or another script, in the game | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
|
||||
| Lua *inside* the WebAssembly sandbox ([`horizon-mod-lua`](#lua-mods)) | yes: the same as any mod | yes | the same API, as Lua tables | the interpreter in the mod (~240 KB); a HUD tick ~60 µs | **offered** as a way in for Lua writers |
|
||||
| native `cdylib` plugins | none | no: per OS and arch, unstable Rust ABI | yes | none | unsafe for players |
|
||||
| an embedded JVM | none per mod | yes | Java | 100+ MB, seconds of startup | see the research |
|
||||
|
||||
@@ -246,6 +247,66 @@ and each of its commands: it must start and never trap or overrun. It checks a
|
||||
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
|
||||
key> --signature <file>.minisig`.
|
||||
|
||||
### Lua mods
|
||||
|
||||
A mod can be written in Lua 5.4 instead of Rust:
|
||||
`cargo horizon-mod new my-mod --template lua` makes one. The interpreter,
|
||||
[piccolo](https://github.com/kyren/piccolo) (pure Rust, MIT), is built into
|
||||
the mod's WebAssembly component with the script by
|
||||
[`horizon-mod-lua`](../crates/horizon-mod-lua/src/lib.rs). So a Lua mod is an
|
||||
`.hmod` like any other: the same sandbox, capabilities, time budgets and
|
||||
checks. Only the toolchain is shared with Rust mods (`cargo horizon-mod build`
|
||||
compiles the interpreter once, in about 30 s).
|
||||
|
||||
```lua
|
||||
local greeted = 0
|
||||
horizon.command("hello")
|
||||
|
||||
function on_command(name, args)
|
||||
greeted = greeted + 1
|
||||
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
|
||||
end
|
||||
```
|
||||
|
||||
- **Hooks are global functions the script defines:** `on_tick(tick)`,
|
||||
`on_frame(seconds)`, `on_command(name, args)` and `on_chat(text)`. The game
|
||||
calls only those that exist. A command returns its feedback as a string, or
|
||||
raises `error(text)` for the red error.
|
||||
- **Commands** are registered with `horizon.command(name)` while the script
|
||||
loads.
|
||||
- **The API is a table per interface:**
|
||||
- `horizon.game.player()` (a table with `x y z yaw pitch health food
|
||||
dimension`, or nil) and `horizon.game.clock()` (`game_time day_time`).
|
||||
- `horizon.chat.show(text)`.
|
||||
- `horizon.hud.set_panel(corner, lines)`, where the corner is
|
||||
`"top-left"`, ... and the lines are strings or `{text =, color =}`; and
|
||||
`horizon.hud.clear()`.
|
||||
- `horizon.storage.get(key)`, `set(key, value)` (`true`, or `nil` and why)
|
||||
and `delete(key)`.
|
||||
- `horizon.log.info(text)`, ...; `print` writes to the mod's log.
|
||||
- **Capabilities are the crate's features too.** The Cargo.toml's
|
||||
`horizon-mod-lua` features list the same capabilities as
|
||||
`horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`): only those
|
||||
tables are built in, and the game refuses a mod whose component uses one
|
||||
it didn't declare. A capability in the manifest but not in the features
|
||||
leaves its table out: the script fails with `attempt to index a nil value`.
|
||||
- **The standard library** is piccolo's: base, `math`, `string` (`len sub
|
||||
lower upper reverse`), `table` (`pack unpack`) and `coroutine`. The mod
|
||||
runtime adds `string.format` (`%d %s %f %x %e %g %q`, with flags, width and
|
||||
precision), `string.rep`, `table.insert`, `table.remove` and `table.concat`.
|
||||
There is no `io` or `os`, and patterns (`string.find`, `gsub`) aren't there
|
||||
yet.
|
||||
- **`math.random`** is seeded from a fixed sequence: the sandbox has no
|
||||
entropy.
|
||||
|
||||
The example [`coords-hud-lua`](../crates/horizon-mod-sdk/examples/coords-hud-lua/main.lua)
|
||||
is [`coords-hud`](../crates/horizon-mod-sdk/examples/coords-hud/src/lib.rs)
|
||||
line for line, and the sandbox test runs the same checks on both. With the
|
||||
player moving every tick (a new panel each one), a tick costs 54–68 µs in Lua
|
||||
against 22–63 µs in Rust on a busy Mac mini M4 Pro
|
||||
(`coords_hud_tick_cost_in_rust_and_lua`), under the 0.1 ms the API asks of a
|
||||
HUD script.
|
||||
|
||||
### Testing a mod
|
||||
|
||||
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in
|
||||
|
||||
Reference in new issue
Block a user