use std::collections::HashMap; use std::fs::File; use std::io::{BufReader, Read, Seek}; use std::path::PathBuf; use std::str::FromStr; use anyhow::{anyhow, Result}; use chrono::{Duration, NaiveDateTime, Weekday}; use num_traits::cast::FromPrimitive; use regex::Regex; use structopt::StructOpt; use walkdir::{DirEntry, WalkDir}; use log::debug; use evtclib::{AgentKind, AgentName, EventKind, Log}; mod filters; mod guilds; mod logger; mod output; mod csl; use csl::CommaSeparatedList; macro_rules! unwrap { ($p:pat = $e:expr => { $r:expr} ) => { if let $p = $e { $r } else { panic!("Pattern match failed!"); } }; } /// A program that allows you to search through all your evtc logs for specific /// people. #[derive(StructOpt, Debug)] #[structopt(name = "raidgrep")] pub struct Opt { /// Path to the folder with logs. #[structopt(short = "d", long = "dir", default_value = ".", parse(from_os_str))] path: PathBuf, /// The fields which should be searched. #[structopt(short = "f", long = "fields", default_value = "account,character")] field: CommaSeparatedList, /// Only display fights with the given outcome. #[structopt(short = "o", long = "outcome", default_value = "*")] outcome: CommaSeparatedList, /// Invert the regular expression (show fights that do not match) #[structopt(short = "v", long = "invert-match")] invert: bool, /// Only show the name of matching files. #[structopt(short = "l", long = "files-with-matches")] file_name_only: bool, /// Disable colored output. #[structopt(long = "no-color")] no_color: bool, /// Only show logs that are younger than the given time. #[structopt( short = "a", long = "younger", parse(try_from_str = parse_time_arg) )] after: Option, /// Only show logs that are older than the given time. #[structopt( short = "b", long = "older", parse(try_from_str = parse_time_arg) )] before: Option, /// Only show logs from the given weekdays. #[structopt( short = "w", long = "weekdays", default_value = "*", parse(try_from_str = try_from_str_simple_error) )] weekdays: CommaSeparatedList, /// Only show logs from the given encounters. #[structopt(short = "e", long = "bosses", default_value = "*")] bosses: CommaSeparatedList, /// Print more debugging information to stderr. #[structopt(long = "debug")] debug: bool, /// Load guild information from the API. #[structopt(long = "guilds")] guilds: bool, /// The regular expression to search for. #[structopt(name = "EXPR")] expression: Regex, } /// A flag indicating which fields should be searched. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] enum SearchField { /// Only search the account name. Account, /// Only search the character name. Character, /// Only search the guild name or tag. Guild, } impl FromStr for SearchField { type Err = &'static str; fn from_str(s: &str) -> Result { match s { "account" => Ok(SearchField::Account), "character" => Ok(SearchField::Character), "guild" => Ok(SearchField::Guild), _ => Err("Must be account or character"), } } } /// A log that matches the search criteria. #[derive(Debug, Clone)] pub struct LogResult { /// The path to the log file. log_file: PathBuf, /// The time of the recording. time: NaiveDateTime, /// The name of the boss. boss_name: String, /// A vector of all participating players. players: Vec, /// The outcome of the fight. outcome: FightOutcome, } /// A player. #[derive(Debug, Clone)] pub struct Player { /// Account name of the player. account_name: String, /// Character name of the player. character_name: String, /// Profession (or elite specialization) as english name. profession: String, /// Subsquad that the player was in. subgroup: u8, /// Guild ID, ready for API consumption. guild_id: Option, } /// Outcome of the fight. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FightOutcome { Success, Wipe, } impl FromStr for FightOutcome { type Err = &'static str; fn from_str(s: &str) -> Result { match s { "success" | "kill" => Ok(FightOutcome::Success), "wipe" | "fail" => Ok(FightOutcome::Wipe), _ => Err("Must be success or wipe"), } } } fn parse_time_arg(input: &str) -> Result { if let Ok(duration) = humantime::parse_duration(input) { let now = chrono::Local::now().naive_local(); let chrono_dur = Duration::from_std(duration).expect("Duration out of range!"); return Ok(now - chrono_dur); } if let Ok(time) = humantime::parse_rfc3339_weak(input) { let timestamp = time .duration_since(std::time::SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); return Ok(NaiveDateTime::from_timestamp(timestamp as i64, 0)); } Err(anyhow!("unknown time format")) } fn try_from_str_simple_error(input: &str) -> Result { T::from_str(input).map_err(|_| format!("'{}' is an invalid value", input)) } enum ZipWrapper { Raw(Option), Zipped(zip::ZipArchive), } impl ZipWrapper { pub fn raw(input: R) -> Self { ZipWrapper::Raw(Some(input)) } pub fn zipped(input: R) -> Self { ZipWrapper::Zipped(zip::ZipArchive::new(input).unwrap()) } pub fn get_stream<'a>(&'a mut self) -> Box<(dyn Read + 'a)> { match *self { ZipWrapper::Raw(ref mut o) => Box::new(o.take().unwrap()), ZipWrapper::Zipped(ref mut z) => Box::new(z.by_index(0).unwrap()), } } } fn main() { let opt = Opt::from_args(); if opt.no_color { colored::control::set_override(false); } if opt.debug { logger::initialize(log::Level::Debug); } else { logger::initialize(log::Level::Info); } if opt.guilds { guilds::prepare_cache(); } let result = grep(&opt); match result { Ok(_) => {} Err(e) => { eprintln!("Error: {}", e); } } if opt.guilds { guilds::save_cache(); } } /// Check if the given entry represents a log file, based on the file name. fn is_log_file(entry: &DirEntry) -> bool { entry .file_name() .to_str() .map(|n| n.ends_with(".evtc") || n.ends_with(".evtc.zip") || n.ends_with(".zevtc")) .unwrap_or(false) } /// Run the grep search with the given options. fn grep(opt: &Opt) -> Result<()> { let pipeline = &output::build_pipeline(opt); rayon::scope(|s| { let walker = WalkDir::new(&opt.path); for entry in walker { let entry = entry?; s.spawn(move |_| { if is_log_file(&entry) { let search = search_log(&entry, opt); match search { Ok(None) => (), Ok(Some(result)) => pipeline.push_item(&result), Err(err) => { debug!("Runtime error while scanning {:?}: {}", entry.path(), err); } } } }); } Ok(()) }) } /// Search the given single log. /// /// If the log matches, returns `Ok(Some(..))`. /// If the log doesn't match, returns `Ok(None)`. /// If there was a fatal error, returns `Err(..)`. fn search_log(entry: &DirEntry, opt: &Opt) -> Result> { let file_stream = BufReader::new(File::open(entry.path())?); let is_zip = entry .file_name() .to_str() .map(|n| n.ends_with(".zip") || n.ends_with(".zevtc")) .unwrap_or(false); let mut wrapper = if is_zip { ZipWrapper::zipped(file_stream) } else { ZipWrapper::raw(file_stream) }; let mut stream = wrapper.get_stream(); let partial = evtclib::raw::parser::parse_partial_file(&mut stream)?; let early_ok = filters::filter_name(&partial, opt) != opt.invert && filters::filter_boss(&partial, opt); if !early_ok { return Ok(None); } let raw = evtclib::raw::parser::finish_parsing(partial, &mut stream)?; let parsed = evtclib::process(&raw).ok(); let log = if let Some(e) = parsed { e } else { debug!("log file cannot be parsed: {:?}", entry.path()); return Ok(None); }; let info = extract_info(entry, &log); let take_log = filters::filter_outcome(&info, opt) && filters::filter_weekday(&info, opt) && filters::filter_time(&info, opt) && filters::filter_guilds(&info, opt); if take_log { Ok(Some(info)) } else { Ok(None) } } /// Extract human-readable information from the given log file. fn extract_info(entry: &DirEntry, log: &Log) -> LogResult { let boss_name = get_encounter_name(log) .unwrap_or_else(|| { debug!( "log file has unknown boss: {:?} (id: {:#x})", entry.path(), log.boss_id() ); "unknown" }) .into(); let guild_ids = get_guild_mapping(log); let mut players = log .players() .map(|p| { unwrap! { AgentKind::Player { profession, elite } = p.kind() => { unwrap! { AgentName::Player { account_name, character_name, subgroup, } = p.name() => { Player { account_name: account_name.clone(), character_name: character_name.clone(), profession: get_profession_name(*profession, *elite).into(), subgroup: *subgroup, guild_id: guild_ids.get(p.addr()).cloned(), } }}}} }) .collect::>(); players.sort_by_key(|p| p.subgroup); LogResult { log_file: entry.path().to_path_buf(), time: NaiveDateTime::from_timestamp(i64::from(get_start_timestamp(log)), 0), boss_name, players, outcome: get_fight_outcome(log), } } /// Get a mapping of agent IDs to guild API strings. fn get_guild_mapping(log: &Log) -> HashMap { log.events() .iter() .filter_map(|event| { if let EventKind::Guild { source_agent_addr, ref api_guild_id, .. } = event.kind { api_guild_id .as_ref() .map(|api_id| (source_agent_addr, api_id.clone())) } else { None } }) .collect() } /// Get the timestamp of the log start time. fn get_start_timestamp(log: &Log) -> u32 { for event in log.events() { if let EventKind::LogStart { local_timestamp, .. } = event.kind { return local_timestamp; } } 0 } /// Get the outcome of the fight. fn get_fight_outcome(log: &Log) -> FightOutcome { for event in log.events() { if let EventKind::Reward { .. } = event.kind { return FightOutcome::Success; } } FightOutcome::Wipe } /// Get the (english) name for the given encounter fn get_encounter_name(log: &Log) -> Option<&'static str> { use evtclib::statistics::gamedata::Boss; let boss = Boss::from_u16(log.boss_id())?; Some(match boss { Boss::ValeGuardian => "Vale Guardian", Boss::Gorseval => "Gorseval", Boss::Sabetha => "Sabetha", Boss::Slothasor => "Slothasor", Boss::Matthias => "Matthias", Boss::KeepConstruct => "Keep Construct", Boss::Xera => "Xera", Boss::Cairn => "Cairn", Boss::MursaatOverseer => "Mursaat Overseer", Boss::Samarog => "Samarog", Boss::Deimos => "Deimos", Boss::SoullessHorror => "Desmina", Boss::Dhuum => "Dhuum", Boss::ConjuredAmalgamate => "Conjured Amalgamate", Boss::LargosTwins => "Largos Twins", Boss::Qadim => "Qadim", Boss::CardinalAdina => "Cardinal Adina", Boss::CardinalSabir => "Cardinal Sabir", Boss::QadimThePeerless => "Qadim The Peerless", Boss::Skorvald => "Skorvald", Boss::Artsariiv => "Artsariiv", Boss::Arkk => "Arkk", Boss::MAMA => "MAMA", Boss::Siax => "Siax the Corrupted", Boss::Ensolyss => "Ensolyss of the Endless Torment", Boss::IcebroodConstruct => "Icebrood Construct", Boss::VoiceOfTheFallen => "Super Kodan Brothers", Boss::FraenirOfJormag => "Fraenir of Jormag", Boss::Boneskinner => "Boneskinner", Boss::WhisperOfJormag => "Whisper of Jormag", }) } /// Get the (english) name for the given profession/elite specialization. fn get_profession_name(profession: u32, elite: u32) -> &'static str { match (profession, elite) { (1, 0) => "Guardian", (2, 0) => "Warrior", (3, 0) => "Engineer", (4, 0) => "Ranger", (5, 0) => "Thief", (6, 0) => "Elementalist", (7, 0) => "Mesmer", (8, 0) => "Necromancer", (9, 0) => "Revenant", (1, 27) => "Dragonhunter", (2, 18) => "Berserker", (3, 43) => "Scrapper", (4, 5) => "Druid", (5, 7) => "Daredevil", (6, 48) => "Tempest", (7, 40) => "Chronomancer", (8, 34) => "Reaper", (9, 52) => "Herald", (1, 62) => "Firebrand", (2, 61) => "Spellbreaker", (3, 57) => "Holosmith", (4, 55) => "Soulbeast", (5, 58) => "Deadeye", (6, 56) => "Weaver", (7, 59) => "Mirage", (8, 60) => "Scourge", (9, 63) => "Renegade", _ => { debug!("Unknown spec (prof: {}, elite: {})", profession, elite); "Unknown" } } }