Files
airdef/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch
T
chexit ccc3e20902
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:44:14 +03:00

1926 lines
81 KiB
Diff

From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:43:11 +0300
Subject: [PATCH 36/40] Mod command trees: command.set-tree and
set-suggestions, typed parse, colouring and completion from one tree (API 19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game/commands.rs | 2 +-
crates/horizon-client/src/game/mods.rs | 129 +++-
crates/horizon-command/src/args.rs | 9 +
crates/horizon-command/src/lib.rs | 23 +
crates/horizon-command/src/mod_tree.rs | 244 ++++++++
crates/horizon-command/src/tests.rs | 163 +++++
crates/horizon-mod-sdk/src/lib.rs | 1 +
crates/horizon-mod-sdk/src/tree.rs | 583 ++++++++++++++++++
crates/horizon-mod-sdk/wit/horizon.wit | 73 +++
crates/horizon-mods/src/api_level.rs | 7 +-
crates/horizon-mods/src/runtime.rs | 267 ++++++++
crates/horizon-mods/tests/sandbox.rs | 31 +
crates/horizon-mods/tests/ui_guest/src/lib.rs | 12 +
docs/api/mods-reference.md | 52 ++
docs/api/mods.md | 7 +
15 files changed, 1598 insertions(+), 5 deletions(-)
create mode 100644 crates/horizon-command/src/mod_tree.rs
create mode 100644 crates/horizon-mod-sdk/src/tree.rs
diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs
index ea628917e..5c59b7f22 100644
--- a/crates/horizon-client/src/game/commands.rs
+++ b/crates/horizon-client/src/game/commands.rs
@@ -135,7 +135,7 @@ impl Game<'_> {
self.command_source = outer;
ok
}
- fn syntax_failure(&mut self, e: &CommandError, command: &str) {
+ pub(super) fn syntax_failure(&mut self, e: &CommandError, command: &str) {
self.command_failure(e.message.clone());
let Some(input) = &e.input else { return };
let cursor = e.cursor.min(input.len());
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index b4d332f41..b524ff83f 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -19,6 +19,7 @@ use {
super::Screen,
super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent},
horizon_client::{
+ command::mod_tree::{ModKind, ModNode, ModOption, ModValue},
extras::Corner,
extras_hud,
items::ItemStack,
@@ -62,6 +63,9 @@ pub(super) struct ModsState {
/// The chat's command tree while a running mod owns commands.
#[cfg(feature = "mods")]
tree: Option<Dispatcher>,
+ /// The mods' `Mods::tree_generation` the trees were built at.
+ #[cfg(feature = "mods")]
+ tree_generation: u64,
/// A server mod runs here: what players do is queued for it.
#[cfg(feature = "mods")]
pub(super) server: bool,
@@ -188,6 +192,54 @@ impl ModsState {
#[cfg(feature = "mods")]
const RELOAD_CHECK: Duration = Duration::from_secs(1);
+/// What the running mods' trees say, as the chat's tree takes them: a `names`
+/// node as the words its list holds now.
+#[cfg(feature = "mods")]
+fn mod_trees(mods: &Mods) -> Vec<(String, Vec<ModNode>)> {
+ mods.command_trees()
+ .iter()
+ .map(|t| (t.name.to_owned(), mod_nodes(t.nodes, |l| t.list(l).to_vec())))
+ .collect()
+}
+
+/// `nodes` of `command.set-tree` as the chat's [`ModNode`]s; `list` gives the
+/// words of a `names` list.
+#[cfg(feature = "mods")]
+fn mod_nodes(nodes: &[horizon_mods::TreeNode], list: impl Fn(&str) -> Vec<String>) -> Vec<ModNode> {
+ use horizon_mods::{TreeNodeKind as K, TreeValueKind as V};
+ let value = |v: &V| match v {
+ V::Word(words) => ModValue::Word(words.clone()),
+ V::Choice(words) => ModValue::Choice(words.clone()),
+ V::Integer((min, max)) => ModValue::Integer(*min, *max),
+ V::Number((min, max)) => ModValue::Number(*min, *max),
+ V::Names(name) => ModValue::Word(list(name)),
+ V::BlockPos => ModValue::BlockPos,
+ };
+ nodes
+ .iter()
+ .map(|n| ModNode {
+ name: n.name.clone(),
+ kind: n.kind.as_ref().map(|k| match k {
+ K::Value(v) => ModKind::Value(value(v)),
+ K::Text => ModKind::Text,
+ K::Options(o) => ModKind::Options {
+ entries: o
+ .entries
+ .iter()
+ .map(|e| ModOption {
+ key: e.key.clone(),
+ value: e.value.as_ref().map(value),
+ })
+ .collect(),
+ free: o.free.clone(),
+ },
+ }),
+ children: n.children.iter().map(|&c| c as usize).collect(),
+ runs: n.runs,
+ })
+ .collect()
+}
+
/// A vanilla command name: mods may not take it.
#[cfg(feature = "mods")]
fn vanilla_command(name: &str) -> bool {
@@ -396,22 +448,33 @@ impl Game<'_> {
// here (the server parses their arguments).
let server = self.mods.mods.server_commands().map(|c| c.name.as_str());
let mut names = self.mods.mods.commands().chain(server).peekable();
+ let trees = mod_trees(&self.mods.mods);
self.mods.tree = names
.peek()
.is_some()
- .then(|| vanilla::game_dispatcher().with_mod_commands(names));
+ .then(|| vanilla::game_dispatcher().with_mod_trees(names, &trees));
+ self.mods.tree_generation = self.mods.mods.tree_generation();
self.merge_remote_tree();
}
+ /// Rebuilds the chat's tree when a mod changed its tree or a list of
+ /// suggestions (a saved scenario, a deleted one).
+ fn refresh_mod_trees(&mut self) {
+ if self.mods.tree_generation != self.mods.mods.tree_generation() {
+ self.sync_mod_commands();
+ }
+ }
+
/// The server's command tree with the mods' commands, for the chat.
pub(super) fn merge_remote_tree(&mut self) {
let names: Vec<String> = self.mods.mods.commands().map(str::to_owned).collect();
+ let trees = mod_trees(&self.mods.mods);
self.mods.remote_tree = self
.remote
.as_ref()
.and_then(|r| r.commands.as_ref())
.filter(|_| !names.is_empty())
- .map(|t| t.with_mod_commands(names.iter().map(String::as_str)));
+ .map(|t| t.with_mod_trees(names.iter().map(String::as_str), &trees));
}
/// After the options changed: restart the mods if the list of the ones
@@ -643,6 +706,7 @@ impl Game<'_> {
})
});
self.mods.mods.tick(self.ticks);
+ self.refresh_mod_trees();
self.update_mod_models();
self.show_mod_output();
}
@@ -792,12 +856,23 @@ impl Game<'_> {
if !self.mods.mods.is_running() {
return false;
}
+ // The mod's own tree decides whether the line reaches it: the error
+ // is the one the chat box showed while the line was typed.
+ let name = command.split(' ').next().unwrap_or_default();
+ if let Some(tree) = &self.mods.tree
+ && self.mods.mods.commands().any(|c| c == name)
+ && let Err(e) = tree.parse(command, &self.ui.registries).executable()
+ {
+ self.syntax_failure(&e, command);
+ return true;
+ }
// The command may follow a teleport in the same tick.
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
if !self.mods.mods.command(command) {
return false;
}
+ self.refresh_mod_trees();
self.show_mod_output();
true
}
@@ -1400,3 +1475,53 @@ const MOD_UI_COLUMN: f32 = 200.0;
/// A key row's height with its gap, as the widgets below it space out.
#[cfg(feature = "mods")]
const MOD_UI_KEY_ROW: f32 = 26.0;
+
+#[cfg(all(test, feature = "mods"))]
+mod tree_tests {
+ use super::*;
+ use horizon_mods::{TreeNode, TreeNodeKind, TreeValueKind};
+
+ /// What a mod's `command.set-tree` says becomes the chat's tree: a `names`
+ /// node follows its list as the mod changes it, and the line the tree
+ /// refuses is the one the dispatch refuses.
+ #[test]
+ fn a_registered_tree_becomes_the_chats_tree_with_its_lists() {
+ let node = |name: &str, kind, children: Vec<u32>, runs| TreeNode {
+ name: name.into(),
+ kind,
+ children,
+ runs,
+ };
+ let nodes = vec![
+ node("", None, vec![1], false),
+ node("load", None, vec![2], false),
+ node(
+ "name",
+ Some(TreeNodeKind::Value(TreeValueKind::Names("scenarios".into()))),
+ vec![],
+ true,
+ ),
+ ];
+ let build = |words: &[&str]| {
+ let list = |_: &str| words.iter().map(|w| (*w).to_owned()).collect::<Vec<_>>();
+ vanilla::game_dispatcher()
+ .with_mod_trees(["tool"], &[("tool".to_owned(), mod_nodes(&nodes, list))])
+ };
+ let reg = horizon_client::command::Registries::default();
+ let complete = |d: &Dispatcher, line: &str| -> Vec<String> {
+ let parse = d.parse(line, &reg);
+ d.client_suggestions(&parse, line.len(), &reg)
+ .1
+ .into_iter()
+ .map(|s| s.text)
+ .collect()
+ };
+ assert_eq!(complete(&build(&["a", "b"]), "tool load "), ["a", "b"]);
+ assert_eq!(complete(&build(&["b"]), "tool load "), ["b"]);
+ let d = build(&[]);
+ assert!(d.parse("tool load x", &reg).executable().is_ok());
+ assert!(d.parse("tool load", &reg).executable().is_err());
+ assert!(d.parse("tool", &reg).executable().is_err());
+ assert!(d.parse("tool nothing", &reg).executable().is_err());
+ }
+}
diff --git a/crates/horizon-command/src/args.rs b/crates/horizon-command/src/args.rs
index 9b665e25b..a321393fc 100644
--- a/crates/horizon-command/src/args.rs
+++ b/crates/horizon-command/src/args.rs
@@ -139,6 +139,11 @@ pub enum ArgType {
ResourceOrId(&'static str),
/// ResourceSelectorArgument: a `*`/`?` pattern over a registry's ids.
ResourceSelector(&'static str),
+ /// A mod command's word (`mod_tree`): a non-empty unquoted word, one of a
+ /// set when it is strict, suggested from the set otherwise.
+ ModWord(std::sync::Arc<crate::mod_tree::WordSet>),
+ /// A mod command's `key=value` options and flags, to the end of the line.
+ ModOptions(std::sync::Arc<crate::mod_tree::OptionSet>),
}
/// FilenameUtils.wildcardMatch (case-sensitive) of `*` and `?` patterns.
@@ -1575,6 +1580,8 @@ impl ArgType {
}
Arg::Str(reader.string[start..reader.cursor].to_owned())
}
+ ArgType::ModWord(set) => Arg::Str(set.parse(reader)?),
+ ArgType::ModOptions(set) => Arg::Str(set.parse(reader)?),
ArgType::Enum(words) => {
let word = reader.read_unquoted_string();
if !words.contains(&word) {
@@ -1776,6 +1783,8 @@ impl ArgType {
}
ArgType::Operation => builder.suggest_matching(OPERATIONS),
ArgType::Enum(words) => builder.suggest_matching(words.iter().copied()),
+ ArgType::ModWord(set) => builder.suggest_matching(set.words.iter().map(String::as_str)),
+ ArgType::ModOptions(set) => set.suggest(builder),
ArgType::Particle => {
let known = reg.ids("minecraft:particle_type");
if known.is_empty() {
diff --git a/crates/horizon-command/src/lib.rs b/crates/horizon-command/src/lib.rs
index 838c8de87..fbecdcd97 100644
--- a/crates/horizon-command/src/lib.rs
+++ b/crates/horizon-command/src/lib.rs
@@ -9,6 +9,7 @@ pub mod block;
pub mod codec;
pub mod component;
pub mod item;
+pub mod mod_tree;
pub mod network;
pub mod packrat;
pub mod selector;
@@ -763,6 +764,28 @@ impl Dispatcher {
}
d
}
+ /// [`Dispatcher::with_mod_commands`], where the commands named in `trees`
+ /// have the subcommands and typed arguments their mod described
+ /// ([`mod_tree`]) instead of free text.
+ pub fn with_mod_trees<'a>(
+ &self,
+ names: impl IntoIterator<Item = &'a str>,
+ trees: &[(String, Vec<mod_tree::ModNode>)],
+ ) -> Dispatcher {
+ let mut d = self.clone();
+ for name in names {
+ let command = d.literal(0, name);
+ match trees.iter().find(|(n, _)| n == name) {
+ Some((_, nodes)) if !nodes.is_empty() => mod_tree::build(&mut d, command, nodes),
+ _ => {
+ d.executes(command, MOD_COMMAND);
+ let args = d.argument(command, "args", ArgType::Greedy);
+ d.executes(args, MOD_COMMAND);
+ }
+ }
+ }
+ d
+ }
/// A root literal by name.
pub fn find(&self, name: &str) -> Option<usize> {
self.nodes[0]
diff --git a/crates/horizon-command/src/mod_tree.rs b/crates/horizon-command/src/mod_tree.rs
new file mode 100644
index 000000000..e336b9fe5
--- /dev/null
+++ b/crates/horizon-command/src/mod_tree.rs
@@ -0,0 +1,244 @@
+//! The command trees client mods describe (`command.set-tree`): literal
+//! branches, typed words and numbers with bounds, optional branches, option
+//! lists, put into a [`Dispatcher`] so the chat colours, completes and
+//! checks them as it does vanilla's commands, and so the same parse decides
+//! whether a line reaches the mod.
+use crate::{ArgType, CmdResult, CommandError, Dispatcher, MOD_COMMAND, Reader, SuggestionsBuilder};
+use horizon_client_core::text::Text;
+use std::sync::Arc;
+
+/// What one word takes.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ModValue {
+ /// Any word; the given ones are suggested (a mod's `names` list included).
+ Word(Vec<String>),
+ Integer(Option<i32>, Option<i32>),
+ Number(Option<f64>, Option<f64>),
+ /// One of these words.
+ Choice(Vec<String>),
+ BlockPos,
+}
+#[derive(Clone, Debug, PartialEq)]
+pub struct ModOption {
+ pub key: String,
+ /// None: a bare flag.
+ pub value: Option<ModValue>,
+}
+#[derive(Clone, Debug, PartialEq)]
+pub enum ModKind {
+ Value(ModValue),
+ /// The rest of the line.
+ Text,
+ Options {
+ entries: Vec<ModOption>,
+ /// Words that are no option, with the ones suggested; none refuses them.
+ free: Option<Vec<String>>,
+ },
+}
+/// A node of a flat tree: `children` index later nodes; node 0 follows the
+/// command's name.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ModNode {
+ pub name: String,
+ /// None: a literal.
+ pub kind: Option<ModKind>,
+ pub children: Vec<usize>,
+ pub runs: bool,
+}
+
+/// A word, and the words it may or must be.
+#[derive(Debug, PartialEq)]
+pub struct WordSet {
+ pub words: Vec<String>,
+ pub strict: bool,
+}
+impl WordSet {
+ pub fn parse(&self, reader: &mut Reader) -> CmdResult<String> {
+ let start = reader.cursor;
+ // A word is whatever stands up to the next space (a mod splits its line the same way).
+ let rest = reader.remaining();
+ let word = &rest[..rest.find(' ').unwrap_or(rest.len())];
+ reader.cursor += word.len();
+ let fail = |reader: &mut Reader, text: String| {
+ reader.cursor = start;
+ Err(CommandError::at(Text::literal(text), reader))
+ };
+ if word.is_empty() {
+ return fail(reader, "Expected a word".into());
+ }
+ if self.strict && !self.words.iter().any(|w| w == word) {
+ let list = self.words.join(", ");
+ return fail(reader, format!("Expected one of: {list}"));
+ }
+ Ok(word.to_owned())
+ }
+}
+
+/// `key=value` options and flags, taking the rest of the line in any order.
+#[derive(Debug, PartialEq)]
+pub struct OptionSet {
+ pub entries: Vec<ModOption>,
+ pub free: Option<Vec<String>>,
+}
+impl OptionSet {
+ fn entry(&self, key: &str) -> Option<&ModOption> {
+ self.entries.iter().find(|e| e.key == key)
+ }
+ /// The error for a value, or None when it is fine.
+ fn check(key: &str, kind: &ModValue, value: &str) -> Option<String> {
+ match kind {
+ ModValue::Integer(min, max) => match value.parse::<i32>() {
+ Err(_) => Some(format!("{key}: expected a whole number, found '{value}'")),
+ Ok(n) if min.is_some_and(|m| n < m) => {
+ Some(format!("{key}: must not be less than {}, found {n}", min.unwrap_or(0)))
+ }
+ Ok(n) if max.is_some_and(|m| n > m) => {
+ Some(format!("{key}: must not be more than {}, found {n}", max.unwrap_or(0)))
+ }
+ Ok(_) => None,
+ },
+ ModValue::Number(min, max) => match value.parse::<f64>() {
+ Ok(n) if n.is_finite() && min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m) => None,
+ _ => Some(format!("{key}: expected a number in range, found '{value}'")),
+ },
+ ModValue::Choice(words) if !words.iter().any(|w| w == value) => {
+ Some(format!("{key}: expected one of: {}", words.join(", ")))
+ }
+ ModValue::Word(_) | ModValue::Choice(_) if value.is_empty() => {
+ Some(format!("{key}: expected a value"))
+ }
+ _ => None,
+ }
+ }
+ pub fn parse(&self, reader: &mut Reader) -> CmdResult<String> {
+ let text = reader.remaining();
+ let base = reader.cursor;
+ let mut used: Vec<&str> = Vec::new();
+ let mut at = 0;
+ for token in text.split(' ') {
+ let start = at;
+ at += token.len() + 1;
+ if token.is_empty() {
+ continue;
+ }
+ let fail = |reader: &mut Reader, message: String| {
+ reader.cursor = base + start;
+ Err(CommandError::at(Text::literal(message), reader))
+ };
+ let (key, value) = match token.split_once('=') {
+ Some((k, v)) => (k, Some(v)),
+ None => (token, None),
+ };
+ match (self.entry(key), value) {
+ (Some(e), v) => {
+ if used.contains(&key) {
+ return fail(reader, format!("'{key}' twice"));
+ }
+ match (&e.value, v) {
+ (None, None) => {}
+ (None, Some(_)) => return fail(reader, format!("'{key}' takes no value")),
+ (Some(_), None) => {
+ return fail(reader, format!("'{key}' needs a value: {key}=..."));
+ }
+ (Some(kind), Some(v)) => {
+ if let Some(m) = Self::check(key, kind, v) {
+ return fail(reader, m);
+ }
+ }
+ }
+ used.push(key);
+ }
+ (None, _) if self.free.is_some() && value.is_none() => {}
+ (None, _) => return fail(reader, format!("Unknown option '{token}'")),
+ }
+ }
+ reader.cursor = reader.string.len();
+ Ok(text.to_owned())
+ }
+ /// Candidates for the word being typed at the end of the line.
+ pub fn suggest(&self, builder: &mut SuggestionsBuilder) {
+ let rest = builder.remaining();
+ let token_start = builder.start + rest.rfind(' ').map_or(0, |i| i + 1);
+ let before = &rest[..token_start - builder.start];
+ let used: Vec<&str> = before
+ .split(' ')
+ .filter(|t| !t.is_empty())
+ .map(|t| t.split('=').next().unwrap_or(t))
+ .collect();
+ let mut b = builder.offset(token_start);
+ let typed = b.remaining().to_owned();
+ if let Some((key, value)) = typed.split_once('=') {
+ // A value: the choices of a valued option, after its `key=`.
+ if let Some(ModOption { value: Some(ModValue::Choice(words) | ModValue::Word(words)), .. }) = self.entry(key) {
+ let mut v = builder.offset(token_start + key.len() + 1);
+ let _ = value;
+ v.suggest_matching(words.iter().map(String::as_str));
+ builder.add(v);
+ }
+ return;
+ }
+ for e in &self.entries {
+ if used.contains(&e.key.as_str()) {
+ continue;
+ }
+ let text = if e.value.is_some() { format!("{}=", e.key) } else { e.key.clone() };
+ if text.starts_with(&typed) {
+ b.suggest(text);
+ }
+ }
+ if let Some(free) = &self.free {
+ b.suggest_matching(free.iter().map(String::as_str));
+ }
+ builder.add(b);
+ }
+}
+
+fn value_type(kind: &ModValue) -> ArgType {
+ match kind {
+ ModValue::Word(words) => ArgType::ModWord(Arc::new(WordSet { words: words.clone(), strict: false })),
+ ModValue::Choice(words) => ArgType::ModWord(Arc::new(WordSet { words: words.clone(), strict: true })),
+ ModValue::Integer(min, max) => ArgType::Integer {
+ min: min.unwrap_or(i32::MIN),
+ max: max.unwrap_or(i32::MAX),
+ },
+ ModValue::Number(min, max) => ArgType::Double {
+ min: min.unwrap_or(f64::MIN),
+ max: max.unwrap_or(f64::MAX),
+ },
+ ModValue::BlockPos => ArgType::BlockPos,
+ }
+}
+
+/// Adds the tree `nodes` below the command's literal `command`.
+pub fn build(d: &mut Dispatcher, command: usize, nodes: &[ModNode]) {
+ fn add(d: &mut Dispatcher, parent: usize, nodes: &[ModNode], i: usize) {
+ let n = &nodes[i];
+ let id = match &n.kind {
+ None => d.literal(parent, &n.name),
+ Some(ModKind::Value(v)) => d.argument(parent, &n.name, value_type(v)),
+ Some(ModKind::Text) => d.argument(parent, &n.name, ArgType::Greedy),
+ Some(ModKind::Options { entries, free }) => d.argument(
+ parent,
+ &n.name,
+ ArgType::ModOptions(Arc::new(OptionSet { entries: entries.clone(), free: free.clone() })),
+ ),
+ };
+ if n.runs {
+ d.executes(id, MOD_COMMAND);
+ }
+ for &c in &n.children {
+ if c > i && c < nodes.len() {
+ add(d, id, nodes, c);
+ }
+ }
+ }
+ let root = &nodes[0];
+ if root.runs {
+ d.executes(command, MOD_COMMAND);
+ }
+ for &c in &root.children {
+ if c > 0 && c < nodes.len() {
+ add(d, command, nodes, c);
+ }
+ }
+}
diff --git a/crates/horizon-command/src/tests.rs b/crates/horizon-command/src/tests.rs
index d61fd8ae1..d3a7ce678 100644
--- a/crates/horizon-command/src/tests.rs
+++ b/crates/horizon-command/src/tests.rs
@@ -610,3 +610,166 @@ fn horizon_commands_are_off_unless_the_server_turns_them_on() {
);
}
}
+
+/// A tree like AirDef's: subcommands, a bounded number, a choice, names that
+/// change, optional words and `key=value` options.
+fn demo_tree(scenarios: &[&str]) -> Vec<mod_tree::ModNode> {
+ use mod_tree::{ModKind::*, ModNode, ModOption, ModValue::*};
+ let words = |w: &[&str]| w.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
+ let lit = |name: &str, children: &[usize], runs: bool| ModNode {
+ name: name.into(),
+ kind: None,
+ children: children.to_vec(),
+ runs,
+ };
+ let arg = |name: &str, kind, children: &[usize], runs: bool| ModNode {
+ name: name.into(),
+ kind: Some(kind),
+ children: children.to_vec(),
+ runs,
+ };
+ vec![
+ lit("", &[1, 2, 4, 8, 11, 12], true), // 0
+ lit("menu", &[], true), // 1
+ lit("wave", &[3], false), // 2
+ arg("kind", Value(Choice(words(&["shahed", "mix"]))), &[5], true), // 3
+ lit("scenario", &[6, 7], false), // 4
+ arg("count", Value(Integer(Some(1), Some(80))), &[9], true), // 5
+ lit("list", &[], true), // 6
+ lit("load", &[10], false), // 7
+ lit("replay", &[13, 14], true), // 8
+ arg("range", Value(Number(Some(60.0), Some(800.0))), &[], true), // 9
+ arg("name", Value(Word(words(scenarios))), &[], true), // 10
+ lit("why", &[15], true), // 11
+ lit("hold", &[16], false), // 12
+ lit("map", &[], true), // 13
+ lit("export", &[17], true), // 14
+ arg("number", Value(Integer(Some(1), None)), &[], true), // 15
+ arg("on", Value(Choice(words(&["on", "off"]))), &[], true), // 16
+ arg(
+ "options",
+ Options {
+ entries: vec![
+ ModOption { key: "fps".into(), value: Some(Integer(Some(1), Some(240))) },
+ ModOption { key: "size".into(), value: Some(Word(vec![])) },
+ ModOption { key: "noaudio".into(), value: None },
+ ],
+ free: Some(words(&["best", "all"])),
+ },
+ &[],
+ true,
+ ), // 17
+ ]
+}
+
+fn demo(scenarios: &[&str]) -> Dispatcher {
+ vanilla::game_dispatcher().with_mod_trees(
+ ["airdef", "plain"],
+ &[("airdef".to_owned(), demo_tree(scenarios))],
+ )
+}
+
+fn runs(d: &Dispatcher, line: &str) -> Result<(), usize> {
+ let parse = d.parse(line, &reg());
+ match parse.executable() {
+ Ok((_, id)) => {
+ assert_eq!(id, MOD_COMMAND);
+ Ok(())
+ }
+ Err(e) => Err(e.cursor),
+ }
+}
+
+#[test]
+fn a_mod_tree_parses_every_branch_and_refuses_the_rest() {
+ let d = demo(&[]);
+ for line in [
+ "airdef",
+ "airdef menu",
+ "airdef wave mix",
+ "airdef wave shahed 5 260.5",
+ "airdef scenario list",
+ "airdef scenario load abc",
+ "airdef replay",
+ "airdef replay map",
+ "airdef replay export",
+ "airdef replay export mine best fps=30 size=1280x720 noaudio",
+ "airdef why 3",
+ "airdef hold off",
+ ] {
+ assert_eq!(runs(&d, line), Ok(()), "{line}");
+ }
+ // The cursor is at the first thing wrong.
+ assert_eq!(runs(&d, "airdef wave scud"), Err(12), "enum");
+ assert_eq!(runs(&d, "airdef wave shahed 0"), Err(19), "below the bound");
+ assert_eq!(runs(&d, "airdef wave shahed 81"), Err(19), "above the bound");
+ assert_eq!(runs(&d, "airdef wave shahed x"), Err(19), "not a number");
+ assert_eq!(runs(&d, "airdef wave shahed 5 900"), Err(21), "number bound");
+ assert_eq!(runs(&d, "airdef wave"), Err(11), "incomplete");
+ assert_eq!(runs(&d, "airdef scenario load"), Err(20), "a name is needed");
+ assert_eq!(runs(&d, "airdef hold maybe"), Err(12), "enum");
+ assert_eq!(runs(&d, "airdef why 0"), Err(11), "bound");
+ assert_eq!(runs(&d, "airdef nothing"), Err(7), "unknown subcommand");
+ assert_eq!(runs(&d, "airdef menu now"), Err(12), "trailing words");
+ assert_eq!(
+ runs(&d, "airdef replay export best fps=0"),
+ Err(26),
+ "option bound"
+ );
+ assert_eq!(runs(&d, "airdef replay export fps=30 fps=60"), Err(28), "option twice");
+ assert_eq!(runs(&d, "airdef replay export size"), Err(21), "option needs a value");
+ assert_eq!(runs(&d, "airdef replay export noaudio=1"), Err(21), "a flag takes none");
+}
+
+#[test]
+fn a_mod_command_without_a_tree_stays_free_text() {
+ let d = demo(&[]);
+ for line in ["plain", "plain anything at all 1 2 3"] {
+ assert_eq!(runs(&d, line), Ok(()), "{line}");
+ }
+ // Vanilla's commands are as they were.
+ assert!(d.find("gamemode").is_some());
+}
+
+#[test]
+fn a_mod_tree_completes_the_next_word() {
+ let d = demo(&["alpha", "beta"]);
+ let complete = |d: &Dispatcher, line: &str| -> Vec<String> {
+ let parse = d.parse(line, &reg());
+ d.client_suggestions(&parse, line.len(), &reg())
+ .1
+ .into_iter()
+ .map(|s| s.text)
+ .collect()
+ };
+ let subs = complete(&d, "airdef ");
+ for expected in ["menu", "wave", "scenario", "replay", "why", "hold"] {
+ assert!(subs.contains(&expected.to_owned()), "{expected} in {subs:?}");
+ }
+ assert_eq!(complete(&d, "airdef sc"), ["scenario"]);
+ assert_eq!(complete(&d, "airdef scenario "), ["list", "load"]);
+ assert_eq!(complete(&d, "airdef scenario load "), ["alpha", "beta"]);
+ assert_eq!(complete(&d, "airdef scenario load b"), ["beta"]);
+ assert_eq!(complete(&d, "airdef wave "), ["mix", "shahed"]);
+ assert_eq!(complete(&d, "airdef hold "), ["off", "on"]);
+ // The list is rebuilt with the data: a deleted name is gone, a new one there.
+ let d = demo(&["gamma"]);
+ assert_eq!(complete(&d, "airdef scenario load "), ["gamma"]);
+ assert_eq!(complete(&demo(&[]), "airdef scenario load "), Vec::<String>::new());
+ // Options: the ones not used yet, then a value's choices.
+ assert_eq!(
+ complete(&d, "airdef replay export "),
+ ["all", "best", "fps=", "noaudio", "size="]
+ );
+ assert_eq!(complete(&d, "airdef replay export fps=30 n"), ["noaudio"]);
+ assert_eq!(complete(&d, "airdef replay export fps=30 "), ["all", "best", "noaudio", "size="]);
+}
+
+#[test]
+fn the_usage_of_a_mod_tree_comes_from_its_nodes() {
+ let d = demo(&[]);
+ let airdef = d.find("airdef").unwrap();
+ let usage: Vec<String> = d.smart_usage(airdef, 0).into_iter().map(|(_, u)| u).collect();
+ assert!(usage.iter().any(|u| u == "[wave] <kind> [<count>]"), "{usage:?}");
+ assert!(usage.iter().any(|u| u == "[menu]"), "{usage:?}");
+}
diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs
index 1e7120091..478efbd24 100644
--- a/crates/horizon-mod-sdk/src/lib.rs
+++ b/crates/horizon-mod-sdk/src/lib.rs
@@ -39,6 +39,7 @@ wit_bindgen::generate!({
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
+pub mod tree;
pub use horizon::mods::{
browser, chat, command, game, hud, input, log, recipes, render, storage, ui,
};
diff --git a/crates/horizon-mod-sdk/src/tree.rs b/crates/horizon-mod-sdk/src/tree.rs
new file mode 100644
index 000000000..208eee6b0
--- /dev/null
+++ b/crates/horizon-mod-sdk/src/tree.rs
@@ -0,0 +1,583 @@
+//! A command's tree: subcommands, typed words and numbers with bounds,
+//! optional branches (AirDef fork, API level 19). One description serves four
+//! uses: [`Tree::register`] gives it to the game, whose chat box colours,
+//! checks and completes the line before Enter; [`Tree::parse`] checks a line
+//! the way the game does (for the mod's own tests, and as a last guard);
+//! [`Tree::usage`] is the help line; [`Tree::complete`] lists what may come
+//! next.
+//!
+//! ```ignore
+//! use horizon_mod_sdk::tree::{Node, Tree, Value};
+//! let tree = Tree::new("hello")
+//! .runs()
+//! .then(Node::lit("wave").then(Node::arg("count", Value::int(1, 10)).runs()))
+//! .then(Node::lit("load").then(Node::arg("name", Value::names("saves")).runs()));
+//! tree.register()?;
+//! horizon_mod_sdk::command::set_suggestions("saves", &["a".into(), "b".into()]);
+//! ```
+//!
+//! The mod's `command` hook still gets the line as it was typed and reads
+//! it itself (or from [`Tree::parse`]'s [`Parsed`]).
+
+/// What one word takes.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+ /// Any word (anything up to the next space); the given ones are suggested.
+ Word(Vec<String>),
+ Integer(Option<i32>, Option<i32>),
+ Number(Option<f64>, Option<f64>),
+ /// One of these words.
+ Choice(Vec<String>),
+ /// Any word, suggested from the list [`crate::command::set_suggestions`]
+ /// last gave under this name.
+ Names(String),
+ /// Three coordinates, each a whole number, `~` or `~n`.
+ BlockPos,
+}
+impl Value {
+ pub fn word() -> Self {
+ Value::Word(Vec::new())
+ }
+ pub fn int(min: i32, max: i32) -> Self {
+ Value::Integer(Some(min), Some(max))
+ }
+ pub fn int_from(min: i32) -> Self {
+ Value::Integer(Some(min), None)
+ }
+ pub fn number(min: f64, max: f64) -> Self {
+ Value::Number(Some(min), Some(max))
+ }
+ pub fn choice<S: AsRef<str>>(words: impl IntoIterator<Item = S>) -> Self {
+ Value::Choice(words.into_iter().map(|w| w.as_ref().to_owned()).collect())
+ }
+ pub fn names(list: &str) -> Self {
+ Value::Names(list.into())
+ }
+}
+
+/// A `key=value` option, or a flag.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Opt {
+ pub key: String,
+ pub value: Option<Value>,
+}
+impl Opt {
+ pub fn new(key: &str, value: Value) -> Self {
+ Opt { key: key.into(), value: Some(value) }
+ }
+ pub fn flag(key: &str) -> Self {
+ Opt { key: key.into(), value: None }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+enum Kind {
+ Literal,
+ Value(Value),
+ Text,
+ Options { entries: Vec<Opt>, free: Option<Vec<String>> },
+}
+
+/// A node: a literal word or an argument, what may follow, whether the line
+/// may end here.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Node {
+ name: String,
+ kind: Kind,
+ children: Vec<Node>,
+ runs: bool,
+}
+impl Node {
+ fn new(name: &str, kind: Kind) -> Self {
+ Node { name: name.into(), kind, children: Vec::new(), runs: false }
+ }
+ /// A word to be typed as it is.
+ pub fn lit(word: &str) -> Self {
+ Self::new(word, Kind::Literal)
+ }
+ /// A typed argument; `name` is its hint (`<name>`).
+ pub fn arg(name: &str, value: Value) -> Self {
+ Self::new(name, Kind::Value(value))
+ }
+ /// The rest of the line.
+ pub fn text(name: &str) -> Self {
+ Self::new(name, Kind::Text)
+ }
+ /// `key=value` options and flags, in any order, to the end of the line;
+ /// `free`: other words are accepted too (a name), with the ones suggested.
+ pub fn options(name: &str, entries: Vec<Opt>, free: Option<Vec<String>>) -> Self {
+ Self::new(name, Kind::Options { entries, free })
+ }
+ /// The line may end here.
+ pub fn runs(mut self) -> Self {
+ self.runs = true;
+ self
+ }
+ /// What may follow (several: the first that fits wins).
+ pub fn then(mut self, child: Node) -> Self {
+ self.children.push(child);
+ self
+ }
+ /// Several of them.
+ pub fn with(mut self, children: impl IntoIterator<Item = Node>) -> Self {
+ self.children.extend(children);
+ self
+ }
+ fn head(&self) -> String {
+ match &self.kind {
+ Kind::Literal => self.name.clone(),
+ Kind::Value(Value::Choice(words)) if words.len() <= 6 => words.join("|"),
+ _ => format!("<{}>", self.name),
+ }
+ }
+ fn usage(&self) -> String {
+ let mut out = match &self.kind {
+ Kind::Options { entries, .. } => {
+ let list: Vec<String> = entries
+ .iter()
+ .map(|e| if e.value.is_some() { format!("{}=…", e.key) } else { e.key.clone() })
+ .collect();
+ list.join(" ")
+ }
+ _ => self.head(),
+ };
+ if self.children.is_empty() {
+ return out;
+ }
+ let sub: Vec<String> = self.children.iter().map(Node::usage).collect();
+ let sub = sub.join("|");
+ out.push(' ');
+ if self.runs {
+ out.push_str(&format!("[{sub}]"));
+ } else {
+ out.push_str(&sub);
+ }
+ out
+ }
+}
+
+/// Why a line does not parse: the byte of `args` where it goes wrong and what
+/// the game's chat box says there.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ParseError {
+ pub at: usize,
+ pub message: String,
+}
+impl std::fmt::Display for ParseError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{} (символ {})", self.message, self.at + 1)
+ }
+}
+
+/// A line that parsed: the literals it went through and each argument's text.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Parsed {
+ pub path: Vec<String>,
+ pub values: Vec<(String, String)>,
+}
+impl Parsed {
+ /// The text of the argument `name`, if the line had it.
+ pub fn get(&self, name: &str) -> Option<&str> {
+ self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
+ }
+}
+
+fn number_text(s: &str) -> bool {
+ !s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || matches!(c, '-' | '.'))
+}
+
+impl Value {
+ /// The tokens a value takes, and whether `tokens` fit it.
+ fn take(&self, tokens: &[(usize, &str)]) -> Result<usize, (usize, String)> {
+ let Some(&(at, t)) = tokens.first() else {
+ return Err((0, "Ожидалось значение".into()));
+ };
+ let bad = |m: String| Err((at, m));
+ match self {
+ Value::Word(_) | Value::Names(_) if t.is_empty() => bad("Ожидалось слово".into()),
+ Value::Word(_) | Value::Names(_) => Ok(1),
+ Value::Choice(words) if words.iter().any(|w| w == t) => Ok(1),
+ Value::Choice(words) => bad(format!("Ожидалось одно из: {}", words.join(", "))),
+ Value::Integer(min, max) => match t.parse::<i32>().ok().filter(|_| number_text(t)) {
+ None => bad(format!("Ожидалось целое число, найдено «{t}»")),
+ Some(n) if min.is_some_and(|m| n < m) => {
+ bad(format!("Число не меньше {}, найдено {n}", min.unwrap_or(0)))
+ }
+ Some(n) if max.is_some_and(|m| n > m) => {
+ bad(format!("Число не больше {}, найдено {n}", max.unwrap_or(0)))
+ }
+ Some(_) => Ok(1),
+ },
+ Value::Number(min, max) => match t.parse::<f64>().ok().filter(|n| n.is_finite() && number_text(t)) {
+ None => bad(format!("Ожидалось число, найдено «{t}»")),
+ Some(n) if min.is_some_and(|m| n < m) => {
+ bad(format!("Число не меньше {}, найдено {n}", min.unwrap_or(0.0)))
+ }
+ Some(n) if max.is_some_and(|m| n > m) => {
+ bad(format!("Число не больше {}, найдено {n}", max.unwrap_or(0.0)))
+ }
+ Some(_) => Ok(1),
+ },
+ Value::BlockPos => {
+ for i in 0..3 {
+ let Some(&(at, t)) = tokens.get(i) else {
+ return Err((tokens.last().map_or(0, |l| l.0 + l.1.len()), "Нужны три координаты".into()));
+ };
+ let rel = t.strip_prefix('~');
+ let ok = match rel {
+ Some("") => true,
+ Some(n) => number_text(n) && n.parse::<f64>().is_ok(),
+ None => number_text(t) && t.parse::<i32>().is_ok(),
+ };
+ if !ok {
+ return Err((at, format!("Координата: число, ~ или ~n, найдено «{t}»")));
+ }
+ }
+ Ok(3)
+ }
+ }
+ }
+}
+
+/// A command's tree; the root is the command's own name.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Tree {
+ root: Node,
+}
+impl Tree {
+ /// The tree of `/name`.
+ pub fn new(name: &str) -> Self {
+ Tree { root: Node::lit(name) }
+ }
+ /// The bare command runs.
+ pub fn runs(mut self) -> Self {
+ self.root.runs = true;
+ self
+ }
+ pub fn then(mut self, child: Node) -> Self {
+ self.root.children.push(child);
+ self
+ }
+ pub fn with(mut self, children: impl IntoIterator<Item = Node>) -> Self {
+ self.root.children.extend(children);
+ self
+ }
+ pub fn name(&self) -> &str {
+ &self.root.name
+ }
+
+ /// The help line: `/name sub | sub arg | …`, from the same nodes the game
+ /// checks the line with.
+ pub fn usage(&self) -> String {
+ let sub: Vec<String> = self.root.children.iter().map(Node::usage).collect();
+ format!("/{} {}", self.root.name, sub.join(" | "))
+ }
+
+ /// Checks the arguments `args` of the command (after its name) as the
+ /// game's chat box does.
+ pub fn parse(&self, args: &str) -> Result<Parsed, ParseError> {
+ let args = args.trim_end();
+ let mut tokens: Vec<(usize, &str)> = Vec::new();
+ let mut at = 0;
+ for t in args.split(' ') {
+ tokens.push((at, t));
+ at += t.len() + 1;
+ }
+ // The command's name was followed by one space, which is no token.
+ if args.is_empty() {
+ tokens.clear();
+ }
+ let mut node = &self.root;
+ let mut parsed = Parsed::default();
+ let mut i = 0;
+ loop {
+ if i >= tokens.len() {
+ return if node.runs {
+ Ok(parsed)
+ } else {
+ Err(ParseError { at: args.len(), message: format!("Не хватает аргументов: {}", node.children.iter().map(Node::head).collect::<Vec<_>>().join(" | ")) })
+ };
+ }
+ let (at, token) = tokens[i];
+ if let Some(next) = node.children.iter().find(|c| c.kind == Kind::Literal && c.name == token) {
+ parsed.path.push(token.to_owned());
+ node = next;
+ i += 1;
+ continue;
+ }
+ let mut first_error = None;
+ let mut matched = None;
+ for child in node.children.iter().filter(|c| c.kind != Kind::Literal) {
+ let rest = &tokens[i..];
+ match &child.kind {
+ Kind::Value(v) => match v.take(rest) {
+ Ok(n) => {
+ matched = Some((child, n, false));
+ break;
+ }
+ Err((bad_at, m)) => {
+ let bad_at = if bad_at == 0 { at } else { bad_at };
+ first_error.get_or_insert(ParseError { at: bad_at, message: m });
+ }
+ },
+ Kind::Text => {
+ matched = Some((child, rest.len(), true));
+ break;
+ }
+ Kind::Options { entries, free } => match check_options(entries, free.as_deref(), rest) {
+ Ok(()) => {
+ matched = Some((child, rest.len(), true));
+ break;
+ }
+ Err(e) => {
+ first_error.get_or_insert(e);
+ }
+ },
+ Kind::Literal => {}
+ }
+ }
+ let Some((child, n, rest_of_line)) = matched else {
+ return Err(first_error.unwrap_or_else(|| ParseError {
+ at,
+ message: if node.children.is_empty() {
+ format!("Лишнее: «{token}»")
+ } else {
+ let lits: Vec<&str> = node.children.iter().filter(|c| c.kind == Kind::Literal).map(|c| c.name.as_str()).collect();
+ format!("Неизвестная ветка «{token}»: {}", lits.join(", "))
+ },
+ }));
+ };
+ let text = if rest_of_line { args[at..].to_owned() } else { tokens[i..i + n].iter().map(|t| t.1).collect::<Vec<_>>().join(" ") };
+ parsed.values.push((child.name.clone(), text));
+ node = child;
+ i += n;
+ }
+ }
+
+ /// What may complete the word being typed at the end of `args` (the line
+ /// after the command's name): literals and choices that start with it,
+ /// the words of a `names` list (`lists` gives them), options not used yet.
+ pub fn complete(&self, args: &str, lists: &dyn Fn(&str) -> Vec<String>) -> Vec<String> {
+ let (done, typing) = match args.rfind(' ') {
+ Some(i) => (&args[..i], &args[i + 1..]),
+ None => ("", args),
+ };
+ // Walk the finished words with the same parse, as far as they go.
+ let mut node = &self.root;
+ let mut tokens: Vec<&str> = if args.contains(' ') { done.split(' ').collect() } else { Vec::new() };
+ let mut rest_start = 0;
+ while rest_start < tokens.len() {
+ let t = tokens[rest_start];
+ if let Some(next) = node.children.iter().find(|c| c.kind == Kind::Literal && c.name == t) {
+ node = next;
+ rest_start += 1;
+ continue;
+ }
+ let rest: Vec<(usize, &str)> = tokens[rest_start..].iter().map(|t| (0, *t)).collect();
+ let hit = node.children.iter().filter(|c| c.kind != Kind::Literal).find_map(|c| match &c.kind {
+ Kind::Value(v) => v.take(&rest).ok().map(|n| (c, n)),
+ Kind::Text | Kind::Options { .. } => Some((c, rest.len())),
+ Kind::Literal => None,
+ });
+ match hit {
+ Some((c, n)) if !matches!(c.kind, Kind::Text) => {
+ node = c;
+ if matches!(c.kind, Kind::Options { .. }) {
+ // Its words are the options so far; the one typed is the next.
+ break;
+ }
+ rest_start += n;
+ }
+ _ => return Vec::new(),
+ }
+ }
+ let used: Vec<&str> = tokens.drain(rest_start.min(tokens.len())..).map(|t| t.split('=').next().unwrap_or(t)).collect();
+ let mut out = Vec::new();
+ let mut offer = |w: String| {
+ if w.starts_with(typing) && w != typing && !out.contains(&w) {
+ out.push(w);
+ }
+ };
+ if let Kind::Options { entries, free } = &node.kind {
+ for e in entries.iter().filter(|e| !used.contains(&e.key.as_str())) {
+ offer(if e.value.is_some() { format!("{}=", e.key) } else { e.key.clone() });
+ }
+ for w in free.iter().flatten() {
+ offer(w.clone());
+ }
+ out.sort();
+ return out;
+ }
+ for c in &node.children {
+ match &c.kind {
+ Kind::Literal => offer(c.name.clone()),
+ Kind::Value(Value::Choice(words) | Value::Word(words)) => words.iter().for_each(|w| offer(w.clone())),
+ Kind::Value(Value::Names(list)) => lists(list).into_iter().for_each(&mut offer),
+ Kind::Options { entries, free } => {
+ for e in entries {
+ offer(if e.value.is_some() { format!("{}=", e.key) } else { e.key.clone() });
+ }
+ for w in free.iter().flatten() {
+ offer(w.clone());
+ }
+ }
+ _ => {}
+ }
+ }
+ out.sort();
+ out
+ }
+
+ /// The nodes as `command.set-tree` takes them.
+ pub fn nodes(&self) -> Vec<crate::command::TreeNode> {
+ fn value(v: &Value) -> crate::command::ValueKind {
+ use crate::command::ValueKind as V;
+ match v {
+ Value::Word(w) => V::Word(w.clone()),
+ Value::Integer(a, b) => V::Integer((*a, *b)),
+ Value::Number(a, b) => V::Number((*a, *b)),
+ Value::Choice(w) => V::Choice(w.clone()),
+ Value::Names(n) => V::Names(n.clone()),
+ Value::BlockPos => V::BlockPos,
+ }
+ }
+ fn flat(node: &Node, out: &mut Vec<crate::command::TreeNode>) -> u32 {
+ use crate::command::{NodeKind as K, TreeNode, TreeOption, TreeOptions};
+ let index = out.len() as u32;
+ out.push(TreeNode {
+ name: if node.name.is_empty() { "-".into() } else { node.name.clone() },
+ kind: match &node.kind {
+ Kind::Literal => None,
+ Kind::Value(v) => Some(K::Value(value(v))),
+ Kind::Text => Some(K::Text),
+ Kind::Options { entries, free } => Some(K::Options(TreeOptions {
+ entries: entries.iter().map(|e| TreeOption { key: e.key.clone(), value: e.value.as_ref().map(value) }).collect(),
+ free: free.clone(),
+ })),
+ },
+ children: Vec::new(),
+ runs: node.runs,
+ });
+ let children: Vec<u32> = node.children.iter().map(|c| flat(c, out)).collect();
+ out[index as usize].children = children;
+ index
+ }
+ let mut out = Vec::new();
+ flat(&self.root, &mut out);
+ out
+ }
+
+ /// Gives the tree to the game as the arguments of `/name` (which the mod
+ /// registers with [`crate::Registration::command`]).
+ pub fn register(&self) -> Result<(), String> {
+ crate::command::set_tree(&self.root.name, &self.nodes())
+ }
+}
+
+/// `key=value` options and flags: each token one of them, once, or a free word.
+fn check_options(entries: &[Opt], free: Option<&[String]>, tokens: &[(usize, &str)]) -> Result<(), ParseError> {
+ let mut used: Vec<&str> = Vec::new();
+ for &(at, token) in tokens.iter().filter(|t| !t.1.is_empty()) {
+ let err = |m: String| Err(ParseError { at, message: m });
+ let (key, value) = match token.split_once('=') {
+ Some((k, v)) => (k, Some(v)),
+ None => (token, None),
+ };
+ match (entries.iter().find(|e| e.key == key), value) {
+ (Some(_), _) if used.contains(&key) => return err(format!("«{key}» дважды")),
+ (Some(e), v) => {
+ match (&e.value, v) {
+ (None, None) => {}
+ (None, Some(_)) => return err(format!("«{key}» без значения")),
+ (Some(_), None) => return err(format!("«{key}» требует значение: {key}=…")),
+ (Some(kind), Some(v)) => {
+ let probe = [(at + key.len() + 1, v)];
+ if let Err((_, m)) = kind.take(&probe) {
+ return err(format!("{key}: {m}"));
+ }
+ }
+ }
+ used.push(key);
+ }
+ (None, None) if free.is_some() => {}
+ (None, _) => return err(format!("Неизвестный параметр «{token}»")),
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tree() -> Tree {
+ Tree::new("demo")
+ .runs()
+ .then(Node::lit("menu").runs())
+ .then(Node::lit("wave").then(
+ Node::arg("kind", Value::choice(["shahed", "mix"])).runs().then(
+ Node::arg("count", Value::int(1, 80)).runs().then(Node::arg("range", Value::number(60.0, 800.0)).runs()),
+ ),
+ ))
+ .then(Node::lit("load").then(Node::arg("name", Value::names("saves")).runs()))
+ .then(Node::lit("at").then(Node::arg("pos", Value::BlockPos).runs()))
+ .then(Node::lit("export").runs().then(
+ Node::options("options", vec![Opt::new("fps", Value::int(1, 240)), Opt::flag("noaudio")], Some(vec!["best".into()])).runs(),
+ ))
+ }
+
+ #[test]
+ fn the_tree_parses_branches_and_points_at_the_error() {
+ let t = tree();
+ for ok in ["", "menu", "wave mix", "wave shahed 5 260.5", "load abc", "at 1 ~ ~-2", "export", "export n best fps=60 noaudio", "menu "] {
+ assert!(t.parse(ok).is_ok(), "{ok}: {:?}", t.parse(ok));
+ }
+ assert_eq!(t.parse("wave shahed 5 260.5").unwrap().get("range"), Some("260.5"));
+ assert_eq!(t.parse("wave mix").unwrap().path, ["wave"]);
+ let at = |line: &str| t.parse(line).unwrap_err().at;
+ assert_eq!(at("wave scud"), 5);
+ assert_eq!(at("wave shahed 0"), 12);
+ assert_eq!(at("wave shahed 81"), 12);
+ assert_eq!(at("wave shahed 5 900"), 14);
+ assert_eq!(at("wave shahed x"), 12);
+ assert_eq!(at("wave"), 4, "incomplete");
+ assert_eq!(at("load"), 4);
+ assert_eq!(at("nothing"), 0);
+ assert_eq!(at("menu now"), 5);
+ assert_eq!(at("at 1 2"), 6);
+ assert_eq!(at("export fps=0"), 7);
+ assert_eq!(at("export fps"), 7);
+ assert_eq!(at("export noaudio noaudio"), 15);
+ assert_eq!(at("export noaudio=1"), 7);
+ }
+
+ #[test]
+ fn the_usage_line_and_the_nodes_come_from_the_same_tree() {
+ let t = tree();
+ assert_eq!(
+ t.usage(),
+ "/demo menu | wave shahed|mix [<count> [<range>]] | load <name> | at <pos> | export [fps=… noaudio]"
+ );
+ let nodes = t.nodes();
+ assert_eq!(nodes[0].children.len(), 5);
+ assert!(nodes.iter().enumerate().all(|(i, n)| n.children.iter().all(|&c| c as usize > i)));
+ assert!(nodes[0].runs);
+ }
+
+ #[test]
+ fn completion_follows_the_lists_as_they_change() {
+ let t = tree();
+ let saves = std::cell::RefCell::new(vec!["alpha".to_string(), "beta".to_string()]);
+ let lists = |_: &str| saves.borrow().clone();
+ assert_eq!(t.complete("", &lists), ["at", "export", "load", "menu", "wave"]);
+ assert_eq!(t.complete("lo", &lists), ["load"]);
+ assert_eq!(t.complete("load ", &lists), ["alpha", "beta"]);
+ saves.borrow_mut().push("gamma".into());
+ assert_eq!(t.complete("load ", &lists), ["alpha", "beta", "gamma"]);
+ saves.borrow_mut().retain(|n| n != "alpha");
+ assert_eq!(t.complete("load b", &lists), ["beta"]);
+ assert_eq!(t.complete("wave ", &lists), ["mix", "shahed"]);
+ assert_eq!(t.complete("export fps=3 ", &lists), ["best", "noaudio"]);
+ assert!(t.complete("wave nothing ", &lists).is_empty());
+ }
+}
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index a367a666f..2798c2bcd 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -123,6 +123,79 @@ interface command {
/// `msg`) is refused. On any other server they go as chat commands, and
/// there their feedback shows: a mod running many should say so.
run-quiet: func(lines: list<string>);
+
+ /// What one word of a command line takes (AirDef fork, level 19).
+ variant value-kind {
+ /// One word, anything up to the next space. The words given are
+ /// only what the chat box suggests; any word is accepted.
+ word(list<string>),
+ /// A whole number, within the bounds given (either may be none).
+ integer(tuple<option<s32>, option<s32>>),
+ /// A number with or without decimals, within the bounds given.
+ number(tuple<option<f64>, option<f64>>),
+ /// One of these words and no other.
+ choice(list<string>),
+ /// One word, suggested from the list `set-suggestions` last gave
+ /// under this name; any word is accepted, so a list that is out
+ /// of date refuses nothing.
+ names(string),
+ /// Three coordinates: whole numbers, each one also `~` or `~n`.
+ block-pos,
+ }
+ /// One `key=value` option, or a bare flag (`noaudio`) when `value` is none.
+ record tree-option {
+ key: string,
+ value: option<value-kind>,
+ }
+ /// The words that follow, in any order, to the end of the line.
+ record tree-options {
+ /// The options and flags that may appear, each at most once.
+ entries: list<tree-option>,
+ /// Words that are no option are free words (a name): none refuses
+ /// them, some accepts them and suggests the words given.
+ free: option<list<string>>,
+ }
+ variant node-kind {
+ /// One word, as `value-kind` says.
+ value(value-kind),
+ /// The rest of the line, spaces included: only a last node.
+ text,
+ options(tree-options),
+ }
+ /// A node of a command's tree. Nodes are listed flat; a node's children
+ /// come after it in the list (a tree, never a loop).
+ record tree-node {
+ /// A literal's word, or an argument's name (the chat box's hint
+ /// shows it as `<name>`).
+ name: string,
+ /// None: a literal that must be typed as it is.
+ kind: option<node-kind>,
+ /// The nodes that may follow, by their place in the list. A word
+ /// no literal child is, goes to the argument children, in order.
+ children: list<u32>,
+ /// The line may end here (a branch with optional words after it
+ /// runs without them).
+ runs: bool,
+ }
+ /// Describes the arguments of a command `name` the mod registered
+ /// (AirDef fork, level 19), replacing the free text it takes
+ /// otherwise: the chat box colours the line, shows the error and the
+ /// next word before Enter, and completes it with Tab; a line that does
+ /// not parse never reaches the mod's `command` hook (the player gets
+ /// the error). A line that parses reaches it as it was typed.
+ /// `nodes[0]` is the node right after the command's name (its literal
+ /// children are the subcommands; `runs` says whether the bare command
+ /// runs). At most 512 nodes, 256 words in a list, 64 characters in a
+ /// word, no loops, no two children of a node with one name. Errs, and
+ /// changes nothing, on a tree that breaks these rules; a tree for a
+ /// name the mod did not register in `init` is ignored. Calling again
+ /// replaces the tree.
+ set-tree: func(name: string, nodes: list<tree-node>) -> result<_, string>;
+ /// Sets the words a `names` node suggests under `topic` (at most 256
+ /// of 64 characters, kept as given, the rest dropped). Call it again
+ /// whenever the words change (a saved scenario, a deleted one): the
+ /// chat box follows.
+ set-suggestions: func(topic: string, values: list<string>);
}
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 55920c1da..2300f0fcc 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 18;
+pub const LEVEL: u32 = 19;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 78] = [
+pub const FUNCTIONS: [(&str, u32); 80] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -100,6 +100,9 @@ pub const FUNCTIONS: [(&str, u32); 78] = [
("render.world-log-stop", 18),
("render.world-log-range", 18),
("render.set-world-time-view", 18),
+ // AirDef fork: a command's tree of subcommands and typed arguments.
+ ("command.set-tree", 19),
+ ("command.set-suggestions", 19),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 89fa150d0..4762af39c 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -43,6 +43,9 @@ mod ui_bindings {
});
}
pub use bindings::horizon::mods::{
+ command::{
+ NodeKind as TreeNodeKind, TreeNode, TreeOption, TreeOptions, ValueKind as TreeValueKind,
+ },
game::{Clock, Hit, Player},
hud::{Corner, Line},
};
@@ -535,6 +538,12 @@ struct State {
lighting: Option<ModLighting>,
/// `render.set-time-override` (AirDef fork).
time_override: Option<u64>,
+ /// `command.set-tree` (AirDef fork): the trees of the commands' arguments, by command.
+ trees: Vec<(String, Vec<TreeNode>)>,
+ /// `command.set-suggestions`: the lists `names` nodes suggest from.
+ lists: Vec<(String, Vec<String>)>,
+ /// Counts the changes of `trees` and `lists`.
+ tree_generation: u64,
/// `render.set-smoke` (AirDef fork): the segments, the clock of this call and of the one before.
smoke: Vec<SmokeSegment>,
smoke_clock: (f64, f64),
@@ -783,6 +792,9 @@ impl Mods {
spots: Vec::new(),
lighting: None,
time_override: None,
+ trees: Vec::new(),
+ lists: Vec::new(),
+ tree_generation: 0,
smoke: Vec::new(),
smoke_clock: (0.0, 0.0),
input: InputState::default(),
@@ -1441,6 +1453,38 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().main_view)
}
+ /// The argument trees running client mods gave their registered commands
+ /// (`command.set-tree`), for the chat to colour and complete; a tree
+ /// for a name the mod did not register is left out.
+ pub fn command_trees(&self) -> Vec<CommandTree<'_>> {
+ let mut out = Vec::new();
+ for e in &self.entries {
+ let Some(run) = e.run.as_ref() else { continue };
+ let Some(registration) = run.hooks.client() else {
+ continue;
+ };
+ let state = run.store.data();
+ for (name, nodes) in &state.trees {
+ if registration.commands.contains(name) {
+ out.push(CommandTree {
+ name,
+ nodes,
+ lists: &state.lists,
+ });
+ }
+ }
+ }
+ out
+ }
+ /// Changes whenever a mod's tree or suggestion list does (and when mods
+ /// start or stop), so the chat knows to rebuild its tree.
+ pub fn tree_generation(&self) -> u64 {
+ self.entries
+ .iter()
+ .filter_map(|e| e.run.as_ref())
+ .map(|r| r.store.data().tree_generation + 1)
+ .sum()
+ }
/// The time of day the first running mod asked to show, if any.
pub fn time_override(&self) -> Option<u64> {
self.entries
@@ -2626,7 +2670,161 @@ impl render::Host for State {
}
}
+/// The most nodes a command's tree may have, words a list may hold and
+/// characters in one word (`command.set-tree`).
+const TREE_NODES: usize = 512;
+const TREE_WORDS: usize = 256;
+const TREE_WORD_CHARS: usize = 64;
+
+/// A command's tree as `command.set-tree` gave it, with the mod's lists.
+pub struct CommandTree<'a> {
+ pub name: &'a str,
+ pub nodes: &'a [TreeNode],
+ pub lists: &'a [(String, Vec<String>)],
+}
+impl CommandTree<'_> {
+ /// The words of a `names` list, empty for one the mod never set.
+ pub fn list(&self, list: &str) -> &[String] {
+ self.lists
+ .iter()
+ .find(|(n, _)| n == list)
+ .map_or(&[], |(_, v)| v.as_slice())
+ }
+}
+
+fn check_word(word: &str, what: &str) -> Result<(), String> {
+ if word.is_empty() || word.chars().count() > TREE_WORD_CHARS || word.contains(char::is_whitespace)
+ {
+ return Err(format!("{what} {word:?}: one to {TREE_WORD_CHARS} characters, no spaces"));
+ }
+ Ok(())
+}
+fn check_words(words: &[String], what: &str) -> Result<(), String> {
+ if words.len() > TREE_WORDS {
+ return Err(format!("{what}: at most {TREE_WORDS} words"));
+ }
+ words.iter().try_for_each(|w| check_word(w, what))
+}
+fn check_value(kind: &TreeValueKind) -> Result<(), String> {
+ match kind {
+ TreeValueKind::Word(words) => check_words(words, "word suggestion"),
+ TreeValueKind::Choice(words) if words.is_empty() => Err("a choice needs a word".into()),
+ TreeValueKind::Choice(words) => check_words(words, "choice"),
+ TreeValueKind::Names(list) => check_word(list, "list name"),
+ TreeValueKind::Integer((min, max)) if matches!((min, max), (Some(a), Some(b)) if a > b) => {
+ Err("integer bounds: min above max".into())
+ }
+ TreeValueKind::Number((min, max)) => {
+ let ok = |b: &Option<f64>| b.is_none_or(f64::is_finite);
+ if !ok(min) || !ok(max) {
+ return Err("number bounds must be finite".into());
+ }
+ match (min, max) {
+ (Some(a), Some(b)) if a > b => Err("number bounds: min above max".into()),
+ _ => Ok(()),
+ }
+ }
+ TreeValueKind::Integer(_) | TreeValueKind::BlockPos => Ok(()),
+ }
+}
+
+/// Whether `nodes` is a tree the chat can use: at most [`TREE_NODES`],
+/// children after their parent and each reached once, literals that are
+/// single words, text only as a last node, kinds within their limits.
+pub fn check_tree(nodes: &[TreeNode]) -> Result<(), String> {
+ if nodes.is_empty() || nodes.len() > TREE_NODES {
+ return Err(format!("a tree has 1 to {TREE_NODES} nodes"));
+ }
+ let mut parent = vec![None; nodes.len()];
+ for (i, n) in nodes.iter().enumerate() {
+ let what = format!("node {i} ({:?})", n.name);
+ if i > 0 {
+ check_word(&n.name, "node name").map_err(|e| format!("{what}: {e}"))?;
+ }
+ match &n.kind {
+ Some(TreeNodeKind::Value(v)) => check_value(v).map_err(|e| format!("{what}: {e}"))?,
+ Some(TreeNodeKind::Text) if !n.children.is_empty() => {
+ return Err(format!("{what}: text is the rest of the line: nothing follows it"));
+ }
+ Some(TreeNodeKind::Options(o)) => {
+ if !n.children.is_empty() {
+ return Err(format!("{what}: options take the rest of the line: nothing follows them"));
+ }
+ if o.entries.len() > TREE_WORDS {
+ return Err(format!("{what}: too many options"));
+ }
+ for (j, e) in o.entries.iter().enumerate() {
+ check_word(&e.key, "option key").map_err(|e| format!("{what}: {e}"))?;
+ if e.key.contains('=') || o.entries[..j].iter().any(|b| b.key == e.key) {
+ return Err(format!("{what}: option {:?} twice or with an =", e.key));
+ }
+ match &e.value {
+ Some(TreeValueKind::BlockPos) => {
+ return Err(format!("{what}: an option value is one word"));
+ }
+ Some(v) => check_value(v).map_err(|e| format!("{what}: {e}"))?,
+ None => {}
+ }
+ }
+ if let Some(free) = &o.free {
+ check_words(free, "free word").map_err(|e| format!("{what}: {e}"))?;
+ }
+ }
+ Some(TreeNodeKind::Text) | None => {}
+ }
+ for &c in &n.children {
+ let c = c as usize;
+ if c <= i || c >= nodes.len() {
+ return Err(format!("{what}: child {c} must come after it, within the list"));
+ }
+ if parent[c].replace(i).is_some() {
+ return Err(format!("{what}: child {c} has two parents"));
+ }
+ }
+ }
+ // A name once among a node's children.
+ for (i, n) in nodes.iter().enumerate() {
+ for (a, &x) in n.children.iter().enumerate() {
+ if n.children[..a]
+ .iter()
+ .any(|&y| nodes[y as usize].name == nodes[x as usize].name)
+ {
+ return Err(format!("node {i}: two children named {:?}", nodes[x as usize].name));
+ }
+ }
+ }
+ Ok(())
+}
+
impl command::Host for State {
+ fn set_tree(&mut self, name: String, nodes: Vec<TreeNode>) -> Result<(), String> {
+ check_tree(&nodes)?;
+ let known = self.trees.iter().position(|(n, _)| *n == name);
+ match known {
+ Some(i) => self.trees[i].1 = nodes,
+ None if self.trees.len() < 64 => self.trees.push((name, nodes)),
+ None => return Err("at most 64 commands have a tree".into()),
+ }
+ self.tree_generation += 1;
+ Ok(())
+ }
+ fn set_suggestions(&mut self, topic: String, values: Vec<String>) {
+ let values: Vec<String> = values
+ .into_iter()
+ .filter(|w| check_word(w, "").is_ok())
+ .take(TREE_WORDS)
+ .collect();
+ let known = self.lists.iter().position(|(n, _)| *n == topic);
+ match known {
+ Some(i) if self.lists[i].1 == values => return,
+ Some(i) => self.lists[i].1 = values,
+ None if self.lists.len() < 64 && check_word(&topic, "").is_ok() => {
+ self.lists.push((topic, values));
+ }
+ None => return,
+ }
+ self.tree_generation += 1;
+ }
fn run(&mut self, line: String) {
let queued = self
.out
@@ -2742,3 +2940,72 @@ fn storage_key_ok(key: &str) -> bool {
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-".contains(&b));
chars_ok && key != "." && key != ".."
}
+
+#[cfg(test)]
+mod tree_tests {
+ use super::*;
+
+ fn lit(name: &str, children: &[u32]) -> TreeNode {
+ TreeNode {
+ name: name.into(),
+ kind: None,
+ children: children.to_vec(),
+ runs: true,
+ }
+ }
+ fn value(name: &str, kind: TreeValueKind) -> TreeNode {
+ TreeNode {
+ name: name.into(),
+ kind: Some(TreeNodeKind::Value(kind)),
+ children: Vec::new(),
+ runs: true,
+ }
+ }
+
+ #[test]
+ fn a_tree_is_checked_before_a_mod_may_set_it() {
+ let ok = vec![
+ lit("", &[1, 2]),
+ lit("wave", &[3]),
+ lit("menu", &[]),
+ value("count", TreeValueKind::Integer((Some(1), Some(80)))),
+ ];
+ assert_eq!(check_tree(&ok), Ok(()));
+ assert!(check_tree(&[]).is_err());
+ // A loop, a child out of range, two parents, two children with a name.
+ let mut bad = ok.clone();
+ bad[3].children = vec![1];
+ assert!(check_tree(&bad).unwrap_err().contains("after it"));
+ let mut bad = ok.clone();
+ bad[1].children = vec![9];
+ assert!(check_tree(&bad).is_err());
+ let mut bad = ok.clone();
+ bad[2].children = vec![3];
+ assert!(check_tree(&bad).unwrap_err().contains("two parents"));
+ let mut bad = ok.clone();
+ bad[2].name = "wave".into();
+ assert!(check_tree(&bad).unwrap_err().contains("two children"));
+ // Words: one, short, no spaces; bounds the right way round.
+ let mut bad = ok.clone();
+ bad[2].name = "two words".into();
+ assert!(check_tree(&bad).is_err());
+ let mut bad = ok.clone();
+ bad[3] = value("count", TreeValueKind::Integer((Some(5), Some(1))));
+ assert!(check_tree(&bad).is_err());
+ let mut bad = ok.clone();
+ bad[3] = value("n", TreeValueKind::Number((Some(f64::NAN), None)));
+ assert!(check_tree(&bad).is_err());
+ let mut bad = ok.clone();
+ bad[3] = value("kind", TreeValueKind::Choice(Vec::new()));
+ assert!(check_tree(&bad).is_err());
+ // Text and options end the line.
+ let mut bad = ok.clone();
+ bad[3].kind = Some(TreeNodeKind::Text);
+ bad[3].children = vec![];
+ assert_eq!(check_tree(&bad), Ok(()));
+ bad[1].kind = Some(TreeNodeKind::Text);
+ assert!(check_tree(&bad).unwrap_err().contains("nothing follows"));
+ let too_many: Vec<TreeNode> = (0..=TREE_NODES).map(|_| lit("x", &[])).collect();
+ assert!(check_tree(&too_many).is_err());
+ }
+}
diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs
index e400dd56b..b0aa8d322 100644
--- a/crates/horizon-mods/tests/sandbox.rs
+++ b/crates/horizon-mods/tests/sandbox.rs
@@ -110,6 +110,37 @@ fn player() -> Snapshot {
}
}
+/// Capability `command` (level 19): a mod gives its command a tree and the lists it suggests from;
+/// a tree for a command it did not register is ignored; changing a list changes the generation.
+#[test]
+fn a_mod_describes_its_commands_arguments() {
+ use horizon_mods::{TreeNodeKind, TreeValueKind};
+ let mut mods = load("horizon_mods_test_ui_guest.hmod");
+ assert_eq!(mods.entries()[0].status, Status::Running);
+ let trees = mods.command_trees();
+ assert_eq!(trees.len(), 1, "the unregistered command's tree is left out");
+ let tree = &trees[0];
+ assert_eq!(tree.name, "ui_screen");
+ assert_eq!(tree.nodes.len(), 4);
+ assert!(tree.nodes[0].runs);
+ assert_eq!(tree.nodes[0].children, [1, 2]);
+ assert!(matches!(
+ &tree.nodes[3].kind,
+ Some(TreeNodeKind::Value(TreeValueKind::Names(list))) if list == "saves"
+ ));
+ assert_eq!(tree.list("saves"), ["a"]);
+ let before = mods.tree_generation();
+ // A list changed from a hook: the generation moves, the same list again leaves it.
+ assert!(mods.command("saves b,c"));
+ assert_eq!(mods.command_trees()[0].list("saves"), ["b", "c"]);
+ let after = mods.tree_generation();
+ assert_ne!(before, after);
+ assert!(mods.command("saves b,c"));
+ assert_eq!(mods.tree_generation(), after);
+ // The line is still the mod's own: it gets what was typed.
+ assert!(mods.command("ui_screen show"));
+}
+
/// Capability `command`: `/missile` queues vanilla commands, and the chat
/// line its impact check prints sets off the warhead.
#[test]
diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs
index f95aedffa..2696da95b 100644
--- a/crates/horizon-mods/tests/ui_guest/src/lib.rs
+++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs
@@ -124,10 +124,21 @@ impl Mod for Guest {
"recipe_give",
"corner",
"readings",
+ "saves",
]
.into_iter()
.fold(Registration::new(), Registration::command)
.on_tick();
+ // `/ui_screen` has a tree: `/ui_screen show`, `/ui_screen load <saves>`; `/saves A,B`
+ // changes the saves it suggests. A tree for a command the mod did not register is ignored.
+ use horizon_mod_sdk::tree::{Node, Tree, Value};
+ Tree::new("ui_screen")
+ .runs()
+ .then(Node::lit("show").runs())
+ .then(Node::lit("load").then(Node::arg("name", Value::names("saves")).runs()))
+ .register()?;
+ Tree::new("unregistered").runs().register()?;
+ command::set_suggestions("saves", &["a".to_string()]);
Ok((Guest { pending: None }, hooks))
}
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
@@ -252,6 +263,7 @@ impl Mod for Guest {
});
render::set_far_terrain(far);
}
+ "saves" => command::set_suggestions("saves", &args.split(',').map(str::to_owned).collect::<Vec<_>>()),
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
"quiet_many" => {
let n: usize = args.parse().unwrap_or(0);
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index c1dc88f54..22ad00589 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -183,10 +183,62 @@ Access: capability `command`.
Capability `command`: vanilla commands run as the player, with the player's permissions, after the hook call returns (8 per call). A mod's own commands are not run this way.
+### variant `value-kind`
+
+What one word of a command line takes (AirDef fork, level 19).
+
+| Case | Payload | |
+| --- | --- | --- |
+| `word` | `list<string>` | One word, anything up to the next space. The words given are only what the chat box suggests; any word is accepted. |
+| `integer` | `tuple<option<s32>, option<s32>>` | A whole number, within the bounds given (either may be none). |
+| `number` | `tuple<option<f64>, option<f64>>` | A number with or without decimals, within the bounds given. |
+| `choice` | `list<string>` | One of these words and no other. |
+| `names` | `string` | One word, suggested from the list `set-suggestions` last gave under this name; any word is accepted, so a list that is out of date refuses nothing. |
+| `block-pos` | | Three coordinates: whole numbers, each one also `~` or `~n`. |
+
+### record `tree-option`
+
+One `key=value` option, or a bare flag (`noaudio`) when `value` is none.
+
+| Field | Type | |
+| --- | --- | --- |
+| `key` | `string` | |
+| `value` | `option<value-kind>` | |
+
+### record `tree-options`
+
+The words that follow, in any order, to the end of the line.
+
+| Field | Type | |
+| --- | --- | --- |
+| `entries` | `list<tree-option>` | The options and flags that may appear, each at most once. |
+| `free` | `option<list<string>>` | Words that are no option are free words (a name): none refuses them, some accepts them and suggests the words given. |
+
+### variant `node-kind`
+
+| Case | Payload | |
+| --- | --- | --- |
+| `value` | `value-kind` | One word, as `value-kind` says. |
+| `text` | | The rest of the line, spaces included: only a last node. |
+| `options` | `tree-options` | |
+
+### record `tree-node`
+
+A node of a command's tree. Nodes are listed flat; a node's children come after it in the list (a tree, never a loop).
+
+| Field | Type | |
+| --- | --- | --- |
+| `name` | `string` | A literal's word, or an argument's name (the chat box's hint shows it as `<name>`). |
+| `kind` | `option<node-kind>` | None: a literal that must be typed as it is. |
+| `children` | `list<u32>` | The nodes that may follow, by their place in the list. A word no literal child is, goes to the argument children, in order. |
+| `runs` | `bool` | The line may end here (a branch with optional words after it runs without them). |
+
| Function | Signature | What it does |
| --- | --- | --- |
| `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. |
| `run-quiet` | `func(lines: list<string>)` | `lines` (each without its slash, at most 256 a call) as `run` does, but with nothing in the chat: no feedback and no failure. In single player and on Horizon's servers, each runs exactly as the player typing it would (the same rights), in order, 64 a tick, and the server logs one line a batch; one that carries a chat message (`say`, `msg`) is refused. On any other server they go as chat commands, and there their feedback shows: a mod running many should say so. |
+| `set-tree` | `func(name: string, nodes: list<tree-node>) -> result<_, string>` | Describes the arguments of a command `name` the mod registered (AirDef fork, level 19), replacing the free text it takes otherwise: the chat box colours the line, shows the error and the next word before Enter, and completes it with Tab; a line that does not parse never reaches the mod's `command` hook (the player gets the error). A line that parses reaches it as it was typed. `nodes[0]` is the node right after the command's name (its literal children are the subcommands; `runs` says whether the bare command runs). At most 512 nodes, 256 words in a list, 64 characters in a word, no loops, no two children of a node with one name. Errs, and changes nothing, on a tree that breaks these rules; a tree for a name the mod did not register in `init` is ignored. Calling again replaces the tree. |
+| `set-suggestions` | `func(topic: string, values: list<string>)` | Sets the words a `names` node suggests under `topic` (at most 256 of 64 characters, kept as given, the rest dropped). Call it again whenever the words change (a saved scenario, a deleted one): the chat box follows. |
## `ui`
diff --git a/docs/api/mods.md b/docs/api/mods.md
index 0dc74e7a3..c255056e7 100644
--- a/docs/api/mods.md
+++ b/docs/api/mods.md
@@ -106,6 +106,13 @@ What real mods need next, in the order modpacks and servers need it:
## Changelog
+- API level 19 (AirDef fork, capability `command`): `set-tree` gives a client mod's command a tree of subcommands
+ (literals), typed words (`word`, `choice`, `integer` and `number` with bounds, `names` from a list, `block-pos`),
+ optional branches (`runs`), the rest of the line (`text`) and `key=value` options; `set-suggestions` sets the list a
+ `names` node suggests from. The chat box colours the line, shows the error and completes the next word before
+ Enter, and a line that does not parse never reaches the mod; one that does gets the line as it was typed. The
+ plain `registration.commands` name stays the free-text fallback. SDK `tree::{Tree, Node, Value, Opt}`: `register`,
+ `parse`, `complete`, `usage` from the same nodes.
- API 0.3: typed server commands: `registration.commands` lists each command's arguments (`argument-kind`: word,
text, integer, number, boolean, player, block-pos), sent in players' command trees and parsed by the server;
`command` gets their values. SDK `Registration::command_with`, `Argument`, `Args`.
--
2.54.0 (Apple Git-157)