pub mod sheet;
use core::ffi::c_void;
use core::ops::RangeInclusive;
use std::cell::RefCell;
use std::sync::{Arc, PoisonError, RwLock};
use crate::abi::{ActionCall, Str, read_headed};
use crate::i18n::{Language, Texts};
use crate::world::Player;
use crate::{Host, Load, Off};
use sheet::{ArgView, Entry};
const PLATFORM_TEXTS: &str = r#"
[en]
"actions.usage" = "Actions, each after the mod's id:"
"actions.unknown" = "No action {word}; help lists them."
"actions.denied" = "You may not do that."
"actions.no_player" = "No player named {name}."
"actions.several" = "Several players are named {name}."
"actions.bad" = "Write it so: {usage}"
"actions.player" = "player"
"actions.on" = "on"
"actions.off" = "off"
[uk]
"actions.usage" = "Дії, кожна після id мода:"
"actions.unknown" = "Немає дії {word}; help перелічує їх."
"actions.denied" = "Тобі цього не можна."
"actions.no_player" = "Немає гравця на ім'я {name}."
"actions.several" = "Кілька гравців мають ім'я {name}."
"actions.bad" = "Пиши так: {usage}"
"actions.player" = "гравець"
"actions.on" = "on"
"actions.off" = "off"
"#;
pub const MAX_ID: usize = 32;
pub const MAX_PLAYERS: usize = 32;
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Value {
Player {
key: u64,
name: String,
},
Choice(usize),
Number(f64),
Toggle(bool),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Arg {
kind: u8,
label: String,
choices: Vec<(String, String)>,
range: (f64, f64),
step: f64,
}
impl Arg {
fn new(kind: u8, label: &str) -> Self {
Self {
kind,
label: label.to_owned(),
choices: Vec::new(),
range: (0.0, 0.0),
step: 0.0,
}
}
#[must_use]
pub fn player(label: &str) -> Self {
Self::new(ArgView::PLAYER, label)
}
#[must_use]
pub fn choice(label: &str, choices: &[(&str, &str)]) -> Self {
let mut arg = Self::new(ArgView::CHOICE, label);
arg.choices = choices
.iter()
.map(|(word, label)| ((*word).to_owned(), (*label).to_owned()))
.collect();
arg
}
#[must_use]
pub fn number(label: &str, range: RangeInclusive<f64>, step: f64) -> Self {
let (start, end) = range.into_inner();
let mut arg = Self::new(ArgView::NUMBER, label);
arg.range = (start.min(end), start.max(end));
arg.step = if step.is_finite() { step.abs() } else { 0.0 };
arg
}
#[must_use]
pub fn toggle(label: &str) -> Self {
Self::new(ArgView::TOGGLE, label)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Subject {
pub id: u64,
pub label: String,
}
impl Subject {
#[must_use]
pub fn new(id: u64, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Ask<'a> {
pub player: &'a Player,
pub language: Language,
texts: &'a Texts,
}
impl Ask<'_> {
#[must_use]
pub fn say(&self, key: &str, values: &[(&str, &str)]) -> String {
self.texts.fill(key, self.language, values)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Via {
Interface,
Console,
}
#[derive(Debug)]
pub struct Request<'a> {
pub player: &'a Player,
pub subject: Option<&'a Subject>,
pub args: &'a [Value],
pub language: Language,
pub via: Via,
texts: &'a Texts,
}
impl Request<'_> {
#[must_use]
pub fn say(&self, key: &str, values: &[(&str, &str)]) -> String {
self.texts.fill(key, self.language, values)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Outcome {
done: bool,
text: String,
}
impl Outcome {
#[must_use]
pub fn done(text: impl Into<String>) -> Self {
Self {
done: true,
text: text.into(),
}
}
#[must_use]
pub fn refused(text: impl Into<String>) -> Self {
Self {
done: false,
text: text.into(),
}
}
#[must_use]
pub const fn is_done(&self) -> bool {
self.done
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
}
impl From<String> for Outcome {
fn from(text: String) -> Self {
Self::done(text)
}
}
impl From<&str> for Outcome {
fn from(text: &str) -> Self {
Self::done(text)
}
}
type Allow = Box<dyn Fn(&Player) -> bool + Send + Sync>;
type At = Box<dyn Fn(&Ask<'_>) -> Result<Subject, String> + Send + Sync>;
type On = Box<dyn Fn(&Ask<'_>, Option<&Subject>) -> Vec<(u64, String)> + Send + Sync>;
type Handler = Box<dyn Fn(&Request<'_>) -> Outcome + Send + Sync>;
pub struct Action {
id: String,
label: String,
help: String,
args: Vec<Arg>,
hint: bool,
allow: Option<Allow>,
at: Option<At>,
on: Option<On>,
}
impl core::fmt::Debug for Action {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Action")
.field("id", &self.id)
.field("label", &self.label)
.field("args", &self.args)
.field("hint", &self.hint)
.field("per_player", &self.on.is_some())
.finish_non_exhaustive()
}
}
impl Action {
#[must_use]
pub fn new(id: &str, label: &str) -> Self {
Self {
id: id.to_owned(),
label: label.to_owned(),
help: String::new(),
args: Vec::new(),
hint: false,
allow: None,
at: None,
on: None,
}
}
#[must_use]
pub fn per_player<F>(id: &str, label: &str, on: F) -> Self
where
F: Fn(&Ask<'_>, Option<&Subject>) -> Vec<(u64, String)> + Send + Sync + 'static,
{
let mut action = Self::new(id, label);
action.on = Some(Box::new(on));
action
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn help(mut self, help: &str) -> Self {
help.clone_into(&mut self.help);
self
}
#[must_use]
pub fn arg(mut self, arg: Arg) -> Self {
self.args.push(arg);
self
}
#[must_use]
pub fn allow<F>(mut self, allow: F) -> Self
where
F: Fn(&Player) -> bool + Send + Sync + 'static,
{
self.allow = Some(Box::new(allow));
self
}
#[must_use]
pub fn admins(self) -> Self {
self.allow(Player::is_admin)
}
#[must_use]
pub fn at<F>(mut self, at: F) -> Self
where
F: Fn(&Ask<'_>) -> Result<Subject, String> + Send + Sync + 'static,
{
self.at = Some(Box::new(at));
self
}
#[must_use]
pub fn hint(mut self) -> Self {
self.hint = true;
self
}
}
struct Declared {
action: Action,
handler: Handler,
}
struct Registry {
texts: Texts,
host: Host,
declared: RwLock<Vec<Declared>>,
}
#[derive(Clone)]
pub struct Actions {
registry: Arc<Registry>,
}
impl core::fmt::Debug for Actions {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Actions").finish_non_exhaustive()
}
}
impl Actions {
pub fn add<F>(&self, action: Action, handler: F) -> Result<(), Off>
where
F: Fn(&Request<'_>) -> Outcome + Send + Sync + 'static,
{
let id = action.id.clone();
if !is_valid_id(&id) || id == "help" {
return Err(Off::new(format!(
"action {id}: ids are 1 to 32 of [a-z0-9-], not help"
)));
}
if action.on.is_some() && !action.args.is_empty() {
return Err(Off::new(format!("action {id}: a list takes no arguments")));
}
if action.hint && action.at.is_none() {
return Err(Off::new(format!("action {id}: a hint needs a subject")));
}
let mut declared = self
.registry
.declared
.write()
.unwrap_or_else(PoisonError::into_inner);
if declared.iter().any(|d| d.action.id == id) {
return Err(Off::new(format!("action {id}: the id is taken")));
}
declared.push(Declared {
action,
handler: Box::new(handler),
});
Ok(())
}
}
impl Registry {
fn say(&self, key: &str, language: Language, values: &[(&str, &str)]) -> String {
self.texts.fill(key, language, values)
}
fn online(&self) -> Vec<Player> {
crate::world::online(self.host)
}
fn ask<'a>(&'a self, player: &'a Player, language: Language) -> Ask<'a> {
Ask {
player,
language,
texts: &self.texts,
}
}
fn usage(&self, action: &Action, language: Language) -> String {
let player = format!("<{}>", self.say("actions.player", language, &[]));
let on_off = format!(
"<{}|{}>",
self.say("actions.on", language, &[]),
self.say("actions.off", language, &[])
);
let label = self.say(&action.label, language, &[]);
if action.on.is_some() {
return format!("{} {player} {on_off}: {label}", action.id);
}
let mut line = action.id.clone();
for arg in &action.args {
let shown = match arg.kind {
ArgView::PLAYER => player.clone(),
ArgView::CHOICE => format!(
"<{}>",
arg.choices
.iter()
.map(|(word, _)| word.as_str())
.collect::<Vec<_>>()
.join("|")
),
ArgView::NUMBER => format!("<{}-{}>", arg.range.0, arg.range.1),
_ => on_off.clone(),
};
line.push(' ');
line.push_str(&shown);
}
format!("{line}: {label}")
}
fn help(&self, player: &Player, language: Language) -> String {
let declared = self.declared.read().unwrap_or_else(PoisonError::into_inner);
let mut lines = vec![self.say("actions.usage", language, &[])];
for d in declared.iter().filter(|d| Self::allowed(d, player)) {
lines.push(self.usage(&d.action, language));
if !d.action.help.is_empty() {
lines.push(format!(" {}", self.say(&d.action.help, language, &[])));
}
}
lines.join("\n")
}
fn run_from_console(&self, player: &Player, line: &str) -> Outcome {
let language = Language::FALLBACK;
let words: Vec<&str> = line.split_whitespace().collect();
let Some((&word, rest)) = words.split_first() else {
return Outcome::done(self.help(player, language));
};
let word = word.to_lowercase();
if word == "help" {
return Outcome::done(self.help(player, language));
}
let declared = self.declared.read().unwrap_or_else(PoisonError::into_inner);
let Some(d) = declared.iter().find(|d| d.action.id == word) else {
return Outcome::refused(self.say("actions.unknown", language, &[("word", &word)]));
};
match self.console_args(d, player, rest, language) {
Ok(values) => self.run(d, player, &values, language, Via::Console),
Err(why) => Outcome::refused(why),
}
}
fn console_args(
&self,
d: &Declared,
player: &Player,
words: &[&str],
language: Language,
) -> Result<Vec<Value>, String> {
let bad = || {
self.say(
"actions.bad",
language,
&[("usage", &self.usage(&d.action, language))],
)
};
if d.action.on.is_some() {
let (last, name) = words.split_last().ok_or_else(bad)?;
let on = toggle_word(last, &self.texts, language).ok_or_else(bad)?;
let name = name.join(" ");
if name.is_empty() {
return Err(bad());
}
let listed = self.listed(d, player, language);
let target = self.player_named(&name, &listed, language)?;
return Ok(vec![target, Value::Toggle(on)]);
}
let mut values = Vec::with_capacity(d.action.args.len());
let mut rest = words;
for (i, arg) in d.action.args.iter().enumerate() {
let last = i + 1 == d.action.args.len();
let value = if arg.kind == ArgView::PLAYER {
let taken = if last { rest.len() } else { 1 };
if rest.len() < taken || taken == 0 {
return Err(bad());
}
let name = rest[..taken].join(" ");
rest = &rest[taken..];
self.player_named(&name, &[], language)?
} else {
let (&word, tail) = rest.split_first().ok_or_else(bad)?;
rest = tail;
match arg.kind {
ArgView::CHOICE => Value::Choice(
arg.choices
.iter()
.position(|(w, _)| w.eq_ignore_ascii_case(word))
.or_else(|| {
word.parse::<usize>()
.ok()
.and_then(|n| n.checked_sub(1))
.filter(|&n| n < arg.choices.len())
})
.ok_or_else(bad)?,
),
ArgView::NUMBER => Value::Number(
word.replace(',', ".")
.parse::<f64>()
.ok()
.filter(|n| n.is_finite() && *n >= arg.range.0 && *n <= arg.range.1)
.ok_or_else(bad)?,
),
_ => Value::Toggle(toggle_word(word, &self.texts, language).ok_or_else(bad)?),
}
};
values.push(value);
}
if !rest.is_empty() {
return Err(bad());
}
Ok(values)
}
fn player_named(
&self,
name: &str,
listed: &[(u64, String)],
language: Language,
) -> Result<Value, String> {
let lower = name.to_lowercase();
let mut found: Vec<(u64, String)> = self
.online()
.into_iter()
.filter(|p| p.is_named(name))
.map(|p| (p.key, p.name))
.collect();
for (key, listed_name) in listed {
if listed_name.to_lowercase() == lower && !found.iter().any(|(k, _)| k == key) {
found.push((*key, listed_name.clone()));
}
}
match found.as_slice() {
[(key, name)] => Ok(Value::Player {
key: *key,
name: name.clone(),
}),
[] => Err(self.say("actions.no_player", language, &[("name", name)])),
_ => Err(self.say("actions.several", language, &[("name", name)])),
}
}
fn listed(&self, d: &Declared, player: &Player, language: Language) -> Vec<(u64, String)> {
let ask = self.ask(player, language);
let subject = match d.action.at.as_ref().map(|at| at(&ask)) {
Some(Ok(subject)) => Some(subject),
Some(Err(_)) => return Vec::new(),
None => None,
};
d.action
.on
.as_ref()
.map(|on| on(&ask, subject.as_ref()))
.unwrap_or_default()
}
fn allowed(d: &Declared, player: &Player) -> bool {
d.action.allow.as_ref().is_none_or(|allow| allow(player))
}
fn run(
&self,
d: &Declared,
player: &Player,
values: &[Value],
language: Language,
via: Via,
) -> Outcome {
if !Self::allowed(d, player) {
return Outcome::refused(self.say("actions.denied", language, &[]));
}
let ask = self.ask(player, language);
let subject = match d.action.at.as_ref().map(|at| at(&ask)) {
Some(Ok(subject)) => Some(subject),
Some(Err(why)) => return Outcome::refused(why),
None => None,
};
let shape_fits = if d.action.on.is_some() {
matches!(values, [Value::Player { .. }, Value::Toggle(_)])
} else {
values.len() == d.action.args.len()
&& values.iter().zip(&d.action.args).all(|(v, a)| fits(v, a))
};
if !shape_fits {
return Outcome::refused(self.say(
"actions.bad",
language,
&[("usage", &self.usage(&d.action, language))],
));
}
(d.handler)(&Request {
player,
subject: subject.as_ref(),
args: values,
language,
via,
texts: &self.texts,
})
}
fn sheet(&self, player: &Player, language: Language) -> String {
let declared = self.declared.read().unwrap_or_else(PoisonError::into_inner);
let online = self.online();
let others: Vec<(u64, String)> = online
.iter()
.filter(|p| p.key != player.key)
.map(|p| (p.key, p.name.clone()))
.take(MAX_PLAYERS)
.collect();
let mut entries = Vec::new();
for d in declared.iter().filter(|d| Self::allowed(d, player)) {
let ask = self.ask(player, language);
let subject = d.action.at.as_ref().map(|at| at(&ask));
let (offered, subject_text) = match &subject {
Some(Ok(subject)) => (true, subject.label.clone()),
Some(Err(why)) => (false, why.clone()),
None => (true, String::new()),
};
let mut entry = Entry {
id: d.action.id.clone(),
label: self.say(&d.action.label, language, &[]),
help: if d.action.help.is_empty() {
String::new()
} else {
self.say(&d.action.help, language, &[])
},
per_player: d.action.on.is_some(),
hint: d.action.hint,
offered,
subject: subject_text,
args: Vec::new(),
players: Vec::new(),
};
if let Some(on) = &d.action.on {
let switched = if offered {
on(&ask, subject.as_ref().and_then(|s| s.as_ref().ok()))
} else {
Vec::new()
};
let mut players: Vec<(u64, String, bool)> = others
.iter()
.map(|(key, name)| (*key, name.clone(), switched.iter().any(|(k, _)| k == key)))
.collect();
for (key, name) in &switched {
if *key != player.key && !players.iter().any(|(k, _, _)| k == key) {
players.push((*key, name.clone(), true));
}
}
players.sort_by_key(|(_, name, _)| name.to_lowercase());
players.truncate(MAX_PLAYERS);
entry.players = players;
}
for arg in &d.action.args {
entry.args.push(ArgView {
kind: arg.kind,
label: self.say(&arg.label, language, &[]),
min: arg.range.0,
max: arg.range.1,
step: arg.step,
choices: arg
.choices
.iter()
.map(|(_, label)| self.say(label, language, &[]))
.collect(),
players: if arg.kind == ArgView::PLAYER {
others.clone()
} else {
Vec::new()
},
});
}
entries.push(entry);
}
sheet::encode(&entries)
}
fn run_from_interface(
&self,
player: &Player,
id: &str,
args: &str,
language: Language,
) -> String {
let declared = self.declared.read().unwrap_or_else(PoisonError::into_inner);
let outcome = match declared.iter().find(|d| d.action.id == id) {
None => Outcome::refused(self.say("actions.unknown", language, &[("word", id)])),
Some(d) => match sheet::decode_args(args) {
None => Outcome::refused(self.say("actions.denied", language, &[])),
Some(mut values) => match self.name_players(d, player, &mut values, language) {
Ok(()) => self.run(d, player, &values, language, Via::Interface),
Err(why) => Outcome::refused(why),
},
},
};
format!("{}{}", u8::from(outcome.done), outcome.text)
}
fn name_players(
&self,
d: &Declared,
player: &Player,
values: &mut [Value],
language: Language,
) -> Result<(), String> {
if !values.iter().any(|v| matches!(v, Value::Player { .. })) {
return Ok(());
}
let online = self.online();
let listed = if d.action.on.is_some() {
self.listed(d, player, language)
} else {
Vec::new()
};
for value in values.iter_mut() {
if let Value::Player { key, name } = value {
let known = online
.iter()
.find(|p| p.key == *key)
.map(|p| p.name.clone())
.or_else(|| {
listed
.iter()
.find(|(k, _)| k == key)
.map(|(_, n)| n.clone())
});
*name = known.ok_or_else(|| {
self.say(
"actions.no_player",
language,
&[("name", &format!("{key:016x}"))],
)
})?;
}
}
Ok(())
}
fn hints(&self, player: &Player, language: Language) -> String {
let declared = self.declared.read().unwrap_or_else(PoisonError::into_inner);
let mut out = String::new();
for d in declared
.iter()
.filter(|d| d.action.hint && Self::allowed(d, player))
{
let ask = self.ask(player, language);
if let Some(Ok(subject)) = d.action.at.as_ref().map(|at| at(&ask)) {
out.push_str(&sheet::clean(&d.action.id));
out.push('\t');
out.push_str(&sheet::clean(&self.say(&d.action.label, language, &[])));
out.push('\t');
out.push_str(&sheet::clean(&subject.label));
out.push('\n');
}
}
out
}
}
fn fits(value: &Value, arg: &Arg) -> bool {
match (value, arg.kind) {
(Value::Player { .. }, ArgView::PLAYER) | (Value::Toggle(_), ArgView::TOGGLE) => true,
(Value::Choice(index), ArgView::CHOICE) => *index < arg.choices.len(),
(Value::Number(n), ArgView::NUMBER) => {
n.is_finite() && *n >= arg.range.0 && *n <= arg.range.1
}
_ => false,
}
}
fn toggle_word(word: &str, texts: &Texts, language: Language) -> Option<bool> {
let word = word.to_lowercase();
let on = texts.fill("actions.on", language, &[]).to_lowercase();
let off = texts.fill("actions.off", language, &[]).to_lowercase();
if word == on || ["on", "yes", "true", "1", "allow"].contains(&word.as_str()) {
Some(true)
} else if word == off || ["off", "no", "false", "0", "deny"].contains(&word.as_str()) {
Some(false)
} else {
None
}
}
thread_local! {
static REPLY: RefCell<String> = const { RefCell::new(String::new()) };
}
unsafe extern "system" fn answer(call: *const ActionCall, user: *mut c_void, out: *mut Str) {
let Some(call) = (unsafe { read_headed(call, size_of::<ActionCall>()) }) else {
return;
};
let registry = unsafe { &*user.cast::<Arc<Registry>>() };
let player = Player::from_info(&call.player);
let language = Language(call.language);
let action = unsafe { call.action.as_str() }.unwrap_or("");
let args = unsafe { call.args.as_str() }.unwrap_or("");
let reply = crate::host::guard("panic in an action", String::new, || match call.kind {
ActionCall::SHEET => registry.sheet(&player, language),
ActionCall::RUN => registry.run_from_interface(&player, action, args, language),
ActionCall::HINTS => registry.hints(&player, language),
ActionCall::CONSOLE => {
let outcome = registry.run_from_console(&player, action);
format!("{}{}", u8::from(outcome.done), outcome.text)
}
_ => String::new(),
});
if out.is_null() {
return;
}
REPLY.with_borrow_mut(|kept| {
*kept = reply;
unsafe { out.write(Str::new(kept)) };
});
}
impl Load<'_> {
pub fn actions(&mut self, texts: Texts) -> Result<Actions, Off> {
let mut all = Texts::parse(PLATFORM_TEXTS)?;
all.merge(texts);
let host = self.host();
let registry = Arc::new(Registry {
texts: all,
host,
declared: RwLock::new(Vec::new()),
});
crate::load::hand_over(
Arc::clone(®istry),
|user| (host.api().actions)(host.handle(), answer, user),
"actions",
)?;
Ok(Actions { registry })
}
}
fn is_valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_ID
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_words_read_both_ways() {
let texts = Texts::parse(PLATFORM_TEXTS).unwrap();
assert_eq!(toggle_word("ON", &texts, Language::FALLBACK), Some(true));
assert_eq!(toggle_word("deny", &texts, Language::UK_UA), Some(false));
assert_eq!(toggle_word("maybe", &texts, Language::FALLBACK), None);
}
#[test]
fn values_fit_their_arguments() {
let number = Arg::number("n", 5.0..=1.0, -1.0);
assert_eq!(number.range, (1.0, 5.0));
assert!((number.step - 1.0).abs() < f64::EPSILON);
assert!(fits(&Value::Number(3.0), &number));
assert!(!fits(&Value::Number(6.0), &number));
assert!(!fits(&Value::Toggle(true), &number));
let choice = Arg::choice("c", &[("a", "A")]);
assert!(fits(&Value::Choice(0), &choice));
assert!(!fits(&Value::Choice(1), &choice));
}
}