diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/categories.rs | 101 | ||||
-rw-r--r-- | src/logbag.rs | 278 | ||||
-rw-r--r-- | src/matrix.rs | 2 |
3 files changed, 301 insertions, 80 deletions
diff --git a/src/categories.rs b/src/categories.rs index de4f5e6..1713d87 100644 --- a/src/categories.rs +++ b/src/categories.rs @@ -1,48 +1,115 @@ use evtclib::{Encounter, GameMode, Log}; +// There is no canonical order of "categories", so we'll do +// * WvW +// * Raid wings ascending +// * Strike +// * Fractals descending +// * Training area +// * Unknown +// Subject to change, it's only used to sort the message (if enabled). +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)] +pub enum Category { + WvW, + Wing1, + Wing2, + Wing3, + Wing4, + Wing5, + Wing6, + Wing7, + Strike, + SunquaPeak, + ShatteredObservatory, + Nightmare, + SpecialForcesTrainingArea, + #[default] + Unknown, +} + +macro_rules! category_strings { + ($(($item:path, $str:literal),)*) => { + impl std::fmt::Display for Category { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let repr = match *self { + $($item => $str),* + }; + write!(f, "{}", repr) + } + } + + impl std::str::FromStr for Category { + type Err = (); + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + $($str => Ok($item),)* + _ => Err(()), + } + } + } + } +} + +category_strings! { + (Category::WvW, "World versus World"), + (Category::Wing1, "Wing 1 (Spirit Vale)"), + (Category::Wing2, "Wing 2 (Salvation Pass)"), + (Category::Wing3, "Wing 3 (Stronghold of the Faithful)"), + (Category::Wing4, "Wing 4 (Bastion of the Penitent)"), + (Category::Wing5, "Wing 5 (Hall of Chains)"), + (Category::Wing6, "Wing 6 (Mythwright Gambit)"), + (Category::Wing7, "Wing 7 (Key of Ahdashim)"), + (Category::SunquaPeak, "100 CM (Sunqua Peak)"), + (Category::ShatteredObservatory, "99 CM (Shattered Observatory)"), + (Category::Nightmare, "98 CM (Nightmare)"), + (Category::Strike, "Strike Mission"), + (Category::SpecialForcesTrainingArea, "Special Forces Training Area"), + (Category::Unknown, "Unknown"), +} + pub trait Categorizable { - fn category(&self) -> &'static str; + fn category(&self) -> Category; } impl Categorizable for Log { - fn category(&self) -> &'static str { + fn category(&self) -> Category { if self.game_mode() == Some(GameMode::WvW) { - return "World versus World"; + return Category::WvW; } if let Some(encounter) = self.encounter() { match encounter { Encounter::ValeGuardian | Encounter::Gorseval | Encounter::Sabetha => { - "Wing 1 (Spirit Vale)" + Category::Wing1 } Encounter::Slothasor | Encounter::BanditTrio | Encounter::Matthias => { - "Wing 2 (Salvation Pass)" + Category::Wing2 } Encounter::KeepConstruct | Encounter::TwistedCastle | Encounter::Xera => { - "Wing 3 (Stronghold of the Faithful)" + Category::Wing3 } Encounter::Cairn | Encounter::MursaatOverseer | Encounter::Samarog - | Encounter::Deimos => "Wing 4 (Bastion of the Penitent)", + | Encounter::Deimos => Category::Wing4, Encounter::SoullessHorror | Encounter::RiverOfSouls | Encounter::BrokenKing | Encounter::EaterOfSouls | Encounter::StatueOfDarkness - | Encounter::VoiceInTheVoid => "Wing 5 (Hall of Chains)", + | Encounter::VoiceInTheVoid => Category::Wing5, Encounter::ConjuredAmalgamate | Encounter::TwinLargos | Encounter::Qadim => { - "Wing 6 (Mythwright Gambit)" + Category::Wing6 } Encounter::CardinalAdina | Encounter::CardinalSabir - | Encounter::QadimThePeerless => "Wing 7 (Key of Ahdashim)", + | Encounter::QadimThePeerless => Category::Wing7, - Encounter::Ai => "100 CM (Sunqua Peak)", + Encounter::Ai => Category::SunquaPeak, Encounter::Skorvald | Encounter::Artsariiv | Encounter::Arkk => { - "99 CM (Shattered Observatory)" + Category::ShatteredObservatory } - Encounter::MAMA | Encounter::Siax | Encounter::Ensolyss => "98 CM (Nightmare)", + Encounter::MAMA | Encounter::Siax | Encounter::Ensolyss => Category::Nightmare, Encounter::IcebroodConstruct | Encounter::SuperKodanBrothers @@ -52,16 +119,16 @@ impl Categorizable for Log { | Encounter::CaptainMaiTrin | Encounter::Ankka | Encounter::MinisterLi - | Encounter::Dragonvoid => "Strike Mission", + | Encounter::Dragonvoid => Category::Strike, Encounter::StandardKittyGolem | Encounter::MediumKittyGolem - | Encounter::LargeKittyGolem => "Special Forces Training Area", + | Encounter::LargeKittyGolem => Category::SpecialForcesTrainingArea, - _ => "Unknown", + _ => Category::Unknown, } } else { - "Unknown" + Category::Unknown } } } diff --git a/src/logbag.rs b/src/logbag.rs index 362bee5..f173d6e 100644 --- a/src/logbag.rs +++ b/src/logbag.rs @@ -1,8 +1,10 @@ -use std::iter; -use std::str::FromStr; +use super::categories::Category; -use evtclib::{Log, Outcome}; +use std::{cmp::Ordering, sync::OnceLock, str::FromStr}; + +use evtclib::{Encounter, Log, Outcome}; use itertools::Itertools; +use regex::Regex; /// A [`LogBag`] is a struct that holds multiple logs in their categories. /// @@ -11,7 +13,7 @@ use itertools::Itertools; /// them and we can just handle arbitrary data. #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] pub struct LogBag { - data: Vec<(String, Vec<String>)>, + data: Vec<(Category, Vec<String>)>, } // Conditional compilation makes it hard to really use all the code, so we just allow dead code @@ -24,30 +26,54 @@ impl LogBag { } /// Return an iterator over all available categories. - pub fn categories(&self) -> impl Iterator<Item = &str> { - self.data.iter().map(|x| &x.0 as &str) + pub fn categories(&self) -> impl Iterator<Item = Category> + '_ { + self.data.iter().map(|x| x.0) } /// Return an iterator over (category, items). - pub fn iter(&self) -> impl Iterator<Item = (&str, impl Iterator<Item = &str>)> { + pub fn iter(&self) -> impl Iterator<Item = (Category, impl Iterator<Item = &str>)> { self.data .iter() - .map(|(cat, lines)| (cat as &str, lines.iter().map(|l| l as &str))) + .map(|(cat, lines)| (*cat, lines.iter().map(|l| l as &str))) } /// Insert an item into the given category. /// /// If the category does not exist yet, it will be appended at the bottom. - pub fn insert(&mut self, category: &str, line: String) { + pub fn insert(&mut self, category: Category, line: String) { for (cat, lines) in self.data.iter_mut() { - if cat == category { + if *cat == category { lines.push(line); return; } } // When we reach here, we don't have the category yet, so we gotta insert it. - self.data.push((String::from(category), vec![line])); + self.data.push((category, vec![line])); + } + + /// Sort the categories and logs. + pub fn sort(&mut self) { + // First sort the categories, the Ord impl of `Category` takes care here + self.data.sort(); + // Then sort the lines within the categories + for (_, ref mut lines) in self.data.iter_mut() { + lines.sort_by(|a, b| { + let (encounter_a, date_a, url_a) = info_from_line(&a); + let (encounter_b, date_b, url_b) = info_from_line(&b); + match (encounter_a, encounter_b) { + (None, None) => date_a.cmp(&date_b), + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(encounter_a), Some(encounter_b)) => encounter_a.partial_cmp(&encounter_b) + .or_else(|| order_strikes(encounter_a, encounter_b)) + // at this point, just give us a stable order + .unwrap_or((encounter_a as u16).cmp(&(encounter_b as u16))) + .then(date_a.cmp(&date_b)) + .then(url_a.cmp(&url_b)), + } + }); + } } /// Tries to parse the given text as a plain [`LogBag`]. @@ -68,7 +94,7 @@ impl LogBag { /// The output of this can be fed back into [`LogBag::parse_plain`] to round-trip. pub fn render_plain(&self) -> String { self.iter() - .map(|(category, lines)| iter::once(category).chain(lines).join("\n")) + .map(|(category, mut lines)| category.to_string() + "\n" + &lines.join("\n")) .join("\n\n") } @@ -104,18 +130,18 @@ impl FromStr for LogBag { let mut lines = chunk.split('\n'); let category = lines.next().unwrap(); ( - category.to_string(), + category.parse::<Category>().unwrap_or_default(), lines.map(ToString::to_string).collect::<Vec<_>>(), ) }) - .filter(|(cat, lines)| !cat.is_empty() && !lines.is_empty()) + .filter(|(_, lines)| !lines.is_empty()) .collect(); Ok(LogBag { data }) } } -impl From<Vec<(String, Vec<String>)>> for LogBag { - fn from(data: Vec<(String, Vec<String>)>) -> Self { +impl From<Vec<(Category, Vec<String>)>> for LogBag { + fn from(data: Vec<(Category, Vec<String>)>) -> Self { LogBag { data } } } @@ -130,6 +156,100 @@ pub fn state_emoji(log: &Log) -> &'static str { } } +// I don't want to pull in something like `chrono` just to represent a local datetime. Therefore, +// we simply use two integers: The date in the format YYYYMMDD (which automatically sorts +// correctly), and the time in HHMMSS (which does as well). +fn info_from_line(line: &str) -> (Option<Encounter>, Option<(u32, u32)>, &str) { + static RE: OnceLock<Regex> = OnceLock::new(); + let url_re = RE.get_or_init(|| { + Regex::new("http(s?)://[^ ]+(?P<date>\\d{8})-(?P<time>\\d{6})_(?P<slug>[a-z]+)").unwrap() + }); + let Some(caps) = url_re.captures(line) else { return (None, None, line); }; + let date = caps + .name("date") + .expect("date group must be present") + .as_str() + .parse() + .expect("matched only digits, parsing should succeeed"); + let time = caps + .name("time") + .expect("time group must be present") + .as_str() + .parse() + .expect("matched only digits, parsing should succeeed"); + + let encounter = match caps.name("slug").expect("slug group must be present").as_str() { + "vg" => Some(Encounter::ValeGuardian), + "gors" => Some(Encounter::Gorseval), + "sab" => Some(Encounter::Sabetha), + "sloth" => Some(Encounter::Slothasor), + "trio" => Some(Encounter::BanditTrio), + "matt" => Some(Encounter::Matthias), + "kc" => Some(Encounter::KeepConstruct), + "tc" => Some(Encounter::TwistedCastle), + "xera" => Some(Encounter::Xera), + "cairn" => Some(Encounter::Cairn), + "mo" => Some(Encounter::MursaatOverseer), + "sam" => Some(Encounter::Samarog), + "dei" => Some(Encounter::Deimos), + "sh" => Some(Encounter::SoullessHorror), + "rr" => Some(Encounter::RiverOfSouls), + "bk" => Some(Encounter::BrokenKing), + "se" => Some(Encounter::EaterOfSouls), + "eyes" => Some(Encounter::StatueOfDarkness), + "dhuum" => Some(Encounter::VoiceInTheVoid), + "ca" => Some(Encounter::ConjuredAmalgamate), + "twins" => Some(Encounter::TwinLargos), + "qadim" => Some(Encounter::Qadim), + "adina" => Some(Encounter::CardinalAdina), + "sabir" => Some(Encounter::CardinalSabir), + "qpeer" => Some(Encounter::QadimThePeerless), + // ambiguous, but it doesn't matter because we map them all to + // Category::SpecialForcesTrainingArea + "golem" => Some(Encounter::LargeKittyGolem), + "ai" => Some(Encounter::Ai), + "skor" => Some(Encounter::Skorvald), + "arriv" => Some(Encounter::Artsariiv), + "arkk" => Some(Encounter::Arkk), + "mama" => Some(Encounter::MAMA), + "siax" => Some(Encounter::Siax), + "enso" => Some(Encounter::Ensolyss), + "ice" => Some(Encounter::IcebroodConstruct), + "frae" => Some(Encounter::FraenirOfJormag), + "falln" => Some(Encounter::SuperKodanBrothers), + "whisp" => Some(Encounter::WhisperOfJormag), + "bone" => Some(Encounter::Boneskinner), + "trin" => Some(Encounter::CaptainMaiTrin), + "ankka" => Some(Encounter::Ankka), + "li" => Some(Encounter::MinisterLi), + "void" => Some(Encounter::Dragonvoid), + _ => None, + }; + + (encounter, Some((date, time)), caps.get(0).unwrap().as_str()) +} + +fn order_strikes(left: Encounter, right: Encounter) -> Option<Ordering> { + // Order according to the wiki at https://wiki.guildwars2.com/wiki/Strike_Mission + let strikes = &[ + Encounter::IcebroodConstruct, + Encounter::SuperKodanBrothers, + Encounter::FraenirOfJormag, + Encounter::Boneskinner, + Encounter::WhisperOfJormag, + Encounter::CaptainMaiTrin, + Encounter::Ankka, + Encounter::MinisterLi, + Encounter::Dragonvoid, + ]; + if let Some(pos_a) = strikes.iter().position(|x| *x == left) { + if let Some(pos_b) = strikes.iter().position(|x| *x == right) { + return Some(pos_a.cmp(&pos_b)); + } + } + None +} + #[cfg(test)] mod test { use super::*; @@ -137,16 +257,16 @@ mod test { #[test] fn insert() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); assert_eq!(logbag.categories().count(), 1); - logbag.insert("cat 1", "line 2".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!(logbag.categories().count(), 1); - logbag.insert("cat 2", "line 1".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); assert_eq!(logbag.categories().count(), 2); assert_eq!( logbag.categories().collect::<Vec<_>>(), - vec!["cat 1", "cat 2"] + vec![Category::WvW, Category::Strike] ); } @@ -158,13 +278,13 @@ mod test { #[test] fn parse_single() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); - logbag.insert("cat 1", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!( LogBag::parse_plain( "\ -cat 1 +World versus World line 1 line 2" ), @@ -175,19 +295,19 @@ line 2" #[test] fn parse_multi() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); - logbag.insert("cat 1", "line 2".to_string()); - logbag.insert("cat 2", "line 1".to_string()); - logbag.insert("cat 2", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); + logbag.insert(Category::Strike, "line 2".to_string()); assert_eq!( LogBag::parse_plain( "\ -cat 1 +World versus World line 1 line 2 -cat 2 +Strike Mission line 1 line 2" ), @@ -198,19 +318,19 @@ line 2" #[test] fn parse_markdown() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); - logbag.insert("cat 1", "line 2".to_string()); - logbag.insert("cat 2", "line 1".to_string()); - logbag.insert("cat 2", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); + logbag.insert(Category::Strike, "line 2".to_string()); assert_eq!( LogBag::parse_markdown( "\ -**cat 1** +**World versus World** line 1 line 2 -**cat 2** +**Strike Mission** line 1 line 2" ), @@ -221,13 +341,13 @@ line 2" #[test] fn render_plain_single() { let mut logbag = LogBag::new(); - logbag.insert("category", "line 1".to_string()); - logbag.insert("category", "line 2".to_string()); + logbag.insert(Category::Unknown, "line 1".to_string()); + logbag.insert(Category::Unknown, "line 2".to_string()); assert_eq!( logbag.render_plain(), "\ -category +Unknown line 1 line 2" ); @@ -236,19 +356,19 @@ line 2" #[test] fn render_plain_multi() { let mut logbag = LogBag::new(); - logbag.insert("category 1", "line 1".to_string()); - logbag.insert("category 1", "line 2".to_string()); - logbag.insert("category 2", "enil 1".to_string()); - logbag.insert("category 2", "enil 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 1".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 2".to_string()); assert_eq!( logbag.render_plain(), "\ -category 1 +World versus World line 1 line 2 -category 2 +Special Forces Training Area enil 1 enil 2" ); @@ -257,13 +377,13 @@ enil 2" #[test] fn render_html_single() { let mut logbag = LogBag::new(); - logbag.insert("category", "line 1".to_string()); - logbag.insert("category", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!( logbag.render_html(), "\ -<b>category</b><br> +<b>World versus World</b><br> line 1<br> line 2" ); @@ -272,19 +392,19 @@ line 2" #[test] fn render_html_multi() { let mut logbag = LogBag::new(); - logbag.insert("category 1", "line 1".to_string()); - logbag.insert("category 1", "line 2".to_string()); - logbag.insert("category 2", "enil 1".to_string()); - logbag.insert("category 2", "enil 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::Unknown, "enil 1".to_string()); + logbag.insert(Category::Unknown, "enil 2".to_string()); assert_eq!( logbag.render_html(), "\ -<b>category 1</b><br> +<b>World versus World</b><br> line 1<br> line 2<br> <br> -<b>category 2</b><br> +<b>Unknown</b><br> enil 1<br> enil 2" ); @@ -293,13 +413,13 @@ enil 2" #[test] fn render_markdown_single() { let mut logbag = LogBag::new(); - logbag.insert("category", "line 1".to_string()); - logbag.insert("category", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!( logbag.render_markdown(), "\ -**category** +**World versus World** line 1 line 2" ); @@ -308,21 +428,55 @@ line 2" #[test] fn render_markdown_multi() { let mut logbag = LogBag::new(); - logbag.insert("category 1", "line 1".to_string()); - logbag.insert("category 1", "line 2".to_string()); - logbag.insert("category 2", "enil 1".to_string()); - logbag.insert("category 2", "enil 2".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); + logbag.insert(Category::Strike, "line 2".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 1".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 2".to_string()); assert_eq!( logbag.render_markdown(), "\ -**category 1** +**Strike Mission** line 1 line 2 -**category 2** +**Special Forces Training Area** enil 1 enil 2" ); } + + #[test] + fn test_info_from_line() { + assert_eq!( + info_from_line("✅ https://dps.report/O514-20240827-214630_dhuum"), + (Some(Encounter::VoiceInTheVoid), Some((20240827, 214630)), "https://dps.report/O514-20240827-214630_dhuum") + ); + } + + #[test] + fn test_sort_logbag() { + let mut logbag = LogBag::new(); + logbag.insert(Category::Wing2, String::from("https://dps.report/abcd-20240101-120000_matt")); + logbag.insert(Category::Wing2, String::from("https://dps.report/abcd-20240101-115500_matt")); + logbag.insert(Category::Wing2, String::from("https://dps.report/abcd-20240101-130000_sloth")); + logbag.insert(Category::Wing1, String::from("https://dps.report/abcd-20240101-120000_gors")); + logbag.insert(Category::Wing1, String::from("https://dps.report/abcd-20240101-120000_vg")); + logbag.insert(Category::Wing1, String::from("https://dps.report/abcd-20240101-120000_sab")); + + logbag.sort(); + + assert_eq!(logbag.data, vec![ + (Category::Wing1, vec![ + String::from("https://dps.report/abcd-20240101-120000_vg"), + String::from("https://dps.report/abcd-20240101-120000_gors"), + String::from("https://dps.report/abcd-20240101-120000_sab"), + ]), + (Category::Wing2, vec![ + String::from("https://dps.report/abcd-20240101-130000_sloth"), + String::from("https://dps.report/abcd-20240101-115500_matt"), + String::from("https://dps.report/abcd-20240101-120000_matt"), + ]), + ]); + } } diff --git a/src/matrix.rs b/src/matrix.rs index 8be0e38..7835ddd 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -377,7 +377,7 @@ async fn find_message( /// Post a new message to the given Matrix channel. async fn post_new(client: &Client, room_id: OwnedRoomId, log: &Log, link: &str) -> Result<()> { let line = format!("{} {}", state_emoji(log), link); - let logbag: LogBag = vec![(log.category().to_string(), vec![line])].into(); + let logbag: LogBag = vec![(log.category(), vec![line])].into(); let body = logbag.render_plain(); let html = logbag.render_html(); |