use core::fmt::Write as _;
use super::Value;
use crate::PlayerKey;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Entry {
pub id: String,
pub label: String,
pub help: String,
pub per_player: bool,
pub hint: bool,
pub offered: bool,
pub subject: String,
pub args: Vec<ArgView>,
pub players: Vec<(PlayerKey, String, bool)>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ArgView {
pub kind: u8,
pub label: String,
pub min: f64,
pub max: f64,
pub step: f64,
pub choices: Vec<String>,
pub players: Vec<(PlayerKey, String)>,
}
impl ArgView {
pub const PLAYER: u8 = 0;
pub const CHOICE: u8 = 1;
pub const NUMBER: u8 = 2;
pub const TOGGLE: u8 = 3;
}
const ITEM: char = '\u{1f}';
const PAIR: char = '\u{1e}';
#[must_use]
pub fn clean(text: &str) -> String {
text.chars()
.map(|c| match c {
'\t' | '\n' | '\r' | ITEM | PAIR => ' ',
c => c,
})
.collect()
}
#[must_use]
pub fn encode(entries: &[Entry]) -> String {
let mut out = String::new();
for entry in entries {
let flags =
u8::from(entry.per_player) | u8::from(entry.hint) << 1 | u8::from(entry.offered) << 2;
let _ = writeln!(
out,
"A\t{}\t{flags}\t{}\t{}\t{}",
clean(&entry.id),
clean(&entry.label),
clean(&entry.help),
clean(&entry.subject)
);
for arg in &entry.args {
let choices = if arg.kind == ArgView::PLAYER {
arg.players
.iter()
.map(|(key, name)| format!("{key:016x}{PAIR}{}", clean(name)))
.collect::<Vec<_>>()
} else {
arg.choices.iter().map(|c| clean(c)).collect()
};
let _ = writeln!(
out,
"G\t{}\t{}\t{}\t{}\t{}\t{}",
arg.kind,
clean(&arg.label),
arg.min,
arg.max,
arg.step,
choices.join(&ITEM.to_string())
);
}
for (key, name, on) in &entry.players {
let _ = writeln!(out, "O\t{key:016x}\t{}\t{}", u8::from(*on), clean(name));
}
}
out
}
#[must_use]
pub fn decode(text: &str) -> Vec<Entry> {
let mut entries: Vec<Entry> = Vec::new();
for line in text.lines() {
let fields: Vec<&str> = line.split('\t').collect();
match fields.as_slice() {
["A", id, flags, label, help, subject] => {
let flags = flags.parse::<u8>().unwrap_or(0);
entries.push(Entry {
id: (*id).to_owned(),
label: (*label).to_owned(),
help: (*help).to_owned(),
per_player: flags & 1 != 0,
hint: flags & 2 != 0,
offered: flags & 4 != 0,
subject: (*subject).to_owned(),
args: Vec::new(),
players: Vec::new(),
});
}
["G", kind, label, min, max, step, choices] => {
let Some(entry) = entries.last_mut() else {
continue;
};
let kind = kind.parse::<u8>().unwrap_or(u8::MAX);
let items = choices.split(ITEM).filter(|c| !c.is_empty());
let (choices, players) = if kind == ArgView::PLAYER {
let players = items
.filter_map(|item| {
let (key, name) = item.split_once(PAIR)?;
Some((
u64::from_str_radix(key, 16).ok().map(PlayerKey::new)?,
name.to_owned(),
))
})
.collect();
(Vec::new(), players)
} else {
(items.map(str::to_owned).collect(), Vec::new())
};
entry.args.push(ArgView {
kind,
label: (*label).to_owned(),
min: min.parse().unwrap_or(0.0),
max: max.parse().unwrap_or(0.0),
step: step.parse().unwrap_or(0.0),
choices,
players,
});
}
["O", key, on, name] => {
let (Some(entry), Ok(key)) = (
entries.last_mut(),
u64::from_str_radix(key, 16).map(PlayerKey::new),
) else {
continue;
};
entry.players.push((key, (*name).to_owned(), *on == "1"));
}
_ => {}
}
}
entries
}
#[must_use]
pub fn encode_args(args: &[Value]) -> String {
let mut out = String::new();
for (i, value) in args.iter().enumerate() {
if i > 0 {
out.push('\t');
}
let _ = match value {
Value::Player { key, .. } => write!(out, "p{key:016x}"),
Value::Choice(index) => write!(out, "c{index}"),
Value::Number(number) => write!(out, "n{number}"),
Value::Toggle(on) => write!(out, "b{}", u8::from(*on)),
};
}
out
}
#[must_use]
pub fn decode_args(text: &str) -> Option<Vec<Value>> {
if text.is_empty() {
return Some(Vec::new());
}
text.split('\t')
.map(|field| {
let (tag, rest) = field.split_at_checked(1)?;
Some(match tag {
"p" => Value::Player {
key: u64::from_str_radix(rest, 16).ok().map(PlayerKey::new)?,
name: String::new(),
},
"c" => Value::Choice(rest.parse().ok()?),
"n" => Value::Number(rest.parse::<f64>().ok().filter(|n| n.is_finite())?),
"b" => Value::Toggle(rest == "1"),
_ => return None,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sheet_round_trips_with_arguments_and_players() {
let entries = vec![
Entry {
id: "access".into(),
label: "Who may use it".into(),
help: "Let players use the locker".into(),
per_player: true,
hint: true,
offered: true,
subject: "Your locker at (2, 10, -4)".into(),
args: Vec::new(),
players: vec![
(PlayerKey::new(7), "Ann".into(), true),
(PlayerKey::new(u64::MAX), "Bob\tLee".into(), false),
],
},
Entry {
id: "give".into(),
label: "Give".into(),
offered: false,
subject: "No locker in reach".into(),
args: vec![
ArgView {
kind: ArgView::PLAYER,
label: "To".into(),
players: vec![(PlayerKey::new(9), "Cy".into())],
..ArgView::default()
},
ArgView {
kind: ArgView::CHOICE,
label: "What".into(),
choices: vec!["Wood".into(), "Stone".into()],
..ArgView::default()
},
ArgView {
kind: ArgView::NUMBER,
label: "How many".into(),
min: 1.0,
max: 99.0,
step: 1.0,
..ArgView::default()
},
],
..Entry::default()
},
];
let mut expected = entries.clone();
expected[0].players[1].1 = "Bob Lee".into();
assert_eq!(decode(&encode(&entries)), expected);
assert!(decode("G\t0\tx\t0\t0\t0\t\nnonsense\n").is_empty());
}
#[test]
fn arguments_round_trip_and_bad_ones_are_refused() {
let args = vec![
Value::Player {
key: PlayerKey::new(0xdead_beef),
name: String::new(),
},
Value::Toggle(false),
];
assert_eq!(decode_args(&encode_args(&args)), Some(args));
assert_eq!(decode_args(""), Some(Vec::new()));
assert_eq!(decode_args("x1"), None);
assert_eq!(decode_args("nNaN"), None);
assert_eq!(decode_args("pzz"), None);
}
}