f16bc2387f
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1926 lines
81 KiB
Diff
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/36] 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, ®);
|
|
+ d.client_suggestions(&parse, line.len(), ®)
|
|
+ .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", ®).executable().is_ok());
|
|
+ assert!(d.parse("tool load", ®).executable().is_err());
|
|
+ assert!(d.parse("tool", ®).executable().is_err());
|
|
+ assert!(d.parse("tool nothing", ®).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, ®());
|
|
+ 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, ®());
|
|
+ d.client_suggestions(&parse, line.len(), ®())
|
|
+ .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)
|
|
|