diff options
author | Daniel Schadt <kingdread@gmx.de> | 2020-07-24 14:23:53 +0200 |
---|---|---|
committer | Daniel Schadt <kingdread@gmx.de> | 2020-07-24 14:23:53 +0200 |
commit | 71528905ed228750559a41144a2e0a95db3e6805 (patch) | |
tree | 4e46c6cbd3a3e83ab707e7156b345fbe7f3048ea | |
parent | 01354b0934409c355831bb4202f998fe5dbdc335 (diff) | |
parent | 9d27ec7034f9ad07d8a1d74ab30fdc470de4e02d (diff) | |
download | evtclib-71528905ed228750559a41144a2e0a95db3e6805.tar.gz evtclib-71528905ed228750559a41144a2e0a95db3e6805.tar.bz2 evtclib-71528905ed228750559a41144a2e0a95db3e6805.zip |
Merge branch 'analyzers'
This brings in proper fight outcome detection, which is nice and needed
for downstream applications (raidgrep/ezau).
Furthermore, this cleans up the CM detection a bit by moving away from
the "descriptive" trigger way to just having dynamically dispatched
methods for every log.
-rw-r--r-- | CHANGELOG.md | 7 | ||||
-rw-r--r-- | src/analyzers/fractals.rs | 77 | ||||
-rw-r--r-- | src/analyzers/helpers.rs | 113 | ||||
-rw-r--r-- | src/analyzers/mod.rs | 121 | ||||
-rw-r--r-- | src/analyzers/raids/mod.rs | 60 | ||||
-rw-r--r-- | src/analyzers/raids/w3.rs | 34 | ||||
-rw-r--r-- | src/analyzers/raids/w4.rs | 230 | ||||
-rw-r--r-- | src/analyzers/raids/w5.rs | 90 | ||||
-rw-r--r-- | src/analyzers/raids/w6.rs | 149 | ||||
-rw-r--r-- | src/analyzers/raids/w7.rs | 111 | ||||
-rw-r--r-- | src/analyzers/strikes.rs | 38 | ||||
-rw-r--r-- | src/gamedata.rs | 66 | ||||
-rw-r--r-- | src/lib.rs | 89 | ||||
-rw-r--r-- | tests/analyzers.rs | 31 | ||||
-rw-r--r-- | tests/logs/analyzers/xera-failed-20200714.zevtc | bin | 0 -> 749741 bytes | |||
-rw-r--r-- | tests/logs/analyzers/xera-success-20200714.zevtc | bin | 0 -> 1724050 bytes | |||
-rw-r--r-- | tests/parsing.rs | 5 |
17 files changed, 1089 insertions, 132 deletions
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 diff --git a/src/analyzers/fractals.rs b/src/analyzers/fractals.rs new file mode 100644 index 0000000..910b182 --- /dev/null +++ b/src/analyzers/fractals.rs @@ -0,0 +1,77 @@ +//! Analyzers for (challenge mote) fractal encounters. +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + 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. +/// +/// 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(self.log.was_rewarded() || helpers::boss_is_dead(self.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 } + } +} + +impl<'log> Analyzer for GenericFractal<'log> { + fn log(&self) -> &Log { + self.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 + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(self.log.was_rewarded() || helpers::boss_is_dead(self.log)) + } +} diff --git a/src/analyzers/helpers.rs b/src/analyzers/helpers.rs new file mode 100644 index 0000000..674d752 --- /dev/null +++ b/src/analyzers/helpers.rs @@ -0,0 +1,113 @@ +//! This module contains helper methods that are used in different analyzers. +use std::collections::HashMap; + +use crate::{AgentKind, 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<u64> { + let mut health: Option<u64> = 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 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() { + 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<u64, Vec<u64>> = 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..d6315f3 --- /dev/null +++ b/src/analyzers/mod.rs @@ -0,0 +1,121 @@ +//! Traits and structures to analyze fights. +//! +//! 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 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<dyn Analyzer>`. 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 +//! [`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; +pub mod strikes; + +/// 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<Outcome> { + if b { + Some(Outcome::Success) + } else { + Some(Outcome::Failure) + } + } +} + +/// 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; + + /// 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<Outcome>; +} + +/// 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<Box<dyn Analyzer + 'l>> { + 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))), + 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))) + } + + Boss::IcebroodConstruct + | Boss::VoiceOfTheFallen + | Boss::FraenirOfJormag + | Boss::Boneskinner + | Boss::WhisperOfJormag => Some(Box::new(strikes::GenericStrike::new(log))), + } +} diff --git a/src/analyzers/raids/mod.rs b/src/analyzers/raids/mod.rs new file mode 100644 index 0000000..bb3824b --- /dev/null +++ b/src/analyzers/raids/mod.rs @@ -0,0 +1,60 @@ +//! 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, +}; + +mod w3; +pub use w3::Xera; + +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}; + +/// 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, +} + +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 } + } +} + +impl<'log> Analyzer for GenericRaid<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + false + } + + fn outcome(&self) -> Option<Outcome> { + 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..1b80b8d --- /dev/null +++ b/src/analyzers/raids/w3.rs @@ -0,0 +1,34 @@ +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> { + /// 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 } + } +} + +impl<'log> Analyzer for Xera<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + false + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::players_exit_after_boss(self.log)) + } +} diff --git a/src/analyzers/raids/w4.rs b/src/analyzers/raids/w4.rs new file mode 100644 index 0000000..310b26f --- /dev/null +++ b/src/analyzers/raids/w4.rs @@ -0,0 +1,230 @@ +//! Boss fight analyzers for Wing 4 (Bastion of the Penitent). +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + EventKind, 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} + +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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} + +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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} + +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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + // 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 +} diff --git a/src/analyzers/raids/w5.rs b/src/analyzers/raids/w5.rs new file mode 100644 index 0000000..578cea8 --- /dev/null +++ b/src/analyzers/raids/w5.rs @@ -0,0 +1,90 @@ +//! Boss fight analyzers for Wing 5 (Hall of Chains) +use crate::{ + 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). +/// +/// 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> { + /// 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 } + } +} + +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 + } + + fn outcome(&self) -> Option<Outcome> { + 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; + +/// 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} diff --git a/src/analyzers/raids/w6.rs b/src/analyzers/raids/w6.rs new file mode 100644 index 0000000..8701a63 --- /dev/null +++ b/src/analyzers/raids/w6.rs @@ -0,0 +1,149 @@ +//! Boss fight analyzers for Wing 6 (Mythwright Gambit) +use crate::{ + 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. +/// +/// 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + 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; + +/// 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + 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; + +/// 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::players_exit_after_boss(self.log)) + } +} diff --git a/src/analyzers/raids/w7.rs b/src/analyzers/raids/w7.rs new file mode 100644 index 0000000..bdfadd6 --- /dev/null +++ b/src/analyzers/raids/w7.rs @@ -0,0 +1,111 @@ +//! Boss fight analyzers for Wing 6 (Mythwright Gambit) +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + 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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} + +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> { + /// 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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.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, +} + +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 } + } +} + +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) + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} diff --git a/src/analyzers/strikes.rs b/src/analyzers/strikes.rs new file mode 100644 index 0000000..8c22c49 --- /dev/null +++ b/src/analyzers/strikes.rs @@ -0,0 +1,38 @@ +//! Analyzers for Strike Mission logs. +use crate::{ + analyzers::{helpers, Analyzer, Outcome}, + 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 } + } +} + +impl<'log> Analyzer for GenericStrike<'log> { + fn log(&self) -> &Log { + self.log + } + + fn is_cm(&self) -> bool { + false + } + + fn outcome(&self) -> Option<Outcome> { + Outcome::from_bool(helpers::boss_is_dead(self.log)) + } +} diff --git a/src/gamedata.rs b/src/gamedata.rs index 30bbcf6..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, @@ -65,42 +69,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,28 +171,10 @@ 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), -} +/// 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)] @@ -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, Outcome}; + /// Any error that can occur during the processing of evtc files. #[derive(Error, Debug)] pub enum EvtcError { @@ -759,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] }; @@ -789,6 +792,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<Box<dyn Analyzer + 's>> { + analyzers::for_log(&self) + } + /// Return all events present in this log. #[inline] pub fn events(&self) -> &[Event] { @@ -832,46 +840,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. @@ -915,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() { @@ -925,35 +898,3 @@ impl Log { }) } } - -fn time_between_buffs(events: &[Event], wanted_buff_id: u32) -> u64 { - let mut time_maps: HashMap<u64, Vec<u64>> = 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) -} 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 Binary files differnew file mode 100644 index 0000000..c4e72bf --- /dev/null +++ b/tests/logs/analyzers/xera-failed-20200714.zevtc diff --git a/tests/logs/analyzers/xera-success-20200714.zevtc b/tests/logs/analyzers/xera-success-20200714.zevtc Binary files differnew file mode 100644 index 0000000..0289f4c --- /dev/null +++ b/tests/logs/analyzers/xera-success-20200714.zevtc 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()); } }; } |