From 0978345648cf9cdad6222f583dd21497b409d07e Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 28 Jun 2020 17:22:43 +0200 Subject: start implementing analyzers It turns out that the different encounters do require quite some encounter-specific logic, not only to determine whether the CM was activated, but also to determine whether the fight was successful, the duration of the fight, later the phases, ... Wrapping all of this in pre-defined "triggers" (like CmTrigger) feels like it will be a bit unfitting, so with this patch we have introduced the evtclib::Analyzer, which can be used to analyze the fights. Currently, the whole CM detection logic has been moved to this new interface, and soon we also want the success-detection logic in there. The tests pass and the interface of Log::is_cm is unchanged. --- src/analyzers/fractals.rs | 54 +++++++++++++++++++++ src/analyzers/helpers.rs | 70 +++++++++++++++++++++++++++ src/analyzers/mod.rs | 65 +++++++++++++++++++++++++ src/analyzers/raids/mod.rs | 11 +++++ src/analyzers/raids/w4.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++ src/analyzers/raids/w5.rs | 62 ++++++++++++++++++++++++ src/analyzers/raids/w6.rs | 87 ++++++++++++++++++++++++++++++++++ src/analyzers/raids/w7.rs | 86 +++++++++++++++++++++++++++++++++ src/gamedata.rs | 59 ----------------------- src/lib.rs | 83 ++++---------------------------- 10 files changed, 560 insertions(+), 133 deletions(-) create mode 100644 src/analyzers/fractals.rs create mode 100644 src/analyzers/helpers.rs create mode 100644 src/analyzers/mod.rs create mode 100644 src/analyzers/raids/mod.rs create mode 100644 src/analyzers/raids/w4.rs create mode 100644 src/analyzers/raids/w5.rs create mode 100644 src/analyzers/raids/w6.rs create mode 100644 src/analyzers/raids/w7.rs diff --git a/src/analyzers/fractals.rs b/src/analyzers/fractals.rs new file mode 100644 index 0000000..dd010ac --- /dev/null +++ b/src/analyzers/fractals.rs @@ -0,0 +1,54 @@ +//! Analyzers for (challenge mote) fractal encounters. +use crate::{ + analyzers::{helpers, Analyzer}, + Log, +}; + +pub const SKORVALD_CM_HEALTH: u64 = 5_551_340; + +/// Analyzer for the first boss of 100 CM, Skorvald. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct Skorvald<'log> { + log: &'log Log, +} + +impl<'log> Skorvald<'log> { + pub fn new(log: &'log Log) -> Self { + Skorvald { log } + } +} + +impl<'log> Analyzer for Skorvald<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= SKORVALD_CM_HEALTH) + .unwrap_or(false) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct GenericFractal<'log> { + log: &'log Log, +} + +impl<'log> GenericFractal<'log> { + pub fn new(log: &'log Log) -> Self { + GenericFractal { log } + } +} + +impl<'log> Analyzer for GenericFractal<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + true + } +} diff --git a/src/analyzers/helpers.rs b/src/analyzers/helpers.rs new file mode 100644 index 0000000..ec09355 --- /dev/null +++ b/src/analyzers/helpers.rs @@ -0,0 +1,70 @@ +//! This module contains helper methods that are used in different analyzers. +use std::collections::HashMap; + +use crate::{EventKind, Log}; + +/// Returns the maximum health of the boss agent. +/// +/// If the health cannot be determined, this function returns `None`. +/// +/// The boss agent is determined by using [`Log::is_boss`][Log::is_boss]. +pub fn boss_health(log: &Log) -> Option { + let mut health: Option = None; + for event in log.events() { + if let EventKind::MaxHealthUpdate { + agent_addr, + max_health, + } = *event.kind() + { + if log.is_boss(agent_addr) { + health = health.map(|h| h.max(max_health)).or(Some(max_health)); + } + } + } + health +} + +/// Checks if the given buff is present in the log. +pub fn buff_present(log: &Log, wanted_buff_id: u32) -> bool { + for event in log.events() { + if let EventKind::BuffApplication { buff_id, .. } = *event.kind() { + if buff_id == wanted_buff_id { + return true; + } + } + } + false +} + +/// Returns the (minimum) time between applications of the given buff in milliseconds. +pub fn time_between_buffs(log: &Log, wanted_buff_id: u32) -> u64 { + let mut time_maps: HashMap> = HashMap::new(); + for event in log.events() { + if let EventKind::BuffApplication { + destination_agent_addr, + buff_id, + .. + } = event.kind() + { + if *buff_id == wanted_buff_id { + time_maps + .entry(*destination_agent_addr) + .or_default() + .push(event.time()); + } + } + } + let timestamps = if let Some(ts) = time_maps.values().max_by_key(|v| v.len()) { + ts + } else { + return 0; + }; + timestamps + .iter() + .zip(timestamps.iter().skip(1)) + .map(|(a, b)| b - a) + // Arbitrary limit to filter out duplicated buff application events + .filter(|x| *x > 50) + .min() + .unwrap_or(0) +} diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs new file mode 100644 index 0000000..5ad88ec --- /dev/null +++ b/src/analyzers/mod.rs @@ -0,0 +1,65 @@ +//! Traits and structures to analyze fights. +//! +//! Fights need different logic to determine some data, for example each fight has a different way +//! to determine whether or not the Challenge Mote was activated, whether or not the fight was +//! successful, ... +//! +//! This module aims to unify that logic by providing a trait [`Analyzer`][Analyzer], which +//! provides a unified interface to query this information. You can use +//! [`Log::analyzer`][Log::analyzer] or [`for_log`][for_log] to obtain an analyzer fitting for the +//! encounter that is represented by the log. +//! +//! The implementation of the different analyzers is split off in different submodules: +//! * [`raids`][raids] for the raid-related encounters. +//! +//! Note that you should not create concrete analyzers on your own, as the behaviour is not +//! specified when you use a wrong analyzer for the given log. Rely only on +//! [`Log::analyzer`][Log::analyzer] (or [`for_log`][for_log]) and the methods defined in +//! [`Analyzer`][Analyzer]. + +use crate::{Boss, Log}; + +pub mod fractals; +pub mod helpers; +pub mod raids; + +/// An [`Analyzer`][Analyzer] is something that implements fight-dependent analyzing of the log. +pub trait Analyzer { + /// Returns a reference to the log being analyzed. + fn log(&self) -> &Log; + + /// Checks whether the fight was done with the challenge mote activated. + fn is_cm(&self) -> bool; +} + +/// Returns the correct [`Analyzer`][Analyzer] for the given log file. +/// +/// See also [`Log::analyzer`][Log::analyzer]. +pub fn for_log<'l>(log: &'l Log) -> Option> { + let boss = log.encounter()?; + + match boss { + Boss::Cairn => Some(Box::new(raids::Cairn::new(log))), + Boss::MursaatOverseer => Some(Box::new(raids::MursaatOverseer::new(log))), + Boss::Samarog => Some(Box::new(raids::Samarog::new(log))), + Boss::Deimos => Some(Box::new(raids::Deimos::new(log))), + + Boss::SoullessHorror => Some(Box::new(raids::SoullessHorror::new(log))), + Boss::Dhuum => Some(Box::new(raids::Dhuum::new(log))), + + Boss::ConjuredAmalgamate => Some(Box::new(raids::ConjuredAmalgamate::new(log))), + Boss::LargosTwins => Some(Box::new(raids::LargosTwins::new(log))), + Boss::Qadim => Some(Box::new(raids::Qadim::new(log))), + + Boss::CardinalAdina => Some(Box::new(raids::CardinalAdina::new(log))), + Boss::CardinalSabir => Some(Box::new(raids::CardinalSabir::new(log))), + Boss::QadimThePeerless => Some(Box::new(raids::QadimThePeerless::new(log))), + + Boss::Skorvald => Some(Box::new(fractals::Skorvald::new(log))), + Boss::Artsariiv | Boss::Arkk | Boss::MAMA | Boss::Siax | Boss::Ensolyss => { + Some(Box::new(fractals::GenericFractal::new(log))) + } + + _ => None, + } +} diff --git a/src/analyzers/raids/mod.rs b/src/analyzers/raids/mod.rs new file mode 100644 index 0000000..91b0dba --- /dev/null +++ b/src/analyzers/raids/mod.rs @@ -0,0 +1,11 @@ +mod w4; +pub use w4::{Cairn, Deimos, MursaatOverseer, Samarog}; + +mod w5; +pub use w5::{Dhuum, SoullessHorror}; + +mod w6; +pub use w6::{ConjuredAmalgamate, LargosTwins, Qadim}; + +mod w7; +pub use w7::{CardinalAdina, CardinalSabir, QadimThePeerless}; diff --git a/src/analyzers/raids/w4.rs b/src/analyzers/raids/w4.rs new file mode 100644 index 0000000..efdab8f --- /dev/null +++ b/src/analyzers/raids/w4.rs @@ -0,0 +1,116 @@ +//! Boss fight analyzers for Wing 4 (Bastion of the Penitent). +use crate::{ + analyzers::{helpers, Analyzer}, + Log, +}; + +pub const CAIRN_CM_BUFF: u32 = 38_098; + +/// Analyzer for the first fight of Wing 4, Cairn. +/// +/// The CM is detected by the presence of the buff representing the countdown before which you have +/// to use your special action skill. +#[derive(Debug, Clone, Copy)] +pub struct Cairn<'log> { + log: &'log Log, +} + +impl<'log> Cairn<'log> { + pub fn new(log: &'log Log) -> Self { + Cairn { log } + } +} + +impl<'log> Analyzer for Cairn<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::buff_present(self.log, CAIRN_CM_BUFF) + } +} + +pub const MO_CM_HEALTH: u64 = 30_000_000; + +/// Analyzer for the second fight of Wing 4, Mursaat Overseer. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct MursaatOverseer<'log> { + log: &'log Log, +} + +impl<'log> MursaatOverseer<'log> { + pub fn new(log: &'log Log) -> Self { + MursaatOverseer { log } + } +} + +impl<'log> Analyzer for MursaatOverseer<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= MO_CM_HEALTH) + .unwrap_or(false) + } +} + +pub const SAMAROG_CM_HEALTH: u64 = 40_000_000; + +/// Analyzer for the third fight of Wing 4, Samarog. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct Samarog<'log> { + log: &'log Log, +} + +impl<'log> Samarog<'log> { + pub fn new(log: &'log Log) -> Self { + Samarog { log } + } +} + +impl<'log> Analyzer for Samarog<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= SAMAROG_CM_HEALTH) + .unwrap_or(false) + } +} + +pub const DEIMOS_CM_HEALTH: u64 = 42_000_000; + +/// Analyzer for the fourth fight of Wing 4, Deimos. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct Deimos<'log> { + log: &'log Log, +} + +impl<'log> Deimos<'log> { + pub fn new(log: &'log Log) -> Self { + Deimos { log } + } +} + +impl<'log> Analyzer for Deimos<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= DEIMOS_CM_HEALTH) + .unwrap_or(false) + } +} diff --git a/src/analyzers/raids/w5.rs b/src/analyzers/raids/w5.rs new file mode 100644 index 0000000..b8c3f3c --- /dev/null +++ b/src/analyzers/raids/w5.rs @@ -0,0 +1,62 @@ +//! Boss fight analyzers for Wing 5 (Hall of Chains) +use crate::{ + analyzers::{helpers, Analyzer}, + Log, +}; + +pub const DESMINA_BUFF_ID: u32 = 47414; +pub const DESMINA_MS_THRESHOLD: u64 = 11_000; + +/// Analyzer for the first fight of Wing 5, Soulless Horror (aka. Desmina). +/// +/// The CM is detected by the time between applications of the Necrosis debuff, which is applied at +/// a faster rate when the challenge mote is active. +#[derive(Debug, Clone, Copy)] +pub struct SoullessHorror<'log> { + log: &'log Log, +} + +impl<'log> SoullessHorror<'log> { + pub fn new(log: &'log Log) -> Self { + SoullessHorror { log } + } +} + +impl<'log> Analyzer for SoullessHorror<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + let tbb = helpers::time_between_buffs(self.log, DESMINA_BUFF_ID); + tbb > 0 && tbb <= DESMINA_MS_THRESHOLD + } +} + +pub const DHUUM_CM_HEALTH: u64 = 40_000_000; + +/// Analyzer for the second fight of Wing 5, Dhuum. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct Dhuum<'log> { + log: &'log Log, +} + +impl<'log> Dhuum<'log> { + pub fn new(log: &'log Log) -> Self { + Dhuum { log } + } +} + +impl<'log> Analyzer for Dhuum<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= DHUUM_CM_HEALTH) + .unwrap_or(false) + } +} diff --git a/src/analyzers/raids/w6.rs b/src/analyzers/raids/w6.rs new file mode 100644 index 0000000..c4e5b1a --- /dev/null +++ b/src/analyzers/raids/w6.rs @@ -0,0 +1,87 @@ +//! Boss fight analyzers for Wing 6 (Mythwright Gambit) +use crate::{ + analyzers::{helpers, Analyzer}, + Log, +}; + +pub const CA_CM_BUFF: u32 = 53_075; + +/// Analyzer for the first fight of Wing 6, Conjured Amalgamate. +/// +/// The CM is detected by the presence of the buff that the player targeted by the laser has. +#[derive(Debug, Clone, Copy)] +pub struct ConjuredAmalgamate<'log> { + log: &'log Log, +} + +impl<'log> ConjuredAmalgamate<'log> { + pub fn new(log: &'log Log) -> Self { + ConjuredAmalgamate { log } + } +} + +impl<'log> Analyzer for ConjuredAmalgamate<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::buff_present(self.log, CA_CM_BUFF) + } +} + +pub const LARGOS_CM_HEALTH: u64 = 19_200_000; + +/// Analyzer for the second fight of Wing 6, Largos Twins. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct LargosTwins<'log> { + log: &'log Log, +} + +impl<'log> LargosTwins<'log> { + pub fn new(log: &'log Log) -> Self { + LargosTwins { log } + } +} + +impl<'log> Analyzer for LargosTwins<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= LARGOS_CM_HEALTH) + .unwrap_or(false) + } +} + +pub const QADIM_CM_HEALTH: u64 = 21_100_000; + +/// Analyzer for the third fight of Wing 6, Qadim. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct Qadim<'log> { + log: &'log Log, +} + +impl<'log> Qadim<'log> { + pub fn new(log: &'log Log) -> Self { + Qadim { log } + } +} + +impl<'log> Analyzer for Qadim<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= QADIM_CM_HEALTH) + .unwrap_or(false) + } +} diff --git a/src/analyzers/raids/w7.rs b/src/analyzers/raids/w7.rs new file mode 100644 index 0000000..a8319a3 --- /dev/null +++ b/src/analyzers/raids/w7.rs @@ -0,0 +1,86 @@ +//! Boss fight analyzers for Wing 6 (Mythwright Gambit) +use crate::{ + analyzers::{helpers, Analyzer}, + Log, +}; + +pub const ADINA_CM_HEALTH: u64 = 24_800_000; + +/// Analyzer for the first fight of Wing 7, Cardinal Adina. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct CardinalAdina<'log> { + log: &'log Log, +} + +impl<'log> CardinalAdina<'log> { + pub fn new(log: &'log Log) -> Self { + CardinalAdina { log } + } +} + +impl<'log> Analyzer for CardinalAdina<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= ADINA_CM_HEALTH) + .unwrap_or(false) + } +} + +pub const SABIR_CM_HEALTH: u64 = 32_400_000; + +/// Analyzer for the second fight of Wing 7, Cardinal Sabir. +/// +/// The CM is detected by the boss's health, which is higher in the challenge mote. +#[derive(Debug, Clone, Copy)] +pub struct CardinalSabir<'log> { + log: &'log Log, +} + +impl<'log> CardinalSabir<'log> { + pub fn new(log: &'log Log) -> Self { + CardinalSabir { log } + } +} + +impl<'log> Analyzer for CardinalSabir<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= SABIR_CM_HEALTH) + .unwrap_or(false) + } +} + +pub const QADIMP_CM_HEALTH: u64 = 51_000_000; + +#[derive(Debug, Clone, Copy)] +pub struct QadimThePeerless<'log> { + log: &'log Log, +} + +impl<'log> QadimThePeerless<'log> { + pub fn new(log: &'log Log) -> Self { + QadimThePeerless { log } + } +} + +impl<'log> Analyzer for QadimThePeerless<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + helpers::boss_health(self.log) + .map(|h| h >= QADIMP_CM_HEALTH) + .unwrap_or(false) + } +} diff --git a/src/gamedata.rs b/src/gamedata.rs index 30bbcf6..dd11e94 100644 --- a/src/gamedata.rs +++ b/src/gamedata.rs @@ -65,42 +65,6 @@ pub enum Boss { WhisperOfJormag = 0x58B7, } -impl Boss { - /// Returns the CM trigger for this boss. - pub fn cm_trigger(self) -> CmTrigger { - match self { - Boss::KeepConstruct => CmTrigger::Unknown, - - Boss::Cairn => CmTrigger::BuffPresent(38_098), - Boss::MursaatOverseer => CmTrigger::HpThreshold(30_000_000), - Boss::Samarog => CmTrigger::HpThreshold(40_000_000), - Boss::Deimos => CmTrigger::HpThreshold(42_000_000), - - Boss::SoullessHorror => CmTrigger::TimeBetweenBuffs(47414, 11_000), - Boss::Dhuum => CmTrigger::HpThreshold(40_000_000), - - Boss::ConjuredAmalgamate => CmTrigger::BuffPresent(53_075), - // This is Nikare's health, as the log is saved with his ID - Boss::LargosTwins => CmTrigger::HpThreshold(19_200_000), - Boss::Qadim => CmTrigger::HpThreshold(21_100_000), - - Boss::CardinalAdina => CmTrigger::HpThreshold(24_800_000), - Boss::CardinalSabir => CmTrigger::HpThreshold(32_400_000), - Boss::QadimThePeerless => CmTrigger::HpThreshold(51_000_000), - - Boss::Skorvald => CmTrigger::HpThreshold(5_551_340), - Boss::Artsariiv => CmTrigger::Always, - Boss::Arkk => CmTrigger::Always, - - Boss::MAMA => CmTrigger::Always, - Boss::Siax => CmTrigger::Always, - Boss::Ensolyss => CmTrigger::Always, - - _ => CmTrigger::None, - } - } -} - /// Error for when converting a string to the boss fails. #[derive(Debug, Clone, Hash, PartialEq, Eq, Error)] #[error("Invalid boss identifier: {0}")] @@ -203,29 +167,6 @@ impl Display for Boss { /// into account. pub const XERA_PHASE2_ID: u16 = 0x3F9E; -/// The trigger of how a boss challenge mote (CM) is determined. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CmTrigger { - /// The boss does not have a CM available. - None, - /// The boss has a CM available but we cannot determine if it has been activated. - Unknown, - /// Logs from this boss always count as having the CM active. - Always, - /// The CM is determined by the boss's health being at or above the given threshold. - /// - /// This works since most bosses increase their HP pool in the CM variant. - HpThreshold(u32), - /// The CM is active if the given buff is present in the log. - /// - /// The buff can be either on player or the enemy. - BuffPresent(u32), - /// The time between buff applications falls below the given threshold. - /// - /// The first number is the buff id, the second number is the time threshold in milliseconds. - TimeBetweenBuffs(u32, u64), -} - /// Error for when converting a string to a profession fails. #[derive(Debug, Clone, PartialEq, Eq, Hash, Error)] #[error("Invalid profession identifier: {0}")] diff --git a/src/lib.rs b/src/lib.rs index 519d1c4..a0bb741 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,7 +88,6 @@ //! While there are legitimate use cases for writing/modification support, they are currently not //! implemented (but might be in a future version). -use std::collections::HashMap; use std::convert::TryFrom; use std::marker::PhantomData; @@ -105,9 +104,11 @@ mod processing; pub use processing::{process, process_file, process_stream, Compression}; pub mod gamedata; -use gamedata::CmTrigger; pub use gamedata::{Boss, EliteSpec, Profession}; +pub mod analyzers; +pub use analyzers::Analyzer; + /// Any error that can occur during the processing of evtc files. #[derive(Error, Debug)] pub enum EvtcError { @@ -789,6 +790,11 @@ impl Log { Boss::from_u16(self.boss_id) } + /// Return an analyzer suitable to analyze the given log. + pub fn analyzer<'s>(&'s self) -> Option> { + analyzers::for_log(&self) + } + /// Return all events present in this log. #[inline] pub fn events(&self) -> &[Event] { @@ -832,46 +838,7 @@ impl Log { /// * We cannot determine whether the CM was active /// * The boss is not known pub fn is_cm(&self) -> bool { - let trigger = self - .encounter() - .map(Boss::cm_trigger) - .unwrap_or(CmTrigger::Unknown); - match trigger { - CmTrigger::HpThreshold(hp_threshold) => { - for event in self.events() { - if let EventKind::MaxHealthUpdate { - agent_addr, - max_health, - } = *event.kind() - { - if self.is_boss(agent_addr) && max_health >= hp_threshold as u64 { - return true; - } - } - } - false - } - - CmTrigger::BuffPresent(wanted_buff_id) => { - for event in self.events() { - if let EventKind::BuffApplication { buff_id, .. } = *event.kind() { - if buff_id == wanted_buff_id { - return true; - } - } - } - false - } - - CmTrigger::TimeBetweenBuffs(buff_id, threshold) => { - let tbb = time_between_buffs(&self.events, buff_id); - tbb != 0 && tbb <= threshold - } - - CmTrigger::Always => true, - - CmTrigger::None | CmTrigger::Unknown => false, - } + self.analyzer().map(|a| a.is_cm()).unwrap_or(false) } /// Get the timestamp of when the log was started. @@ -925,35 +892,3 @@ impl Log { }) } } - -fn time_between_buffs(events: &[Event], wanted_buff_id: u32) -> u64 { - let mut time_maps: HashMap> = HashMap::new(); - for event in events { - if let EventKind::BuffApplication { - destination_agent_addr, - buff_id, - .. - } = event.kind() - { - if *buff_id == wanted_buff_id { - time_maps - .entry(*destination_agent_addr) - .or_default() - .push(event.time()); - } - } - } - let timestamps = if let Some(ts) = time_maps.values().max_by_key(|v| v.len()) { - ts - } else { - return 0; - }; - timestamps - .iter() - .zip(timestamps.iter().skip(1)) - .map(|(a, b)| b - a) - // Arbitrary limit to filter out duplicated buff application events - .filter(|x| *x > 50) - .min() - .unwrap_or(0) -} -- cgit v1.2.3 From 962e2b9f8e17a50c7d7d37a424591b0df62f265c Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 02:47:52 +0200 Subject: implement proper outcome for w1-w4 It turns out that `was_rewarded` is a pretty bad heuristic if you ever kill a boss a second time per week (basically, was_rewarded=false does not imply that the boss was unsuccessful). Therefore, we need a proper detection of when a fight failed and when a fight succeeded. This is the first batch that implements this as part of the Analyzer trait for bosses of wings 1 to 4. --- src/analyzers/helpers.rs | 45 +++++++++++++++++++- src/analyzers/mod.rs | 44 +++++++++++++++++++ src/analyzers/raids/mod.rs | 34 +++++++++++++++ src/analyzers/raids/w3.rs | 30 +++++++++++++ src/analyzers/raids/w4.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 src/analyzers/raids/w3.rs diff --git a/src/analyzers/helpers.rs b/src/analyzers/helpers.rs index ec09355..674d752 100644 --- a/src/analyzers/helpers.rs +++ b/src/analyzers/helpers.rs @@ -1,7 +1,7 @@ //! This module contains helper methods that are used in different analyzers. use std::collections::HashMap; -use crate::{EventKind, Log}; +use crate::{AgentKind, EventKind, Log}; /// Returns the maximum health of the boss agent. /// @@ -24,6 +24,49 @@ pub fn boss_health(log: &Log) -> Option { health } +/// Checks if any of the boss NPCs have died. +/// +/// Death is determined by checking for the [`EventKind::ChangeDead`][EventKind::ChangeDead] event, +/// and whether a NPC is a boss is determined by the [`Log::is_boss`][Log::is_boss] method. +pub fn boss_is_dead(log: &Log) -> bool { + log.events().iter().any(|ev| match ev.kind() { + EventKind::ChangeDead { agent_addr } if log.is_boss(*agent_addr) => true, + _ => false, + }) +} + +/// Checks whether the players exit combat after the boss. +/// +/// This is useful to determine the success state of some fights. +pub fn players_exit_after_boss(log: &Log) -> bool { + let mut player_exit = 0u64; + let mut boss_exit = 0u64; + + for event in log.events() { + if let EventKind::ExitCombat { agent_addr } = event.kind() { + let agent = if let Some(a) = log.agent_by_addr(*agent_addr) { + a + } else { + continue; + }; + + match agent.kind() { + AgentKind::Player(_) if event.time() >= player_exit => { + player_exit = event.time(); + } + AgentKind::Character(_) + if event.time() >= boss_exit && log.is_boss(*agent_addr) => + { + boss_exit = event.time(); + } + _ => (), + } + } + } + // Safety margin + boss_exit != 0 && player_exit > boss_exit + 1000 +} + /// Checks if the given buff is present in the log. pub fn buff_present(log: &Log, wanted_buff_id: u32) -> bool { for event in log.events() { diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs index 5ad88ec..c440136 100644 --- a/src/analyzers/mod.rs +++ b/src/analyzers/mod.rs @@ -23,6 +23,33 @@ pub mod fractals; pub mod helpers; pub mod raids; +/// The outcome of a fight. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum Outcome { + /// The fight succeeded. + Success, + /// The fight failed, i.e. the group wiped. + Failure, +} + +impl Outcome { + /// A function that turns a boolean into an [`Outcome`][Outcome]. + /// + /// This is a convenience function that can help implementing + /// [`Analyzer::outcome`][Analyzer::outcome], which is also why this function returns an Option + /// instead of the outcome directly. + /// + /// This turns `true` into [`Outcome::Success`][Outcome::Success] and `false` into + /// [`Outcome::Failure`][Outcome::Failure]. + pub fn from_bool(b: bool) -> Option { + if b { + Some(Outcome::Success) + } else { + Some(Outcome::Failure) + } + } +} + /// An [`Analyzer`][Analyzer] is something that implements fight-dependent analyzing of the log. pub trait Analyzer { /// Returns a reference to the log being analyzed. @@ -30,6 +57,14 @@ pub trait Analyzer { /// Checks whether the fight was done with the challenge mote activated. fn is_cm(&self) -> bool; + + /// Returns the outcome of the fight. + /// + /// Note that not all logs need to have an outcome, e.g. WvW or Golem logs may return `None` + /// here. + fn outcome(&self) -> Option { + None + } } /// Returns the correct [`Analyzer`][Analyzer] for the given log file. @@ -39,6 +74,15 @@ pub fn for_log<'l>(log: &'l Log) -> Option> { let boss = log.encounter()?; match boss { + Boss::ValeGuardian | Boss::Gorseval | Boss::Sabetha => { + Some(Box::new(raids::GenericRaid::new(log))) + } + + Boss::Slothasor | Boss::Matthias => Some(Box::new(raids::GenericRaid::new(log))), + + Boss::KeepConstruct => Some(Box::new(raids::GenericRaid::new(log))), + Boss::Xera => Some(Box::new(raids::Xera::new(log))), + Boss::Cairn => Some(Box::new(raids::Cairn::new(log))), Boss::MursaatOverseer => Some(Box::new(raids::MursaatOverseer::new(log))), Boss::Samarog => Some(Box::new(raids::Samarog::new(log))), diff --git a/src/analyzers/raids/mod.rs b/src/analyzers/raids/mod.rs index 91b0dba..33d54ce 100644 --- a/src/analyzers/raids/mod.rs +++ b/src/analyzers/raids/mod.rs @@ -1,3 +1,11 @@ +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + Log, +}; + +mod w3; +pub use w3::Xera; + mod w4; pub use w4::{Cairn, Deimos, MursaatOverseer, Samarog}; @@ -9,3 +17,29 @@ pub use w6::{ConjuredAmalgamate, LargosTwins, Qadim}; mod w7; pub use w7::{CardinalAdina, CardinalSabir, QadimThePeerless}; + +/// A generic raid analyzer that works for bosses without special interactions. +#[derive(Debug, Clone, Copy)] +pub struct GenericRaid<'log> { + log: &'log Log, +} + +impl<'log> GenericRaid<'log> { + pub fn new(log: &'log Log) -> Self { + GenericRaid { log } + } +} + +impl<'log> Analyzer for GenericRaid<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + false + } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} diff --git a/src/analyzers/raids/w3.rs b/src/analyzers/raids/w3.rs new file mode 100644 index 0000000..82c007d --- /dev/null +++ b/src/analyzers/raids/w3.rs @@ -0,0 +1,30 @@ +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + Log, +}; + +/// Analyzer for the final fight of Wing 3, Xera. +#[derive(Debug, Clone, Copy)] +pub struct Xera<'log> { + log: &'log Log, +} + +impl<'log> Xera<'log> { + pub fn new(log: &'log Log) -> Self { + Xera { log } + } +} + +impl<'log> Analyzer for Xera<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + false + } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::players_exit_after_boss(self.log)) + } +} diff --git a/src/analyzers/raids/w4.rs b/src/analyzers/raids/w4.rs index efdab8f..e753e49 100644 --- a/src/analyzers/raids/w4.rs +++ b/src/analyzers/raids/w4.rs @@ -1,7 +1,7 @@ //! Boss fight analyzers for Wing 4 (Bastion of the Penitent). use crate::{ - analyzers::{helpers, Analyzer}, - Log, + analyzers::{helpers, Analyzer, Outcome}, + EventKind, Log, }; pub const CAIRN_CM_BUFF: u32 = 38_098; @@ -29,6 +29,10 @@ impl<'log> Analyzer for Cairn<'log> { fn is_cm(&self) -> bool { helpers::buff_present(self.log, CAIRN_CM_BUFF) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } pub const MO_CM_HEALTH: u64 = 30_000_000; @@ -57,6 +61,10 @@ impl<'log> Analyzer for MursaatOverseer<'log> { .map(|h| h >= MO_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } pub const SAMAROG_CM_HEALTH: u64 = 40_000_000; @@ -85,6 +93,10 @@ impl<'log> Analyzer for Samarog<'log> { .map(|h| h >= SAMAROG_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } pub const DEIMOS_CM_HEALTH: u64 = 42_000_000; @@ -113,4 +125,90 @@ impl<'log> Analyzer for Deimos<'log> { .map(|h| h >= DEIMOS_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + // The idea for Deimos is that we first need to figure out when the 10% split happens (if + // it even happens), then we can find the time when 10%-Deimos becomes untargetable and + // then we can compare this time to the player exit time. + + let split_time = deimos_10_time(self.log); + // We never got to 10%, so this is a fail. + if split_time == 0 { + return Some(Outcome::Failure); + } + + let at_address = deimos_at_address(self.log); + if at_address == 0 { + return Some(Outcome::Failure); + } + + let mut player_exit = 0u64; + let mut at_exit = 0u64; + for event in self.log.events() { + match event.kind() { + EventKind::ExitCombat { agent_addr } + if self + .log + .agent_by_addr(*agent_addr) + .map(|a| a.kind().is_player()) + .unwrap_or(false) + && event.time() >= player_exit => + { + player_exit = event.time(); + } + + EventKind::Targetable { + agent_addr, + targetable, + } if *agent_addr == at_address && !targetable && event.time() >= at_exit => { + at_exit = event.time(); + } + + _ => (), + } + } + + // Safety margin + Outcome::from_bool(player_exit > at_exit + 1000) + } +} + +// Extracts the timestamp when Deimos's 10% phase started. +// +// This function may panic when passed non-Deimos logs! +fn deimos_10_time(log: &Log) -> u64 { + let mut first_aware = 0u64; + + for event in log.events() { + if let EventKind::Targetable { targetable, .. } = event.kind() { + if *targetable { + first_aware = event.time(); + println!("First aware: {}", first_aware); + } + } + } + + first_aware +} + +// Returns the attack target address for the 10% Deimos phase. +// +// Returns 0 when the right attack target is not found. +fn deimos_at_address(log: &Log) -> u64 { + for event in log.events().iter().rev() { + if let EventKind::AttackTarget { + agent_addr, + parent_agent_addr, + .. + } = event.kind() + { + let parent = log.agent_by_addr(*parent_agent_addr); + if let Some(parent) = parent { + if Some("Deimos") == parent.as_gadget().map(|g| g.name()) { + return *agent_addr; + } + } + } + } + 0 } -- cgit v1.2.3 From 4c02181067e789e41eb95c6f6e954e4de6277dc1 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 03:00:53 +0200 Subject: implement Analyzer::outcome for wing 5 --- src/analyzers/raids/w5.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/analyzers/raids/w5.rs b/src/analyzers/raids/w5.rs index b8c3f3c..b9668b7 100644 --- a/src/analyzers/raids/w5.rs +++ b/src/analyzers/raids/w5.rs @@ -1,11 +1,12 @@ //! Boss fight analyzers for Wing 5 (Hall of Chains) use crate::{ - analyzers::{helpers, Analyzer}, - Log, + analyzers::{helpers, Analyzer, Outcome}, + EventKind, Log, }; pub const DESMINA_BUFF_ID: u32 = 47414; pub const DESMINA_MS_THRESHOLD: u64 = 11_000; +pub const DESMINA_DEATH_BUFF: u32 = 895; /// Analyzer for the first fight of Wing 5, Soulless Horror (aka. Desmina). /// @@ -31,6 +32,21 @@ impl<'log> Analyzer for SoullessHorror<'log> { let tbb = helpers::time_between_buffs(self.log, DESMINA_BUFF_ID); tbb > 0 && tbb <= DESMINA_MS_THRESHOLD } + + fn outcome(&self) -> Option { + Outcome::from_bool(self.log.events().iter().any(|event| { + if let EventKind::BuffApplication { + buff_id, + destination_agent_addr, + .. + } = event.kind() + { + self.log.is_boss(*destination_agent_addr) && *buff_id == DESMINA_DEATH_BUFF + } else { + false + } + })) + } } pub const DHUUM_CM_HEALTH: u64 = 40_000_000; @@ -59,4 +75,8 @@ impl<'log> Analyzer for Dhuum<'log> { .map(|h| h >= DHUUM_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } -- cgit v1.2.3 From dcf1b948b953fb17db16eafcdd30f0a25301171f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:18:56 +0200 Subject: implement Analyzer::outcome for wing 6 --- src/analyzers/raids/w6.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++-- src/gamedata.rs | 9 ++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/analyzers/raids/w6.rs b/src/analyzers/raids/w6.rs index c4e5b1a..cc39403 100644 --- a/src/analyzers/raids/w6.rs +++ b/src/analyzers/raids/w6.rs @@ -1,10 +1,12 @@ //! Boss fight analyzers for Wing 6 (Mythwright Gambit) use crate::{ - analyzers::{helpers, Analyzer}, - Log, + analyzers::{helpers, Analyzer, Outcome}, + gamedata::{KENUT_ID, NIKARE_ID}, + EventKind, Log, }; pub const CA_CM_BUFF: u32 = 53_075; +pub const ZOMMOROS_ID: u16 = 21_118; /// Analyzer for the first fight of Wing 6, Conjured Amalgamate. /// @@ -28,6 +30,23 @@ impl<'log> Analyzer for ConjuredAmalgamate<'log> { fn is_cm(&self) -> bool { helpers::buff_present(self.log, CA_CM_BUFF) } + + fn outcome(&self) -> Option { + for event in self.log.events() { + if let EventKind::Spawn { agent_addr } = event.kind() { + if self + .log + .agent_by_addr(*agent_addr) + .and_then(|a| a.as_character()) + .map(|a| a.id() == ZOMMOROS_ID) + .unwrap_or(false) + { + return Some(Outcome::Success); + } + } + } + Some(Outcome::Failure) + } } pub const LARGOS_CM_HEALTH: u64 = 19_200_000; @@ -56,6 +75,33 @@ impl<'log> Analyzer for LargosTwins<'log> { .map(|h| h >= LARGOS_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + let mut nikare_dead = false; + let mut kenut_dead = false; + + for event in self.log.events() { + if let EventKind::ChangeDead { agent_addr } = event.kind() { + let agent = if let Some(agent) = self + .log + .agent_by_addr(*agent_addr) + .and_then(|a| a.as_character()) + { + agent + } else { + continue; + }; + + if agent.id() == NIKARE_ID { + nikare_dead = true; + } else if agent.id() == KENUT_ID { + kenut_dead = true; + } + } + } + + Outcome::from_bool(kenut_dead && nikare_dead) + } } pub const QADIM_CM_HEALTH: u64 = 21_100_000; @@ -84,4 +130,8 @@ impl<'log> Analyzer for Qadim<'log> { .map(|h| h >= QADIM_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::players_exit_after_boss(self.log)) + } } diff --git a/src/gamedata.rs b/src/gamedata.rs index dd11e94..5e83167 100644 --- a/src/gamedata.rs +++ b/src/gamedata.rs @@ -39,6 +39,10 @@ pub enum Boss { // Wing 6 ConjuredAmalgamate = 0xABC6, + /// This is the ID of Nikare, as that is what the Twin Largos logs are identified by. + /// + /// If you want Nikare specifically, consider using [`NIKARE_ID`][NIKARE_ID], and similarly, if + /// you need Kenut, you can use [`KENUT_ID`][KENUT_ID]. LargosTwins = 0x5271, Qadim = 0x51C6, @@ -167,6 +171,11 @@ impl Display for Boss { /// into account. pub const XERA_PHASE2_ID: u16 = 0x3F9E; +/// The ID of Nikare in the Twin Largos fight. +pub const NIKARE_ID: u16 = Boss::LargosTwins as u16; +/// The ID of Kenut in the Twin Largos fight. +pub const KENUT_ID: u16 = 21089; + /// Error for when converting a string to a profession fails. #[derive(Debug, Clone, PartialEq, Eq, Hash, Error)] #[error("Invalid profession identifier: {0}")] -- cgit v1.2.3 From d290abac857fd88008afcde1d76fc70fe33ca605 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:23:03 +0200 Subject: implement Analyzer::outcome for wing 7 --- src/analyzers/raids/w7.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/analyzers/raids/w7.rs b/src/analyzers/raids/w7.rs index a8319a3..54073a3 100644 --- a/src/analyzers/raids/w7.rs +++ b/src/analyzers/raids/w7.rs @@ -1,6 +1,6 @@ //! Boss fight analyzers for Wing 6 (Mythwright Gambit) use crate::{ - analyzers::{helpers, Analyzer}, + analyzers::{helpers, Analyzer, Outcome}, Log, }; @@ -30,6 +30,10 @@ impl<'log> Analyzer for CardinalAdina<'log> { .map(|h| h >= ADINA_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } pub const SABIR_CM_HEALTH: u64 = 32_400_000; @@ -58,6 +62,10 @@ impl<'log> Analyzer for CardinalSabir<'log> { .map(|h| h >= SABIR_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } pub const QADIMP_CM_HEALTH: u64 = 51_000_000; @@ -83,4 +91,8 @@ impl<'log> Analyzer for QadimThePeerless<'log> { .map(|h| h >= QADIMP_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } } -- cgit v1.2.3 From c6c2f4fa92dc94d0710586e3ba96b31dcb125f91 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:26:56 +0200 Subject: make Log::boss_agents/is_boss work on Largos Twins Otherwise, this would only return Nikare, and not Kenut. --- src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index a0bb741..a343cfe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -760,6 +760,8 @@ impl Log { pub fn boss_agents(&self) -> Vec<&Agent> { let boss_ids = if self.boss_id == Boss::Xera as u16 { vec![self.boss_id, gamedata::XERA_PHASE2_ID] + } else if self.boss_id == Boss::LargosTwins as u16 { + vec![gamedata::NIKARE_ID, gamedata::KENUT_ID] } else { vec![self.boss_id] }; -- cgit v1.2.3 From d64ead757122e713d2cbb133d5fe683537cfcf6c Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:33:15 +0200 Subject: implement Analyzer::outcome for fractals --- src/analyzers/fractals.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/analyzers/fractals.rs b/src/analyzers/fractals.rs index dd010ac..e3ebfc5 100644 --- a/src/analyzers/fractals.rs +++ b/src/analyzers/fractals.rs @@ -1,6 +1,6 @@ //! Analyzers for (challenge mote) fractal encounters. use crate::{ - analyzers::{helpers, Analyzer}, + analyzers::{helpers, Analyzer, Outcome}, Log, }; @@ -30,6 +30,10 @@ impl<'log> Analyzer for Skorvald<'log> { .map(|h| h >= SKORVALD_CM_HEALTH) .unwrap_or(false) } + + fn outcome(&self) -> Option { + Outcome::from_bool(self.log.was_rewarded() || helpers::boss_is_dead(self.log)) + } } #[derive(Debug, Clone, Copy)] @@ -51,4 +55,8 @@ impl<'log> Analyzer for GenericFractal<'log> { fn is_cm(&self) -> bool { true } + + fn outcome(&self) -> Option { + Outcome::from_bool(self.log.was_rewarded() || helpers::boss_is_dead(self.log)) + } } -- cgit v1.2.3 From d2a3a49dc4759ede6cc9f553955eb289477a9d74 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:40:42 +0200 Subject: implement strike Analyzers --- src/analyzers/mod.rs | 7 ++++++- src/analyzers/strikes.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/analyzers/strikes.rs diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs index c440136..f2cd2c7 100644 --- a/src/analyzers/mod.rs +++ b/src/analyzers/mod.rs @@ -22,6 +22,7 @@ use crate::{Boss, Log}; pub mod fractals; pub mod helpers; pub mod raids; +pub mod strikes; /// The outcome of a fight. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] @@ -104,6 +105,10 @@ pub fn for_log<'l>(log: &'l Log) -> Option> { Some(Box::new(fractals::GenericFractal::new(log))) } - _ => None, + Boss::IcebroodConstruct + | Boss::VoiceOfTheFallen + | Boss::FraenirOfJormag + | Boss::Boneskinner + | Boss::WhisperOfJormag => Some(Box::new(strikes::GenericStrike::new(log))), } } diff --git a/src/analyzers/strikes.rs b/src/analyzers/strikes.rs new file mode 100644 index 0000000..82fcd79 --- /dev/null +++ b/src/analyzers/strikes.rs @@ -0,0 +1,30 @@ +//! Analyzers for Strike Mission logs. +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + Log, +}; + +#[derive(Debug, Clone, Copy)] +pub struct GenericStrike<'log> { + log: &'log Log, +} + +impl<'log> GenericStrike<'log> { + pub fn new(log: &'log Log) -> Self { + GenericStrike { log } + } +} + +impl<'log> Analyzer for GenericStrike<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + false + } + + fn outcome(&self) -> Option { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} -- cgit v1.2.3 From 6a4e302e49bea67cfd2ce240bc0de284967540c1 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:41:52 +0200 Subject: remove default implementation of Analyzer::outcome This was only there to make it easier to gradually implement the outcome method for the individual bosses. Now that each boss has a proper outcome, we no longer need the default method - in fact, I'd rather make sure the compiler tells us if we forget to implement this method in a new analyzer. --- src/analyzers/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs index f2cd2c7..880e5df 100644 --- a/src/analyzers/mod.rs +++ b/src/analyzers/mod.rs @@ -63,9 +63,7 @@ pub trait Analyzer { /// /// Note that not all logs need to have an outcome, e.g. WvW or Golem logs may return `None` /// here. - fn outcome(&self) -> Option { - None - } + fn outcome(&self) -> Option; } /// Returns the correct [`Analyzer`][Analyzer] for the given log file. -- cgit v1.2.3 From 30b3c2cfbb88d0a8e8e6b209c11fd0efacb66aff Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:45:16 +0200 Subject: update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6170b97..6457984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. ### Added - A variant for `CBTS_TAG`. - The function `Log::span` to get the duration of a log. +- Analyzers to detect fight outcomes and challenge motes in a fight-dependent + way. +- `gamedata::KENUT_ID` and `gamedata::NIKARE_ID` for the Largos Twins' IDs. + +### Fixed +- `Log::is_boss` and `Log::boss_agents` now properly work with both Largos in + the Twin Largos fight. ## 0.3.3 - 2020-05-25 ### Added -- cgit v1.2.3 From 75f5ce065efb6a186570b365c88e564871915d76 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 23 Jul 2020 17:57:59 +0200 Subject: more documentation & adjustments --- src/analyzers/mod.rs | 17 +++++++++++++---- src/analyzers/raids/mod.rs | 11 +++++++++++ src/analyzers/raids/w7.rs | 1 + src/lib.rs | 4 ++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs index 880e5df..d6315f3 100644 --- a/src/analyzers/mod.rs +++ b/src/analyzers/mod.rs @@ -1,16 +1,23 @@ //! Traits and structures to analyze fights. //! -//! Fights need different logic to determine some data, for example each fight has a different way -//! to determine whether or not the Challenge Mote was activated, whether or not the fight was -//! successful, ... +//! Fights need different logic in order to determine specific data, for example each fight has a +//! different way to determine whether or not the Challenge Mote was activated, whether or not the +//! fight was successful, ... //! -//! This module aims to unify that logic by providing a trait [`Analyzer`][Analyzer], which +//! This module aims to unify that logic by providing the [`Analyzer`][Analyzer] trait, which //! provides a unified interface to query this information. You can use //! [`Log::analyzer`][Log::analyzer] or [`for_log`][for_log] to obtain an analyzer fitting for the //! encounter that is represented by the log. //! +//! Most of the time, you will be dealing with a dynamically dispatched version of +//! [`Analyzer`][Analyzer], that is either `&dyn Analyzer` or `Box`. Also keep in +//! mind that an analyzer keeps a reference to the log that it is analyzing, which can be accessed +//! through [`Analyzer::log`][Analyzer::log]. +//! //! The implementation of the different analyzers is split off in different submodules: //! * [`raids`][raids] for the raid-related encounters. +//! * [`fractals`][fractals] for the fractal-specific encounters. +//! * [`strikes`][strikes] for the strike-mission specific encounters. //! //! Note that you should not create concrete analyzers on your own, as the behaviour is not //! specified when you use a wrong analyzer for the given log. Rely only on @@ -52,6 +59,8 @@ impl Outcome { } /// An [`Analyzer`][Analyzer] is something that implements fight-dependent analyzing of the log. +/// +/// For more information and explanations, see the [module level documentation][self]. pub trait Analyzer { /// Returns a reference to the log being analyzed. fn log(&self) -> &Log; diff --git a/src/analyzers/raids/mod.rs b/src/analyzers/raids/mod.rs index 33d54ce..39fb823 100644 --- a/src/analyzers/raids/mod.rs +++ b/src/analyzers/raids/mod.rs @@ -1,3 +1,9 @@ +//! Analyzers for raid logs. +//! +//! Most of the fights can use the [`GenericRaid`][GenericRaid] analyzer. The exception to this are +//! fights which have a Challenge Mote (Wing 4, Wing 5, Wing 6, Wing 7), and fights which need to +//! use a different method to determine their outcome (Xera, Deimos, Soulless Horror, Conjured +//! Amalgamate, Qadim). use crate::{ analyzers::{helpers, Analyzer, Outcome}, Log, @@ -19,6 +25,11 @@ mod w7; pub use w7::{CardinalAdina, CardinalSabir, QadimThePeerless}; /// A generic raid analyzer that works for bosses without special interactions. +/// +/// This analyzer always returns `false` for the Challenge Mote calculation. +/// +/// The outcome of the fight is determined by whether the boss agent has a death event - which +/// works for a lot of fights, but not all of them. #[derive(Debug, Clone, Copy)] pub struct GenericRaid<'log> { log: &'log Log, diff --git a/src/analyzers/raids/w7.rs b/src/analyzers/raids/w7.rs index 54073a3..480c303 100644 --- a/src/analyzers/raids/w7.rs +++ b/src/analyzers/raids/w7.rs @@ -70,6 +70,7 @@ impl<'log> Analyzer for CardinalSabir<'log> { pub const QADIMP_CM_HEALTH: u64 = 51_000_000; +/// Analyzer for the final fight of Wing 7, Qadim The Peerless. #[derive(Debug, Clone, Copy)] pub struct QadimThePeerless<'log> { log: &'log Log, diff --git a/src/lib.rs b/src/lib.rs index a343cfe..bd2acca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -884,6 +884,10 @@ impl Log { /// /// This can be used as an indication whether the fight was successful (`true`) or not /// (`false`). + /// + /// If you want to properly determine whether a fight was successful, check the + /// [`Analyzer::outcome`][Analyzer::outcome] method, which does more sophisticated checks + /// (dependent on the boss). pub fn was_rewarded(&self) -> bool { self.events().iter().any(|e| { if let EventKind::Reward { .. } = e.kind() { -- cgit v1.2.3 From 06590a174a4b3707b9048f3669ad17702902b601 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 24 Jul 2020 13:49:49 +0200 Subject: re-export Outcome as well --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index bd2acca..b3c587d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,7 +107,7 @@ pub mod gamedata; pub use gamedata::{Boss, EliteSpec, Profession}; pub mod analyzers; -pub use analyzers::Analyzer; +pub use analyzers::{Analyzer, Outcome}; /// Any error that can occur during the processing of evtc files. #[derive(Error, Debug)] -- cgit v1.2.3 From f6717fc45188870341e9b6185ef5f3102f5a96ae Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 24 Jul 2020 14:09:51 +0200 Subject: more documentation --- src/analyzers/fractals.rs | 15 +++++++++++++++ src/analyzers/raids/mod.rs | 4 ++++ src/analyzers/raids/w3.rs | 4 ++++ src/analyzers/raids/w4.rs | 16 ++++++++++++++++ src/analyzers/raids/w5.rs | 8 ++++++++ src/analyzers/raids/w6.rs | 12 ++++++++++++ src/analyzers/raids/w7.rs | 12 ++++++++++++ src/analyzers/strikes.rs | 8 ++++++++ 8 files changed, 79 insertions(+) diff --git a/src/analyzers/fractals.rs b/src/analyzers/fractals.rs index e3ebfc5..910b182 100644 --- a/src/analyzers/fractals.rs +++ b/src/analyzers/fractals.rs @@ -4,6 +4,7 @@ use crate::{ Log, }; +/// Health threshold for Skorvald to be detected as Challenge Mote. pub const SKORVALD_CM_HEALTH: u64 = 5_551_340; /// Analyzer for the first boss of 100 CM, Skorvald. @@ -15,6 +16,10 @@ pub struct Skorvald<'log> { } impl<'log> Skorvald<'log> { + /// Create a new [`Skorvald`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Skorvald { log } } @@ -36,12 +41,19 @@ impl<'log> Analyzer for Skorvald<'log> { } } +/// Analyzer for fractals that don't require special logic. +/// +/// This is used for Artsariiv, Arkk, MAMA, Siax and Ensolyss. #[derive(Debug, Clone, Copy)] pub struct GenericFractal<'log> { log: &'log Log, } impl<'log> GenericFractal<'log> { + /// Create a new [`GenericFractal`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { GenericFractal { log } } @@ -53,6 +65,9 @@ impl<'log> Analyzer for GenericFractal<'log> { } fn is_cm(&self) -> bool { + // Besides Skorvald normal mode, we only get logs for the challenge mote encounters (at + // least, only for those we'll use this analyzer). So we can safely return true here in any + // case. true } diff --git a/src/analyzers/raids/mod.rs b/src/analyzers/raids/mod.rs index 39fb823..bb3824b 100644 --- a/src/analyzers/raids/mod.rs +++ b/src/analyzers/raids/mod.rs @@ -36,6 +36,10 @@ pub struct GenericRaid<'log> { } impl<'log> GenericRaid<'log> { + /// Create a new [`GenericRaid`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { GenericRaid { log } } diff --git a/src/analyzers/raids/w3.rs b/src/analyzers/raids/w3.rs index 82c007d..1b80b8d 100644 --- a/src/analyzers/raids/w3.rs +++ b/src/analyzers/raids/w3.rs @@ -10,6 +10,10 @@ pub struct Xera<'log> { } impl<'log> Xera<'log> { + /// Create a new [`Xera`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Xera { log } } diff --git a/src/analyzers/raids/w4.rs b/src/analyzers/raids/w4.rs index e753e49..310b26f 100644 --- a/src/analyzers/raids/w4.rs +++ b/src/analyzers/raids/w4.rs @@ -16,6 +16,10 @@ pub struct Cairn<'log> { } impl<'log> Cairn<'log> { + /// Create a new [`Cairn`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Cairn { log } } @@ -46,6 +50,10 @@ pub struct MursaatOverseer<'log> { } impl<'log> MursaatOverseer<'log> { + /// Create a new [`MursaatOverseer`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { MursaatOverseer { log } } @@ -78,6 +86,10 @@ pub struct Samarog<'log> { } impl<'log> Samarog<'log> { + /// Create a new [`Samarog`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Samarog { log } } @@ -110,6 +122,10 @@ pub struct Deimos<'log> { } impl<'log> Deimos<'log> { + /// Create a new [`Deimos`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Deimos { log } } diff --git a/src/analyzers/raids/w5.rs b/src/analyzers/raids/w5.rs index b9668b7..578cea8 100644 --- a/src/analyzers/raids/w5.rs +++ b/src/analyzers/raids/w5.rs @@ -18,6 +18,10 @@ pub struct SoullessHorror<'log> { } impl<'log> SoullessHorror<'log> { + /// Create a new [`SoullessHorror`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { SoullessHorror { log } } @@ -60,6 +64,10 @@ pub struct Dhuum<'log> { } impl<'log> Dhuum<'log> { + /// Create a new [`Dhuum`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Dhuum { log } } diff --git a/src/analyzers/raids/w6.rs b/src/analyzers/raids/w6.rs index cc39403..8701a63 100644 --- a/src/analyzers/raids/w6.rs +++ b/src/analyzers/raids/w6.rs @@ -17,6 +17,10 @@ pub struct ConjuredAmalgamate<'log> { } impl<'log> ConjuredAmalgamate<'log> { + /// Create a new [`ConjuredAmalgamate`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { ConjuredAmalgamate { log } } @@ -60,6 +64,10 @@ pub struct LargosTwins<'log> { } impl<'log> LargosTwins<'log> { + /// Create a new [`LargosTwins`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { LargosTwins { log } } @@ -115,6 +123,10 @@ pub struct Qadim<'log> { } impl<'log> Qadim<'log> { + /// Create a new [`Qadim`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { Qadim { log } } diff --git a/src/analyzers/raids/w7.rs b/src/analyzers/raids/w7.rs index 480c303..bdfadd6 100644 --- a/src/analyzers/raids/w7.rs +++ b/src/analyzers/raids/w7.rs @@ -15,6 +15,10 @@ pub struct CardinalAdina<'log> { } impl<'log> CardinalAdina<'log> { + /// Create a new [`CardinalAdina`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { CardinalAdina { log } } @@ -47,6 +51,10 @@ pub struct CardinalSabir<'log> { } impl<'log> CardinalSabir<'log> { + /// Create a new [`CardinalSabir`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { CardinalSabir { log } } @@ -77,6 +85,10 @@ pub struct QadimThePeerless<'log> { } impl<'log> QadimThePeerless<'log> { + /// Create a new [`QadimThePeerless`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { QadimThePeerless { log } } diff --git a/src/analyzers/strikes.rs b/src/analyzers/strikes.rs index 82fcd79..8c22c49 100644 --- a/src/analyzers/strikes.rs +++ b/src/analyzers/strikes.rs @@ -4,12 +4,20 @@ use crate::{ Log, }; +/// Analyzer for strikes. +/// +/// Since there are currently no strikes requiring special logic, this analyzer is used for all +/// strike missions. #[derive(Debug, Clone, Copy)] pub struct GenericStrike<'log> { log: &'log Log, } impl<'log> GenericStrike<'log> { + /// Create a new [`GenericStrike`] analyzer for the given log. + /// + /// **Do not** use this method unless you know what you are doing. Instead, rely on + /// [`Log::analyzer`]! pub fn new(log: &'log Log) -> Self { GenericStrike { log } } -- cgit v1.2.3 From 9d27ec7034f9ad07d8a1d74ab30fdc470de4e02d Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 24 Jul 2020 14:11:51 +0200 Subject: add some testing for analyzers --- tests/analyzers.rs | 31 +++++++++++++++++++++++ tests/logs/analyzers/xera-failed-20200714.zevtc | Bin 0 -> 749741 bytes tests/logs/analyzers/xera-success-20200714.zevtc | Bin 0 -> 1724050 bytes tests/parsing.rs | 5 ++++ 4 files changed, 36 insertions(+) create mode 100644 tests/analyzers.rs create mode 100644 tests/logs/analyzers/xera-failed-20200714.zevtc create mode 100644 tests/logs/analyzers/xera-success-20200714.zevtc diff --git a/tests/analyzers.rs b/tests/analyzers.rs new file mode 100644 index 0000000..c7ed6bd --- /dev/null +++ b/tests/analyzers.rs @@ -0,0 +1,31 @@ +//! Test for (some) analyzer functions. +//! +//! Even if those tests do not test the actual functionality, they ensure that the API is usable. + +use evtclib::{Compression, Outcome}; + +#[test] +fn test_xera_failed() { + let log = evtclib::process_file( + "tests/logs/analyzers/xera-failed-20200714.zevtc", + Compression::Zip, + ) + .unwrap(); + + let analyzer = log.analyzer().expect("No analyzer for Xera!"); + + assert_eq!(analyzer.outcome(), Some(Outcome::Failure)); +} + +#[test] +fn test_xera_succeeded() { + let log = evtclib::process_file( + "tests/logs/analyzers/xera-success-20200714.zevtc", + Compression::Zip, + ) + .unwrap(); + + let analyzer = log.analyzer().expect("No analyzer for Xera!"); + + assert_eq!(analyzer.outcome(), Some(Outcome::Success)); +} diff --git a/tests/logs/analyzers/xera-failed-20200714.zevtc b/tests/logs/analyzers/xera-failed-20200714.zevtc new file mode 100644 index 0000000..c4e72bf Binary files /dev/null and b/tests/logs/analyzers/xera-failed-20200714.zevtc differ diff --git a/tests/logs/analyzers/xera-success-20200714.zevtc b/tests/logs/analyzers/xera-success-20200714.zevtc new file mode 100644 index 0000000..0289f4c Binary files /dev/null and b/tests/logs/analyzers/xera-success-20200714.zevtc differ diff --git a/tests/parsing.rs b/tests/parsing.rs index 58e890a..324d823 100644 --- a/tests/parsing.rs +++ b/tests/parsing.rs @@ -28,6 +28,11 @@ macro_rules! test { assert_eq!(player.profession(), *profession); assert_eq!(player.elite(), *elite_spec); } + + // We don't want to assert the correct outcome here (yet?), but at least ensure we have + // analyzer's ready that produce some outcome. + assert!(log.analyzer().is_some()); + assert!(log.analyzer().unwrap().outcome().is_some()); } }; } -- cgit v1.2.3