aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2020-07-24 14:23:53 +0200
committerDaniel Schadt <kingdread@gmx.de>2020-07-24 14:23:53 +0200
commit71528905ed228750559a41144a2e0a95db3e6805 (patch)
tree4e46c6cbd3a3e83ab707e7156b345fbe7f3048ea
parent01354b0934409c355831bb4202f998fe5dbdc335 (diff)
parent9d27ec7034f9ad07d8a1d74ab30fdc470de4e02d (diff)
downloadevtclib-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.md7
-rw-r--r--src/analyzers/fractals.rs77
-rw-r--r--src/analyzers/helpers.rs113
-rw-r--r--src/analyzers/mod.rs121
-rw-r--r--src/analyzers/raids/mod.rs60
-rw-r--r--src/analyzers/raids/w3.rs34
-rw-r--r--src/analyzers/raids/w4.rs230
-rw-r--r--src/analyzers/raids/w5.rs90
-rw-r--r--src/analyzers/raids/w6.rs149
-rw-r--r--src/analyzers/raids/w7.rs111
-rw-r--r--src/analyzers/strikes.rs38
-rw-r--r--src/gamedata.rs66
-rw-r--r--src/lib.rs89
-rw-r--r--tests/analyzers.rs31
-rw-r--r--tests/logs/analyzers/xera-failed-20200714.zevtcbin0 -> 749741 bytes
-rw-r--r--tests/logs/analyzers/xera-success-20200714.zevtcbin0 -> 1724050 bytes
-rw-r--r--tests/parsing.rs5
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)]
diff --git a/src/lib.rs b/src/lib.rs
index 519d1c4..b3c587d 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, 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
new file mode 100644
index 0000000..c4e72bf
--- /dev/null
+++ b/tests/logs/analyzers/xera-failed-20200714.zevtc
Binary files 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
--- /dev/null
+++ b/tests/logs/analyzers/xera-success-20200714.zevtc
Binary files 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());
}
};
}