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:
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user