Files
mudmouth/src/events.rs
T
2026-07-20 20:32:56 -07:00

144 lines
4.0 KiB
Rust

use chrono::{DateTime, Local, NaiveDateTime};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogEvent {
NpcDialogue {
at: DateTime<Local>,
speaker: String,
text: String,
},
ZoneTransition {
at: DateTime<Local>,
zone: String,
},
}
impl LogEvent {
pub fn label(&self) -> &'static str {
match self {
Self::NpcDialogue { .. } => "NPC",
Self::ZoneTransition { .. } => "ZONE",
}
}
pub fn text(&self) -> &str {
match self {
Self::NpcDialogue { text, .. } => text,
Self::ZoneTransition { zone, .. } => zone,
}
}
pub fn occurred_at(&self) -> DateTime<Local> {
match self {
Self::NpcDialogue { at, .. } | Self::ZoneTransition { at, .. } => *at,
}
}
}
pub fn parse_line(line: &str) -> Option<LogEvent> {
let (timestamp, message) = split_timestamp(line)?;
if let Some((speaker, text)) = parse_npc_says(message) {
return Some(LogEvent::NpcDialogue {
at: timestamp,
speaker,
text,
});
}
message
.strip_prefix("You have entered ")
.and_then(|zone| zone.strip_suffix('.'))
.filter(|zone| !zone.is_empty())
.map(|zone| LogEvent::ZoneTransition {
at: timestamp,
zone: zone.to_string(),
})
}
fn split_timestamp(line: &str) -> Option<(DateTime<Local>, &str)> {
let end = line.find("] ")?;
let raw_timestamp = line.strip_prefix('[')?.get(..end - 1)?;
let timestamp = NaiveDateTime::parse_from_str(raw_timestamp, "%a %b %d %H:%M:%S %Y")
.ok()?
.and_local_timezone(Local)
.single()?;
Some((timestamp, &line[end + 2..]))
}
fn parse_npc_says(message: &str) -> Option<(String, String)> {
let (speaker, quote) = message.split_once(" says, '")?;
let text = quote.strip_suffix('\'')?;
if speaker.is_empty() || text.is_empty() || speaker == "You" {
return None;
}
Some((speaker.to_string(), text.to_string()))
}
pub fn speaker_key(name: &str) -> String {
let normalized = name
.chars()
.flat_map(char::to_lowercase)
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect::<String>();
let normalized = normalized.trim_matches('_');
let compact = normalized
.split('_')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("_");
format!("npc:{compact}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_npc_dialogue_with_apostrophe() {
let event =
parse_line("[Fri Jul 17 20:10:29 2026] Turga says, 'I've got a Rusty Long Sword.'")
.unwrap();
assert_eq!(
event,
LogEvent::NpcDialogue {
at: NaiveDateTime::parse_from_str(
"Fri Jul 17 20:10:29 2026",
"%a %b %d %H:%M:%S %Y"
)
.unwrap()
.and_local_timezone(Local)
.single()
.unwrap(),
speaker: "Turga".into(),
text: "I've got a Rusty Long Sword.".into(),
}
);
}
#[test]
fn ignores_player_dialogue_and_channel_tells() {
assert!(parse_line("[Fri Jul 17 20:10:29 2026] You say, 'Hail'").is_none());
assert!(parse_line("[Fri Jul 17 20:10:29 2026] Rath tells General:2, 'hello'").is_none());
}
#[test]
fn parses_zone_message() {
let event =
parse_line("[Fri Jul 17 20:09:32 2026] You have entered Lake Rathetear.").unwrap();
assert!(matches!(event, LogEvent::ZoneTransition { zone, .. } if zone == "Lake Rathetear"));
}
#[test]
fn normalizes_speaker_keys() {
assert_eq!(speaker_key("A lizardman healer"), "npc:a_lizardman_healer");
assert_eq!(speaker_key("Zok Caropni!"), "npc:zok_caropni");
}
}