From fa21804cc3414eb307a79490d33f4465cd2bf575 Mon Sep 17 00:00:00 2001 From: Lol3rrr Date: Tue, 5 Nov 2024 07:50:43 +0100 Subject: [PATCH] Add Head-to-Head kill stats Add basic head-to-head kill analysis and the corresponding matrix display in the UI --- analysis/src/head_to_head.rs | 52 ++++++++++ analysis/src/lib.rs | 1 + analysis/tests/head_to_head.rs | 40 ++++++++ backend/src/analysis.rs | 4 +- backend/src/analysis/head_to_head.rs | 72 ++++++++++++++ backend/src/api/demos.rs | 44 +++++++++ backend/src/models.rs | 10 ++ backend/src/schema.rs | 10 ++ common/src/demo_analysis.rs | 7 ++ frontend/src/demo/scoreboard.rs | 4 +- frontend/src/demo/scoreboard/headtohead.rs | 98 +++++++++++++++++++ frontend/src/demo/scoreboard/utility.rs | 8 -- frontend/src/main.rs | 2 +- .../2024-11-04-220702_head-to-head/down.sql | 2 + .../2024-11-04-220702_head-to-head/up.sql | 8 ++ 15 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 analysis/src/head_to_head.rs create mode 100644 analysis/tests/head_to_head.rs create mode 100644 backend/src/analysis/head_to_head.rs create mode 100644 frontend/src/demo/scoreboard/headtohead.rs delete mode 100644 frontend/src/demo/scoreboard/utility.rs create mode 100644 migrations/2024-11-04-220702_head-to-head/down.sql create mode 100644 migrations/2024-11-04-220702_head-to-head/up.sql diff --git a/analysis/src/head_to_head.rs b/analysis/src/head_to_head.rs new file mode 100644 index 0000000..e2b705a --- /dev/null +++ b/analysis/src/head_to_head.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; + +#[derive(Debug, PartialEq)] +pub struct Output { + pub players: HashMap, + pub head_to_head: HashMap>, +} + +pub fn parse(buf: &[u8]) -> Result { + let tmp = csdemo::Container::parse(buf).map_err(|e| ())?; + + let output = csdemo::lazyparser::LazyParser::new(tmp); + + let players = output.player_info(); + + let mut head_to_head = HashMap::new(); + + for event in output.events().filter_map(|e| e.ok()) { + let event = match event { + csdemo::DemoEvent::GameEvent(ge) => *ge, + _ => continue, + }; + + match event { + csdemo::game_event::GameEvent::PlayerDeath(death) => { + let (attacker_player, attacker) = match death.attacker.and_then(|u| players.get(&u).zip(Some(u))) { + Some(a) => a, + None => continue, + }; + + let (died_player, died) = match death.userid.and_then(|u| players.get(&u).zip(Some(u))) { + Some(d) => d, + None => continue, + }; + + if attacker_player.team == died_player.team { + continue; + } + + let attacker_entry: &mut HashMap<_, _> = head_to_head.entry(attacker).or_default(); + let died_killed: &mut usize = attacker_entry.entry(died).or_default(); + *died_killed += 1; + } + _ => {} + }; + } + + Ok(Output { + players, + head_to_head, + }) +} diff --git a/analysis/src/lib.rs b/analysis/src/lib.rs index 1b90d50..14c5e0c 100644 --- a/analysis/src/lib.rs +++ b/analysis/src/lib.rs @@ -1,3 +1,4 @@ pub mod endofgame; pub mod heatmap; pub mod perround; +pub mod head_to_head; diff --git a/analysis/tests/head_to_head.rs b/analysis/tests/head_to_head.rs new file mode 100644 index 0000000..3d1e6b7 --- /dev/null +++ b/analysis/tests/head_to_head.rs @@ -0,0 +1,40 @@ +use analysis::head_to_head; + +use pretty_assertions::assert_eq; +use std::collections::HashMap; + +#[test] +#[ignore = "Testing"] +fn head_to_head_nuke() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../testfiles/nuke.dem"); + dbg!(path); + let input_bytes = std::fs::read(path).unwrap(); + + let result = head_to_head::parse(&input_bytes).unwrap(); + + let expected = head_to_head::Output { + players: [(csdemo::UserId(0), csdemo::parser::Player { + xuid: 0, + name: "".to_owned(), + team: 0, + color: 0, + })].into_iter().collect(), + head_to_head: [ + (csdemo::UserId(0), HashMap::new()), + (csdemo::UserId(1), HashMap::new()), + (csdemo::UserId(2), HashMap::new()), + (csdemo::UserId(3), HashMap::new()), + (csdemo::UserId(4), HashMap::new()), + (csdemo::UserId(5), HashMap::new()), + (csdemo::UserId(6), HashMap::new()), + (csdemo::UserId(7), HashMap::new()), + (csdemo::UserId(8), HashMap::new()), + (csdemo::UserId(9), HashMap::new()), + ].into_iter().collect(), + }; + + dbg!(&expected, &result); + assert_eq!(result, expected); + + todo!() +} diff --git a/backend/src/analysis.rs b/backend/src/analysis.rs index bcf380d..7b0f49d 100644 --- a/backend/src/analysis.rs +++ b/backend/src/analysis.rs @@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl; pub mod base; pub mod heatmap; pub mod perround; +pub mod head_to_head; #[derive(Debug, Clone)] pub enum AnalysisData { @@ -61,12 +62,13 @@ pub trait Analysis { >; } -pub static ANALYSIS_METHODS: std::sync::LazyLock<[std::sync::Arc; 3]> = +pub static ANALYSIS_METHODS: std::sync::LazyLock<[std::sync::Arc; 4]> = std::sync::LazyLock::new(|| { [ std::sync::Arc::new(base::BaseAnalysis::new()), std::sync::Arc::new(heatmap::HeatmapAnalysis::new()), std::sync::Arc::new(perround::PerRoundAnalysis::new()), + std::sync::Arc::new(head_to_head::HeadToHeadAnalysis::new()), ] }); diff --git a/backend/src/analysis/head_to_head.rs b/backend/src/analysis/head_to_head.rs new file mode 100644 index 0000000..6b1b626 --- /dev/null +++ b/backend/src/analysis/head_to_head.rs @@ -0,0 +1,72 @@ +use super::*; + +pub struct HeadToHeadAnalysis {} + +impl HeadToHeadAnalysis { + pub fn new() -> Self { + Self {} + } +} + +impl Analysis for HeadToHeadAnalysis { + #[tracing::instrument(name = "HeadToHead", skip(self, input))] + fn analyse( + &self, + input: AnalysisInput, + ) -> Result< + Box< + dyn FnOnce( + &mut diesel_async::pg::AsyncPgConnection, + ) -> core::pin::Pin< + Box< + (dyn core::future::Future> + + Send + + '_), + >, + > + Send, + >, + (), + > { + tracing::info!("Performing Head-to-Head analysis"); + + let result = analysis::head_to_head::parse(input.data()).inspect_err(|e| { + tracing::error!("{:?}", e); + }).map_err(|e| ())?; + + let values_to_insert: Vec<_> = result.head_to_head.into_iter().flat_map(|(user_id, enemies)| { + enemies.into_iter().map(move |(enemy, kills)| (user_id, enemy, kills)) + }).filter_map(|(user_id, enemy_id, kills)| { + let player = result.players.get(&user_id)?; + let enemy = result.players.get(&enemy_id)?; + + Some(crate::models::DemoHeadToHead { + demo_id: input.demoid.clone(), + player: player.xuid.to_string(), + enemy: enemy.xuid.to_string(), + kills: kills as i16, + }) + }).collect(); + + Ok(Box::new(move |connection| { + // TODO + // Construct the actual queries + + let query = diesel::insert_into(crate::schema::demo_head_to_head::dsl::demo_head_to_head) + .values(values_to_insert) + .on_conflict(( + crate::schema::demo_head_to_head::dsl::demo_id, + crate::schema::demo_head_to_head::dsl::player, + crate::schema::demo_head_to_head::dsl::enemy, + )) + .do_update().set(crate::schema::demo_head_to_head::kills.eq(diesel::upsert::excluded(crate::schema::demo_head_to_head::kills))); + + Box::pin(async move { + // TODO + // Execute queries + query.execute(connection).await?; + + Ok(()) + }) + })) + } +} diff --git a/backend/src/api/demos.rs b/backend/src/api/demos.rs index dfd2ced..ae93089 100644 --- a/backend/src/api/demos.rs +++ b/backend/src/api/demos.rs @@ -22,6 +22,7 @@ pub fn router(storage: Box) -> axum::Router { .route("/:id/analysis/scoreboard", axum::routing::get(scoreboard)) .route("/:id/analysis/perround", axum::routing::get(perround)) .route("/:id/analysis/heatmap", axum::routing::get(heatmap)) + .route("/:id/analysis/headtohead", axum::routing::get(head_to_head)) .with_state(Arc::new(DemoState { storage })) } @@ -556,6 +557,49 @@ async fn perround( })) } +#[tracing::instrument(skip(session))] +async fn head_to_head( + session: UserSession, + Path(demo_id): Path, +) -> Result, axum::http::StatusCode> { + let mut db_con = crate::db_connection().await; + + let head_to_head_query = crate::schema::demo_head_to_head::dsl::demo_head_to_head + .filter(crate::schema::demo_head_to_head::dsl::demo_id.eq(demo_id.clone())); + + let player_query = crate::schema::demo_players::dsl::demo_players + .filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id)); + + let (players, head_to_head_entries) = db_con.build_transaction().read_only().run(|connection| Box::pin(async move { + let head_to_head_entries: Vec = head_to_head_query.load(connection).await?; + let players: Vec = player_query.load(connection).await?; + + Ok::<_, diesel::result::Error>((players, head_to_head_entries)) + })).await.map_err(|e| { + tracing::error!("Querying DB: {:?}", e); + axum::http::StatusCode::INTERNAL_SERVER_ERROR + })?; + + let (mut row_team, mut column_team): (Vec<_>, Vec<_>) = players.into_iter().partition(|p| p.team == 2); + row_team.sort_unstable_by_key(|p| p.color); + column_team.sort_unstable_by_key(|p| p.color); + + let results: Vec<_> = row_team.iter().map(|row_player| { + column_team.iter().map(|column_player| { + let row_kills = head_to_head_entries.iter().find(|entry| entry.player == row_player.steam_id && entry.enemy == column_player.steam_id); + let column_kills = head_to_head_entries.iter().find(|entry| entry.player == column_player.steam_id && entry.enemy == row_player.steam_id); + + (row_kills.map(|k| k.kills).unwrap_or(0), column_kills.map(|k| k.kills).unwrap_or(0)) + }).collect::>() + }).collect(); + + Ok(axum::Json(common::demo_analysis::HeadToHead { + row_players: row_team.into_iter().map(|p| p.name).collect(), + column_players: column_team.into_iter().map(|p| p.name).collect(), + entries: results, + })) +} + // The corresponding values for each map can be found using the Source2 Viewer and opening the // files in 'game/csgo/pak01_dir.vpk' and then 'resource/overviews/{map}.txt' static MINIMAP_COORDINATES: phf::Map<&str, MiniMapDefinition> = phf::phf_map! { diff --git a/backend/src/models.rs b/backend/src/models.rs index 7237b6c..2f2b7e0 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -120,3 +120,13 @@ pub struct DemoRound { pub win_reason: String, pub events: serde_json::Value, } + +#[derive(Queryable, Selectable, Insertable, Debug)] +#[diesel(table_name = crate::schema::demo_head_to_head)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct DemoHeadToHead { + pub demo_id: String, + pub player: String, + pub enemy: String, + pub kills: i16, +} diff --git a/backend/src/schema.rs b/backend/src/schema.rs index e0e6c35..29679aa 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -8,6 +8,15 @@ diesel::table! { } } +diesel::table! { + demo_head_to_head (demo_id, player, enemy) { + demo_id -> Text, + player -> Text, + enemy -> Text, + kills -> Int2, + } +} + diesel::table! { demo_heatmaps (demo_id, steam_id, team) { demo_id -> Text, @@ -97,6 +106,7 @@ diesel::table! { diesel::allow_tables_to_appear_in_same_query!( analysis_queue, + demo_head_to_head, demo_heatmaps, demo_info, demo_player_stats, diff --git a/common/src/demo_analysis.rs b/common/src/demo_analysis.rs index cc57b1e..40bdd39 100644 --- a/common/src/demo_analysis.rs +++ b/common/src/demo_analysis.rs @@ -82,3 +82,10 @@ pub enum RoundEvent { headshot: bool, }, } + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HeadToHead { + pub row_players: Vec, + pub column_players: Vec, + pub entries: Vec>, +} diff --git a/frontend/src/demo/scoreboard.rs b/frontend/src/demo/scoreboard.rs index e873043..22ffa7e 100644 --- a/frontend/src/demo/scoreboard.rs +++ b/frontend/src/demo/scoreboard.rs @@ -2,7 +2,7 @@ use leptos::*; use leptos_router::Outlet; pub mod general; -pub mod utility; +pub mod headtohead; use crate::demo::TabBar; @@ -12,7 +12,7 @@ pub fn scoreboard() -> impl leptos::IntoView { let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default()); view! { - + } diff --git a/frontend/src/demo/scoreboard/headtohead.rs b/frontend/src/demo/scoreboard/headtohead.rs new file mode 100644 index 0000000..9231f2b --- /dev/null +++ b/frontend/src/demo/scoreboard/headtohead.rs @@ -0,0 +1,98 @@ +use leptos::*; +use leptos::Suspense; + +#[leptos::component] +pub fn head_to_head() -> impl leptos::IntoView { + let head_to_head_resource = + create_resource(leptos_router::use_params_map(), |params| async move { + let id = params.get("id").unwrap(); + + let res = + reqwasm::http::Request::get(&format!("/api/demos/{}/analysis/headtohead", id)) + .send() + .await + .unwrap(); + res.json::() + .await + .unwrap() + }); + + let style = stylers::style! { + "Head-to-Head-root", + div { + display: grid; + } + }; + + let matrix_view = move || head_to_head_resource.get().map(|r| view! { + + }); + + view! { + class=style, + Loading Head-to-Head data...

}> +
+ { matrix_view } +
+
+ } +} + +#[leptos::component] +fn matrix(data: common::demo_analysis::HeadToHead) -> impl leptos::IntoView { + let row_player_view = move || { + data.row_players.iter().enumerate().map(|(idx, name)| { + view! { + {name} + } + }).collect::>() + }; + + let column_player_view = move || { + data.column_players.iter().enumerate().map(|(idx, name)| { + view! { + {name} + } + }).collect::>() + }; + + let style = stylers::style! { + "Head-to-Head-Matrix-Cell", + .cell { + display: grid; + + } + .cell_back { + grid-row: 1/3; + grid-column: 1/3; + + background-color: var(--color-surface-a0); + background-image: linear-gradient(to right top, var(--color-surface-a0) 50%, var(--color-surface-a10) 50%); + } + }; + + let entry_view = move || { + data.entries.iter().enumerate().flat_map(|(row, row_data)| { + row_data.iter().enumerate().map(move |(column, &(row_kills, column_kills))| { + let entry = move || view! { +
{ row_kills }
+
{ column_kills }
+ }; + + view! { + class = style, +
+
+ { entry } +
+ } + }) + }).collect::>() + }; + + view! { + { row_player_view } + { column_player_view } + { entry_view } + } +} diff --git a/frontend/src/demo/scoreboard/utility.rs b/frontend/src/demo/scoreboard/utility.rs deleted file mode 100644 index e673cd4..0000000 --- a/frontend/src/demo/scoreboard/utility.rs +++ /dev/null @@ -1,8 +0,0 @@ -use leptos::*; - -#[leptos::component] -pub fn utility() -> impl leptos::IntoView { - view! { - Utility - } -} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 765565c..0780983 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -22,7 +22,7 @@ fn main() { - + diff --git a/migrations/2024-11-04-220702_head-to-head/down.sql b/migrations/2024-11-04-220702_head-to-head/down.sql new file mode 100644 index 0000000..a368113 --- /dev/null +++ b/migrations/2024-11-04-220702_head-to-head/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE demo_head_to_head; diff --git a/migrations/2024-11-04-220702_head-to-head/up.sql b/migrations/2024-11-04-220702_head-to-head/up.sql new file mode 100644 index 0000000..8692b5a --- /dev/null +++ b/migrations/2024-11-04-220702_head-to-head/up.sql @@ -0,0 +1,8 @@ +-- Your SQL goes here +CREATE TABLE IF NOT EXISTS demo_head_to_head ( + demo_id TEXT NOT NULL, + player TEXT NOT NULL, + enemy TEXT NOT NULL, + kills int2 NOT NULL, + PRIMARY KEY (demo_id, player, enemy) +);