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>
This commit is contained in:
2026-10-30 15:43:11 +03:00
committed by airdef
parent 94dc8eda1a
commit 4cb83fcc9b
15 changed files with 1598 additions and 5 deletions
+1 -1
View File
@@ -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());
+127 -2
View File
@@ -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());
}
}
+9
View File
@@ -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() {
+23
View File
@@ -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]
+244
View File
@@ -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);
}
}
}
+163
View File
@@ -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:?}");
}
+1
View File
@@ -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,
};
+583
View File
@@ -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());
}
}
+73
View File
@@ -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
+5 -2
View File
@@ -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
+267
View File
@@ -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());
}
}
+31
View File
@@ -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]
@@ -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);
+52
View File
@@ -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`
+7
View File
@@ -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`.