initial normalization

This commit is contained in:
Robin Appelman 2020-04-19 23:58:35 +02:00
commit 1b256ecfd9
6 changed files with 4535 additions and 32 deletions

View file

@ -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 {

View file

@ -1,5 +1,6 @@
mod data;
mod raw;
mod normalized;
fn main() {
println!("Hello, world!");

321
src/normalized.rs Normal file
View 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();
}
}
}

View file

@ -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());
}
}
}

1626
tests/data/134389.json Normal file

File diff suppressed because it is too large Load diff

2538
tests/data/2522305.json Normal file

File diff suppressed because it is too large Load diff