144 lines
4.0 KiB
Rust
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");
|
|
}
|
|
}
|