mirror of
https://codeberg.org/icewind/log-normalizer.git
synced 2026-06-03 13:54:11 +02:00
initial normalization
This commit is contained in:
parent
0151dfce7d
commit
1b256ecfd9
6 changed files with 4535 additions and 32 deletions
18
src/data.rs
18
src/data.rs
|
|
@ -1,12 +1,12 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::Type, Deserialize)]
|
||||
pub enum Team {
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type, Deserialize, Eq, PartialEq)]
|
||||
pub enum TeamId {
|
||||
Blue,
|
||||
Red,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::Type, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type, Deserialize, Eq, PartialEq)]
|
||||
pub enum Class {
|
||||
Scout,
|
||||
Soldier,
|
||||
|
|
@ -19,7 +19,7 @@ pub enum Class {
|
|||
Spy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::Type, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type, Deserialize, Eq, PartialEq)]
|
||||
pub enum GameMode {
|
||||
UltiDuo,
|
||||
Fours,
|
||||
|
|
@ -29,7 +29,7 @@ pub enum GameMode {
|
|||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::Type, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type, Deserialize, Eq, PartialEq)]
|
||||
pub enum EventType {
|
||||
Charge,
|
||||
PointCap,
|
||||
|
|
@ -37,13 +37,13 @@ pub enum EventType {
|
|||
RoundWin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::Type, Deserialize, Hash, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type, Deserialize, Hash, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Medigun {
|
||||
Medigun,
|
||||
KritzKrieg,
|
||||
QuickFix,
|
||||
Vacinator
|
||||
Vacinator,
|
||||
}
|
||||
|
||||
impl Default for Medigun {
|
||||
|
|
@ -52,7 +52,7 @@ impl Default for Medigun {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::Type, Deserialize, Hash, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type, Deserialize, Hash, Eq, PartialEq)]
|
||||
// #[sqlx(rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Weapon {
|
||||
|
|
@ -269,4 +269,4 @@ pub enum Weapon {
|
|||
ProSmg,
|
||||
SteelFists,
|
||||
Fryingpan,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod data;
|
||||
mod raw;
|
||||
mod normalized;
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
|
|
|
|||
321
src/normalized.rs
Normal file
321
src/normalized.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
pub use crate::data::TeamId;
|
||||
use crate::raw::RawLog;
|
||||
pub use crate::raw::{
|
||||
ChatMessage, ClassNumbers, Event, Player, RoundPlayer, Team, Teams, Uploader,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use steamid_ng::SteamID;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(from = "crate::raw::RawLog")]
|
||||
pub struct NormalizedLog {
|
||||
pub version: u8,
|
||||
pub length: u32,
|
||||
pub teams: Teams,
|
||||
pub players: HashMap<SteamID, Player>,
|
||||
pub names: HashMap<SteamID, String>,
|
||||
pub rounds: Vec<Round>,
|
||||
pub heal_spread: HashMap<SteamID, HashMap<SteamID, u32>>,
|
||||
pub class_kills: HashMap<SteamID, ClassNumbers>,
|
||||
pub class_deaths: HashMap<SteamID, ClassNumbers>,
|
||||
pub class_kill_assists: HashMap<SteamID, ClassNumbers>,
|
||||
pub chat: Vec<ChatMessage>,
|
||||
pub info: Info,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Info {
|
||||
pub map: String,
|
||||
pub total_length: u32,
|
||||
pub supplemental: bool,
|
||||
pub has_real_damage: bool,
|
||||
pub has_weapon_damage: bool,
|
||||
pub has_accuracy: bool,
|
||||
pub has_hp: bool,
|
||||
pub has_hp_real: bool,
|
||||
pub has_hs: bool,
|
||||
pub has_hs_hit: bool,
|
||||
pub has_bs: bool,
|
||||
pub has_cp: bool,
|
||||
pub has_sb: bool,
|
||||
pub has_dt: bool,
|
||||
pub has_as: bool,
|
||||
pub has_hr: bool,
|
||||
pub has_intel: bool,
|
||||
pub ad_scoring: bool,
|
||||
pub title: String,
|
||||
pub date: u64,
|
||||
pub uploader: Uploader,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Round {
|
||||
pub start_time: u64,
|
||||
pub winner: TeamId,
|
||||
pub first_cap: TeamId,
|
||||
pub length: u32,
|
||||
pub team: Teams,
|
||||
pub players: HashMap<SteamID, RoundPlayer>,
|
||||
pub events: Vec<Event>,
|
||||
}
|
||||
|
||||
impl From<RawLog> for NormalizedLog {
|
||||
fn from(raw: RawLog) -> Self {
|
||||
let info = Info {
|
||||
map: raw.info.map,
|
||||
total_length: raw.info.total_length,
|
||||
supplemental: raw.info.supplemental,
|
||||
has_real_damage: raw.info.has_real_damage,
|
||||
has_weapon_damage: raw.info.has_weapon_damage,
|
||||
has_accuracy: raw.info.has_accuracy,
|
||||
has_hp: raw.info.has_hp,
|
||||
has_hp_real: raw.info.has_hp_real,
|
||||
has_hs: raw.info.has_hs,
|
||||
has_hs_hit: raw.info.has_hs_hit,
|
||||
has_bs: raw.info.has_bs,
|
||||
has_cp: raw.info.has_cp,
|
||||
has_sb: raw.info.has_sb,
|
||||
has_dt: raw.info.has_dt,
|
||||
has_as: raw.info.has_as,
|
||||
has_hr: raw.info.has_hr,
|
||||
has_intel: raw.info.has_intel,
|
||||
ad_scoring: raw.info.ad_scoring,
|
||||
title: raw.info.title,
|
||||
date: raw.info.date,
|
||||
uploader: raw.info.uploader,
|
||||
};
|
||||
let rounds: Vec<Round> = raw
|
||||
.rounds
|
||||
.or(raw.info.rounds)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|raw| Round::from(raw))
|
||||
.collect();
|
||||
let teams = raw.teams.or(raw.info.teams).unwrap();
|
||||
|
||||
let mut normalized = NormalizedLog {
|
||||
version: raw.version,
|
||||
length: raw.length,
|
||||
teams,
|
||||
players: raw.players,
|
||||
names: raw.names,
|
||||
rounds,
|
||||
heal_spread: raw.heal_spread,
|
||||
class_kills: raw.class_kills,
|
||||
class_deaths: raw.class_deaths,
|
||||
class_kill_assists: raw.class_kill_assists.unwrap_or_default(),
|
||||
chat: raw.chat,
|
||||
info,
|
||||
};
|
||||
|
||||
normalize_stopwatch_events(&mut normalized);
|
||||
normalize_event_times(&mut normalized);
|
||||
normalize_stopwatch_score(&mut normalized);
|
||||
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::raw::Round> for Round {
|
||||
fn from(raw: crate::raw::Round) -> Self {
|
||||
let first_cap = raw
|
||||
.first_cap
|
||||
.or_else(|| {
|
||||
raw.events.iter().find_map(|event| match event {
|
||||
Event::PointCap { team, .. } => Some(*team),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
let team = raw.team.or(raw.flat_team).unwrap();
|
||||
|
||||
Round {
|
||||
start_time: raw.start_time,
|
||||
winner: raw.winner,
|
||||
first_cap,
|
||||
length: raw.length,
|
||||
team,
|
||||
players: raw.players,
|
||||
events: raw.events,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_is_stopwatch(map: &str) -> bool {
|
||||
if map.starts_with("pl_") {
|
||||
true
|
||||
} else if map.starts_with("cp_steel") {
|
||||
true
|
||||
} else if map.starts_with("cp_gravelpit") {
|
||||
true
|
||||
} else if map.starts_with("cp_dustbowl") {
|
||||
true
|
||||
} else if map.starts_with("cp_egypt") {
|
||||
true
|
||||
} else if map.starts_with("cp_degrootkeep") {
|
||||
true
|
||||
} else if map.starts_with("cp_gorge") {
|
||||
true
|
||||
} else if map.starts_with("cp_junction") {
|
||||
true
|
||||
} else if map.starts_with("cp_mossrock") {
|
||||
true
|
||||
} else if map.starts_with("cp_manor") {
|
||||
true
|
||||
} else if map.starts_with("cp_snowplow") {
|
||||
true
|
||||
} else if map.starts_with("cp_alloy") {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Add missing round wins for 2nd round blue win
|
||||
fn normalize_stopwatch_events(log: &mut NormalizedLog) {
|
||||
if map_is_stopwatch(&log.info.map)
|
||||
&& log.rounds.len() >= 2
|
||||
&& log.rounds[1].winner == TeamId::Blue
|
||||
{
|
||||
let first_half_rounds = get_round_point_capped(&log.rounds[0]);
|
||||
let second_half_rounds = get_round_point_capped(&log.rounds[1]);
|
||||
let second_half_end_time = get_round_end_time(&log.rounds[1]);
|
||||
|
||||
// attackers won 2nd round so they have to have at least the same number of point caps
|
||||
// however some old demos dont properly include the last cap so we add them
|
||||
if second_half_rounds < first_half_rounds {
|
||||
let last_event = log.rounds[1].events.pop();
|
||||
log.rounds[1].events.push(Event::PointCap {
|
||||
time: second_half_end_time,
|
||||
team: TeamId::Blue,
|
||||
point: first_half_rounds,
|
||||
});
|
||||
log.rounds[1].events.push(last_event.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_round_end_time(round: &Round) -> u32 {
|
||||
round
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::RoundWin { time, .. } => Some(*time),
|
||||
_ => None,
|
||||
})
|
||||
.last()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_first_event_time(round: &Round) -> u32 {
|
||||
round
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| Some(event.time()))
|
||||
.last()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_round_point_capped(round: &Round) -> u8 {
|
||||
round
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::PointCap { point, .. } => Some(*point),
|
||||
_ => None,
|
||||
})
|
||||
.last()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Old logs have event times reset each round, newer ones keep counting
|
||||
fn normalize_event_times(log: &mut NormalizedLog) {
|
||||
let mut prev_round_end_time = 0;
|
||||
for round in log.rounds.iter_mut() {
|
||||
if get_first_event_time(round) < prev_round_end_time {
|
||||
round.events.iter_mut().for_each(|event| match event {
|
||||
Event::PointCap { time, .. } => *time += prev_round_end_time,
|
||||
Event::Charge { time, .. } => *time += prev_round_end_time,
|
||||
Event::Drop { time, .. } => *time += prev_round_end_time,
|
||||
Event::MedicDeath { time, .. } => *time += prev_round_end_time,
|
||||
Event::RoundWin { time, .. } => *time += prev_round_end_time,
|
||||
});
|
||||
}
|
||||
prev_round_end_time = get_round_end_time(round);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_last_cap_time(round: &Round) -> u32 {
|
||||
round
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::PointCap { time, .. } => Some(*time),
|
||||
_ => None,
|
||||
})
|
||||
.last()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Apply modern ad scoring to old demos
|
||||
fn normalize_stopwatch_score(log: &mut NormalizedLog) {
|
||||
if !log.info.ad_scoring && map_is_stopwatch(&log.info.map) && log.rounds.len() == 2 {
|
||||
let first_half_capped = get_round_point_capped(&log.rounds[0]);
|
||||
let second_half_capped = get_round_point_capped(&log.rounds[1]);
|
||||
|
||||
// "blue" is the team that attacked first
|
||||
if first_half_capped > second_half_capped {
|
||||
log.teams.blue.score = 1;
|
||||
log.teams.red.score = 0;
|
||||
} else if second_half_capped > first_half_capped {
|
||||
log.teams.blue.score = 0;
|
||||
log.teams.red.score = 1;
|
||||
} else {
|
||||
let first_half_cap_time = get_last_cap_time(&log.rounds[0]);
|
||||
let second_half_cap_time =
|
||||
get_last_cap_time(&log.rounds[1]) - get_round_end_time(&log.rounds[0]);
|
||||
|
||||
if first_half_cap_time < second_half_cap_time {
|
||||
log.teams.blue.score = 1;
|
||||
log.teams.red.score = 0;
|
||||
} else {
|
||||
log.teams.blue.score = 0;
|
||||
log.teams.red.score = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use test_case::test_case;
|
||||
|
||||
#[test_case("134389.json", 0, 1)]
|
||||
#[test_case("550237.json", 1, 0)]
|
||||
fn test_normalize_stopwatch_score(file: &str, blue: u8, red: u8) {
|
||||
let content = fs::read_to_string(format!("tests/data/{}", file)).unwrap();
|
||||
let parsed: NormalizedLog = serde_json::from_str(&content).unwrap();
|
||||
|
||||
assert_eq!(parsed.teams.blue.score, blue);
|
||||
assert_eq!(parsed.teams.red.score, red);
|
||||
}
|
||||
|
||||
#[test_case("1.json")]
|
||||
#[test_case("134389.json")]
|
||||
#[test_case("550237.json")]
|
||||
#[test_case("2522305.json")]
|
||||
fn test_normalize_event_time(file: &str) {
|
||||
let content = fs::read_to_string(format!("tests/data/{}", file)).unwrap();
|
||||
let parsed: NormalizedLog = serde_json::from_str(&content).unwrap();
|
||||
|
||||
let mut last_event_time = 0;
|
||||
|
||||
for event in parsed.rounds.iter().flat_map(|round| round.events.iter()) {
|
||||
assert!(event.time() >= last_event_time);
|
||||
last_event_time = event.time();
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/raw.rs
63
src/raw.rs
|
|
@ -1,8 +1,8 @@
|
|||
use crate::data::{Class, Medigun, TeamId, Weapon};
|
||||
use serde::export::TryFrom;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use steamid_ng::SteamID;
|
||||
use crate::data::{Medigun, Class, Weapon, Team as TeamId};
|
||||
use serde::Deserialize;
|
||||
use serde::export::TryFrom;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RawLog {
|
||||
|
|
@ -14,10 +14,14 @@ pub struct RawLog {
|
|||
pub players: HashMap<SteamID, Player>,
|
||||
pub names: HashMap<SteamID, String>,
|
||||
pub rounds: Option<Vec<Round>>,
|
||||
pub healspread: HashMap<SteamID, HashMap<SteamID, u32>>,
|
||||
pub classkills: HashMap<SteamID, ClassNumbers>,
|
||||
pub classdeaths: HashMap<SteamID, ClassNumbers>,
|
||||
pub classkillassists: Option<HashMap<SteamID, ClassNumbers>>,
|
||||
#[serde(rename = "healspread")]
|
||||
pub heal_spread: HashMap<SteamID, HashMap<SteamID, u32>>,
|
||||
#[serde(rename = "classkills")]
|
||||
pub class_kills: HashMap<SteamID, ClassNumbers>,
|
||||
#[serde(rename = "classdeaths")]
|
||||
pub class_deaths: HashMap<SteamID, ClassNumbers>,
|
||||
#[serde(rename = "classkillassists")]
|
||||
pub class_kill_assists: Option<HashMap<SteamID, ClassNumbers>>,
|
||||
pub chat: Vec<ChatMessage>,
|
||||
pub info: Info,
|
||||
}
|
||||
|
|
@ -31,10 +35,12 @@ pub struct Teams {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Team {
|
||||
pub score: u32,
|
||||
pub score: u8,
|
||||
#[serde(default)]
|
||||
pub kills: u32,
|
||||
#[serde(default)]
|
||||
pub deaths: u32,
|
||||
#[serde(default)]
|
||||
pub dmg: u32,
|
||||
#[serde(default)]
|
||||
pub charges: u32,
|
||||
|
|
@ -123,6 +129,8 @@ pub struct Round {
|
|||
pub first_cap: Option<TeamId>,
|
||||
pub length: u32,
|
||||
pub team: Option<Teams>,
|
||||
#[serde(flatten)]
|
||||
pub flat_team: Option<Teams>,
|
||||
pub players: HashMap<SteamID, RoundPlayer>,
|
||||
pub events: Vec<Event>,
|
||||
}
|
||||
|
|
@ -167,6 +175,18 @@ pub enum Event {
|
|||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn time(&self) -> u32 {
|
||||
match self {
|
||||
Event::RoundWin { time, .. } => *time,
|
||||
Event::Charge { time, .. } => *time,
|
||||
Event::Drop { time, .. } => *time,
|
||||
Event::MedicDeath { time, .. } => *time,
|
||||
Event::PointCap { time, .. } => *time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ClassNumbers {
|
||||
#[serde(default)]
|
||||
|
|
@ -250,24 +270,15 @@ pub struct Info {
|
|||
pub has_hr: bool,
|
||||
#[serde(default)]
|
||||
pub has_intel: bool,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "AD_scoring")]
|
||||
pub ad_scoring: bool,
|
||||
pub title: String,
|
||||
pub date: u64,
|
||||
pub uploader: Uploader,
|
||||
pub rounds: Option<Vec<Round>>,
|
||||
#[serde(flatten)]
|
||||
pub teams: Option<InfoTeams>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct InfoTeams {
|
||||
pub red: InfoTeam,
|
||||
pub blue: InfoTeam,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InfoTeam {
|
||||
pub score: u32
|
||||
pub teams: Option<Teams>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -279,16 +290,22 @@ pub struct Uploader {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use test_case::test_case;
|
||||
use super::*;
|
||||
|
||||
#[test_case("1.json")]
|
||||
#[test_case("134389.json")]
|
||||
#[test_case("550237.json")]
|
||||
#[test_case("2522305.json")]
|
||||
fn test_parse(file: &str) {
|
||||
let content = fs::read_to_string(format!("tests/data/{}", file)).unwrap();
|
||||
let parsed: RawLog = serde_json::from_str(&content).unwrap();
|
||||
assert!(parsed.teams.is_some() || parsed.info.teams.is_some());
|
||||
assert!(parsed.rounds.is_some() || parsed.info.rounds.is_some());
|
||||
|
||||
for round in parsed.rounds.or(parsed.info.rounds).unwrap() {
|
||||
assert!(round.flat_team.is_some() || round.team.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue