Compare commits
11 Commits
2019c66c23
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edc8aaa27 | ||
|
|
48e7c5c5b7 | ||
|
|
fa21804cc3 | ||
|
|
ecfed6b739 | ||
|
|
be220291f6 | ||
|
|
e01cbd0a51 | ||
|
|
898a889a53 | ||
|
|
7e50a627f6 | ||
|
|
e8843540a3 | ||
|
|
101833d0d8 | ||
|
|
1d6b6555b1 |
49
Cargo.lock
generated
49
Cargo.lock
generated
@@ -38,6 +38,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"colors-transform",
|
||||
"csdemo",
|
||||
"divan",
|
||||
"image",
|
||||
"phf",
|
||||
"pretty_assertions",
|
||||
@@ -554,6 +555,7 @@ dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
"terminal_size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -606,6 +608,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "condtype"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.14.1"
|
||||
@@ -927,6 +935,31 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "divan"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e05d17bd4ff1c1e7998ed4623d2efd91f72f1e24141ac33aac9377974270e1f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"clap",
|
||||
"condtype",
|
||||
"divan-macros",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "divan-macros"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b4464d46ce68bfc7cb76389248c7c254def7baca8bece0693b02b83842c4c88"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
@@ -2933,6 +2966,12 @@ dependencies = [
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.29"
|
||||
@@ -3709,6 +3748,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminal_size"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.66"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Knifer
|
||||
A self-hosted demo analysis tool
|
||||
A self-hosted demo analysis tool.
|
||||
Migrated from [GitHub](https://github.com/Lol3rrr/knifer)
|
||||
|
||||
## Usage
|
||||
### Environment Variables
|
||||
|
||||
@@ -17,3 +17,9 @@ phf = { version = "0.11" }
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { version = "1.4" }
|
||||
tracing-test = { version = "0.2", features = ["no-env-filter"] }
|
||||
|
||||
divan = "0.1.15"
|
||||
|
||||
[[bench]]
|
||||
name = "analysis"
|
||||
harness = false
|
||||
|
||||
35
analysis/benches/analysis.rs
Normal file
35
analysis/benches/analysis.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
fn main() {
|
||||
divan::main();
|
||||
}
|
||||
|
||||
#[divan::bench(args = ["dust2.dem", "inferno.dem", "nuke.dem"])]
|
||||
fn heatmap(bencher: divan::Bencher, file: &str) {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../testfiles/")
|
||||
.join(file);
|
||||
let data = std::fs::read(path).unwrap();
|
||||
|
||||
let config = analysis::heatmap::Config { cell_size: 2.0 };
|
||||
|
||||
bencher.bench(|| analysis::heatmap::parse(divan::black_box(&config), divan::black_box(&data)));
|
||||
}
|
||||
|
||||
#[divan::bench(args = ["dust2.dem", "inferno.dem", "nuke.dem"])]
|
||||
fn perround(bencher: divan::Bencher, file: &str) {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../testfiles/")
|
||||
.join(file);
|
||||
let data = std::fs::read(path).unwrap();
|
||||
|
||||
bencher.bench(|| analysis::perround::parse(divan::black_box(&data)));
|
||||
}
|
||||
|
||||
#[divan::bench(args = ["dust2.dem", "inferno.dem", "nuke.dem"])]
|
||||
fn endofgame(bencher: divan::Bencher, file: &str) {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../testfiles/")
|
||||
.join(file);
|
||||
let data = std::fs::read(path).unwrap();
|
||||
|
||||
bencher.bench(|| analysis::endofgame::parse(divan::black_box(&data)));
|
||||
}
|
||||
52
analysis/src/head_to_head.rs
Normal file
52
analysis/src/head_to_head.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Output {
|
||||
pub players: HashMap<csdemo::UserId, csdemo::parser::Player>,
|
||||
pub head_to_head: HashMap<csdemo::UserId, HashMap<csdemo::UserId, usize>>,
|
||||
}
|
||||
|
||||
pub fn parse(buf: &[u8]) -> Result<Output, ()> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod endofgame;
|
||||
pub mod heatmap;
|
||||
pub mod perround;
|
||||
pub mod head_to_head;
|
||||
|
||||
@@ -60,7 +60,16 @@ pub struct Round {
|
||||
pub enum RoundEvent {
|
||||
BombPlanted,
|
||||
BombDefused,
|
||||
Kill { attacker: u64, died: u64 },
|
||||
Kill {
|
||||
attacker: u64,
|
||||
died: u64,
|
||||
#[serde(default)]
|
||||
weapon: Option<String>,
|
||||
#[serde(default)]
|
||||
headshot: bool,
|
||||
#[serde(default)]
|
||||
noscope: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -138,7 +147,7 @@ pub fn parse(buf: &[u8]) -> Result<PerRound, ()> {
|
||||
};
|
||||
}
|
||||
|
||||
let event = match ge.as_ref() {
|
||||
let event = match *ge {
|
||||
csdemo::game_event::GameEvent::BombPlanted(planted) => RoundEvent::BombPlanted,
|
||||
csdemo::game_event::GameEvent::BombDefused(defused) => RoundEvent::BombDefused,
|
||||
csdemo::game_event::GameEvent::PlayerDeath(death) => {
|
||||
@@ -157,6 +166,9 @@ pub fn parse(buf: &[u8]) -> Result<PerRound, ()> {
|
||||
RoundEvent::Kill {
|
||||
attacker: attacker_player.xuid,
|
||||
died: died_player.xuid,
|
||||
weapon: death.weapon,
|
||||
noscope: death.noscope.unwrap_or(false),
|
||||
headshot: death.headshot.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
|
||||
40
analysis/tests/head_to_head.rs
Normal file
40
analysis/tests/head_to_head.rs
Normal file
@@ -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!()
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -23,8 +24,8 @@ impl AnalysisInput {
|
||||
steamid: String,
|
||||
demoid: String,
|
||||
storage: &dyn crate::storage::DemoStorage,
|
||||
) -> Result<Self, ()> {
|
||||
let data = storage.load(steamid.clone(), demoid.clone()).await.unwrap();
|
||||
) -> Result<Self, String> {
|
||||
let data = storage.load(steamid.clone(), demoid.clone()).await?;
|
||||
|
||||
Ok(Self {
|
||||
steamid,
|
||||
@@ -61,12 +62,13 @@ pub trait Analysis {
|
||||
>;
|
||||
}
|
||||
|
||||
pub static ANALYSIS_METHODS: std::sync::LazyLock<[std::sync::Arc<dyn Analysis + Send + Sync>; 3]> =
|
||||
pub static ANALYSIS_METHODS: std::sync::LazyLock<[std::sync::Arc<dyn Analysis + Send + Sync>; 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()),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -111,6 +113,8 @@ where
|
||||
let result = db_con
|
||||
.build_transaction()
|
||||
.run::<_, TaskError<AE>, _>(|conn| {
|
||||
|
||||
|
||||
Box::pin(async move {
|
||||
let mut results: Vec<crate::models::AnalysisTask> = query.load(conn).await?;
|
||||
let task = match results.pop() {
|
||||
@@ -118,21 +122,28 @@ where
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let input = AnalysisInput::load(
|
||||
task.steam_id.clone(),
|
||||
task.demo_id.clone(),
|
||||
let delete_query =
|
||||
diesel::dsl::delete(crate::schema::analysis_queue::dsl::analysis_queue)
|
||||
.filter(crate::schema::analysis_queue::dsl::demo_id.eq(task.demo_id.clone()))
|
||||
.filter(crate::schema::analysis_queue::dsl::steam_id.eq(task.steam_id.clone()));
|
||||
|
||||
let input = match AnalysisInput::load(
|
||||
task.steam_id,
|
||||
task.demo_id,
|
||||
storage.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::error!("Loading Analysis Input: {:?}", e);
|
||||
delete_query.execute(conn).await?;
|
||||
return Ok(Some(()));
|
||||
}
|
||||
};
|
||||
|
||||
let tmp = action(input, &mut *conn);
|
||||
tmp.await.map_err(|e| TaskError::RunningAction(e))?;
|
||||
|
||||
let delete_query =
|
||||
diesel::dsl::delete(crate::schema::analysis_queue::dsl::analysis_queue)
|
||||
.filter(crate::schema::analysis_queue::dsl::demo_id.eq(task.demo_id))
|
||||
.filter(crate::schema::analysis_queue::dsl::steam_id.eq(task.steam_id));
|
||||
delete_query.execute(conn).await?;
|
||||
|
||||
Ok(Some(()))
|
||||
|
||||
72
backend/src/analysis/head_to_head.rs
Normal file
72
backend/src/analysis/head_to_head.rs
Normal file
@@ -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<Output = Result<(), diesel::result::Error>>
|
||||
+ 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(())
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,16 @@ impl Analysis for PerRoundAnalysis {
|
||||
Box::pin(async move {
|
||||
let query = diesel::dsl::insert_into(crate::schema::demo_round::dsl::demo_round)
|
||||
.values(&values)
|
||||
.on_conflict_do_nothing();
|
||||
.on_conflict((
|
||||
crate::schema::demo_round::dsl::demo_id,
|
||||
crate::schema::demo_round::dsl::round_number,
|
||||
))
|
||||
.do_update()
|
||||
.set(
|
||||
crate::schema::demo_round::dsl::events.eq(diesel::upsert::excluded(
|
||||
crate::schema::demo_round::dsl::events,
|
||||
)),
|
||||
);
|
||||
|
||||
query.execute(connection).await?;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn router(storage: Box<dyn crate::storage::DemoStorage>) -> 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 }))
|
||||
}
|
||||
|
||||
@@ -44,12 +45,22 @@ async fn list(
|
||||
crate::schema::demo_teams::table
|
||||
.on(crate::schema::demos::dsl::demo_id.eq(crate::schema::demo_teams::dsl::demo_id)),
|
||||
)
|
||||
.inner_join(
|
||||
crate::schema::demo_players::table
|
||||
.on(crate::schema::demos::dsl::demo_id
|
||||
.eq(crate::schema::demo_players::dsl::demo_id)),
|
||||
)
|
||||
.select((
|
||||
crate::models::Demo::as_select(),
|
||||
crate::models::DemoInfo::as_select(),
|
||||
crate::models::DemoTeam::as_select(),
|
||||
crate::models::DemoPlayer::as_select(),
|
||||
))
|
||||
.filter(crate::schema::demos::dsl::steam_id.eq(steam_id.to_string()));
|
||||
.filter(
|
||||
crate::schema::demos::dsl::steam_id
|
||||
.eq(steam_id.to_string())
|
||||
.and(crate::schema::demo_players::dsl::steam_id.eq(steam_id.to_string())),
|
||||
);
|
||||
let pending_query = crate::schema::demos::dsl::demos
|
||||
.inner_join(crate::schema::processing_status::table.on(
|
||||
crate::schema::demos::dsl::demo_id.eq(crate::schema::processing_status::dsl::demo_id),
|
||||
@@ -72,6 +83,7 @@ async fn list(
|
||||
crate::models::Demo,
|
||||
crate::models::DemoInfo,
|
||||
crate::models::DemoTeam,
|
||||
crate::models::DemoPlayer,
|
||||
)> = done_query.load(con).await?;
|
||||
|
||||
let pending_results: Vec<(crate::models::Demo)> = pending_query.load(con).await?;
|
||||
@@ -83,7 +95,7 @@ async fn list(
|
||||
.unwrap();
|
||||
|
||||
let mut demos = std::collections::HashMap::new();
|
||||
for (demo, info, team) in results.into_iter() {
|
||||
for (demo, info, team, player) in results.into_iter() {
|
||||
let entry = demos
|
||||
.entry(demo.demo_id.clone())
|
||||
.or_insert(common::BaseDemoInfo {
|
||||
@@ -92,6 +104,7 @@ async fn list(
|
||||
uploaded_at: demo.uploaded_at,
|
||||
team2_score: 0,
|
||||
team3_score: 0,
|
||||
player_team: player.team,
|
||||
});
|
||||
|
||||
if team.team == 2 {
|
||||
@@ -287,18 +300,34 @@ async fn scoreboard(
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id));
|
||||
.filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id.clone()));
|
||||
|
||||
let team_query = crate::schema::demo_teams::dsl::demo_teams
|
||||
.filter(crate::schema::demo_teams::dsl::demo_id.eq(demo_id));
|
||||
|
||||
let mut db_con = crate::db_connection().await;
|
||||
|
||||
let response: Vec<(crate::models::DemoPlayer, crate::models::DemoPlayerStats)> =
|
||||
match query.load(&mut db_con).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("Querying DB: {:?}", e);
|
||||
return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
let db_result = db_con
|
||||
.build_transaction()
|
||||
.read_only()
|
||||
.run::<_, diesel::result::Error, _>(|con| {
|
||||
Box::pin(async move {
|
||||
let players: Vec<(crate::models::DemoPlayer, crate::models::DemoPlayerStats)> =
|
||||
query.load(con).await?;
|
||||
let teams: Vec<crate::models::DemoTeam> = team_query.load(con).await?;
|
||||
|
||||
Ok((players, teams))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
let (response, team_response) = match db_result {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("Querying DB {:?}", e);
|
||||
return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
if response.is_empty() {
|
||||
tracing::error!("DB Response was empty");
|
||||
@@ -307,9 +336,16 @@ async fn scoreboard(
|
||||
|
||||
let mut teams = std::collections::BTreeMap::new();
|
||||
for (player, stats) in response {
|
||||
let team = teams.entry(player.team as u32).or_insert(Vec::new());
|
||||
let team =
|
||||
teams
|
||||
.entry(player.team as u32)
|
||||
.or_insert(common::demo_analysis::ScoreBoardTeam {
|
||||
number: player.team as u32,
|
||||
players: Vec::new(),
|
||||
score: 0,
|
||||
});
|
||||
|
||||
team.push(common::demo_analysis::ScoreBoardPlayer {
|
||||
team.players.push(common::demo_analysis::ScoreBoardPlayer {
|
||||
name: player.name,
|
||||
kills: stats.kills as usize,
|
||||
deaths: stats.deaths as usize,
|
||||
@@ -318,8 +354,15 @@ async fn scoreboard(
|
||||
});
|
||||
}
|
||||
|
||||
for team in team_response {
|
||||
let number = team.team as u32;
|
||||
if let Some(entry) = teams.get_mut(&number) {
|
||||
entry.score = team.end_score;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(axum::Json(common::demo_analysis::ScoreBoard {
|
||||
teams: teams.into_iter().collect::<Vec<_>>(),
|
||||
teams: teams.into_values().collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -463,7 +506,13 @@ async fn perround(
|
||||
analysis::perround::RoundEvent::BombDefused => {
|
||||
common::demo_analysis::RoundEvent::BombDefused
|
||||
}
|
||||
analysis::perround::RoundEvent::Kill { attacker, died } => {
|
||||
analysis::perround::RoundEvent::Kill {
|
||||
attacker,
|
||||
died,
|
||||
weapon,
|
||||
noscope,
|
||||
headshot,
|
||||
} => {
|
||||
let attacker_name = players
|
||||
.iter()
|
||||
.find(|p| p.steam_id == attacker.to_string())
|
||||
@@ -478,6 +527,9 @@ async fn perround(
|
||||
common::demo_analysis::RoundEvent::Killed {
|
||||
attacker: attacker_name,
|
||||
died: died_name,
|
||||
weapon,
|
||||
headshot,
|
||||
noscope,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -505,6 +557,49 @@ async fn perround(
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(session))]
|
||||
async fn head_to_head(
|
||||
session: UserSession,
|
||||
Path(demo_id): Path<String>,
|
||||
) -> Result<axum::response::Json<common::demo_analysis::HeadToHead>, 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<crate::models::DemoHeadToHead> = head_to_head_query.load(connection).await?;
|
||||
let players: Vec<crate::models::DemoPlayer> = 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::<Vec<_>>()
|
||||
}).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! {
|
||||
|
||||
36
backend/src/gc.rs
Normal file
36
backend/src/gc.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
|
||||
#[tracing::instrument(skip(storage))]
|
||||
pub async fn run_gc(storage: &mut dyn crate::storage::DemoStorage) -> Result<(), ()> {
|
||||
let stored_demos = match storage.list_demos().await {
|
||||
Ok(ds) => ds,
|
||||
Err(e) => return Err(()),
|
||||
};
|
||||
|
||||
tracing::info!("Found {} demos in storage", stored_demos.len());
|
||||
|
||||
let mut db_con = crate::db_connection().await;
|
||||
|
||||
let db_res = db_con
|
||||
.build_transaction()
|
||||
.run(move |con| {
|
||||
Box::pin(async move {
|
||||
for demo in stored_demos {
|
||||
let query = crate::schema::demos::dsl::demos
|
||||
.filter(crate::schema::demos::dsl::demo_id.eq(&demo)).select(crate::models::Demo::as_select());
|
||||
|
||||
let matching: Vec<crate::models::Demo> = query.load(con).await?;
|
||||
|
||||
if matching.is_empty() {
|
||||
tracing::debug!("Should delete old demo {:?}", demo);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), diesel::result::Error>(())
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
db_res.map_err(|e| ())
|
||||
}
|
||||
@@ -8,6 +8,8 @@ pub mod diesel_sessionstore;
|
||||
|
||||
pub mod analysis;
|
||||
|
||||
mod gc;
|
||||
|
||||
pub async fn db_connection() -> diesel_async::AsyncPgConnection {
|
||||
use diesel_async::AsyncConnection;
|
||||
|
||||
@@ -132,3 +134,16 @@ pub async fn run_analysis(storage: Box<dyn crate::storage::DemoStorage>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(storage))]
|
||||
pub async fn run_garbage_collection(mut storage: Box<dyn crate::storage::DemoStorage>) {
|
||||
loop {
|
||||
tracing::info!("Running Garbage Collection");
|
||||
|
||||
if let Err(e) = gc::run_gc(storage.as_mut()).await {
|
||||
tracing::error!("Running GC {:?}", e);
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15 * 60)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ struct CliArgs {
|
||||
|
||||
#[clap(long = "analysis", default_value_t = true)]
|
||||
analysis: bool,
|
||||
|
||||
#[clap(long = "gc", default_value_t = true)]
|
||||
garbage_collection: bool,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
@@ -93,7 +96,12 @@ async fn main() {
|
||||
if args.analysis {
|
||||
tracing::info!("Enabled Analysis module");
|
||||
|
||||
component_set.spawn(backend::run_analysis(storage));
|
||||
component_set.spawn(backend::run_analysis(storage.duplicate()));
|
||||
}
|
||||
if args.garbage_collection {
|
||||
tracing::info!("Enabled Garbage-Collection module");
|
||||
|
||||
component_set.spawn(backend::run_garbage_collection(storage));
|
||||
}
|
||||
tracing::info!("Started modules");
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,6 +21,9 @@ pub trait DemoStorage: Send + Sync {
|
||||
) -> futures::future::BoxFuture<'f, Result<crate::analysis::AnalysisData, String>>
|
||||
where
|
||||
'own: 'f;
|
||||
|
||||
fn list_demos<'own, 'f>(&'own self) -> futures::future::BoxFuture<'f, Result<Vec<String>, String>>
|
||||
where 'own: 'f;
|
||||
}
|
||||
|
||||
pub struct FileStorage {
|
||||
@@ -107,6 +110,14 @@ impl DemoStorage for FileStorage {
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn list_demos<'own, 'f>(&'own self) -> futures::future::BoxFuture<'f, Result<Vec<String>, String>>
|
||||
where 'own: 'f {
|
||||
async move {
|
||||
// TODO
|
||||
Ok(Vec::new())
|
||||
}.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct S3Storage {
|
||||
@@ -154,12 +165,10 @@ impl DemoStorage for S3Storage {
|
||||
let body_reader = tokio_util::io::StreamReader::new(body_with_io_error);
|
||||
futures::pin_mut!(body_reader);
|
||||
|
||||
self.bucket.list(String::new(), None).await.unwrap();
|
||||
|
||||
self.bucket
|
||||
.put_object_stream(&mut body_reader, path)
|
||||
.await
|
||||
.unwrap();
|
||||
.map_err(|e| format!("Uploading Stream to bucket: {:?}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -178,7 +187,7 @@ impl DemoStorage for S3Storage {
|
||||
let path = std::path::PathBuf::new().join(user_id).join(demo_id);
|
||||
let path = path.to_str().unwrap();
|
||||
|
||||
let resp = self.bucket.get_object(path).await.unwrap();
|
||||
let resp = self.bucket.get_object(path).await.map_err(|e| format!("Loading from Bucket: {:?}", e))?;
|
||||
|
||||
Ok(crate::analysis::AnalysisData::Preloaded(
|
||||
resp.to_vec().into(),
|
||||
@@ -186,4 +195,20 @@ impl DemoStorage for S3Storage {
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn list_demos<'own, 'f>(&'own self) -> futures::future::BoxFuture<'f, Result<Vec<String>, String>>
|
||||
where 'own: 'f {
|
||||
async move {
|
||||
let listed = match self.bucket.list("".to_string(), None).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return Err(format!("{:?}", e)),
|
||||
};
|
||||
|
||||
Ok(listed.into_iter().flat_map(|rs| {
|
||||
rs.contents.into_iter().map(|entry| {
|
||||
entry.key
|
||||
})
|
||||
}).collect())
|
||||
}.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ScoreBoard {
|
||||
pub teams: Vec<(u32, Vec<ScoreBoardPlayer>)>,
|
||||
pub teams: Vec<ScoreBoardTeam>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ScoreBoardTeam {
|
||||
pub number: u32,
|
||||
pub score: i16,
|
||||
pub players: Vec<ScoreBoardPlayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -67,5 +74,18 @@ pub enum RoundWinReason {
|
||||
pub enum RoundEvent {
|
||||
BombPlanted,
|
||||
BombDefused,
|
||||
Killed { attacker: String, died: String },
|
||||
Killed {
|
||||
attacker: String,
|
||||
died: String,
|
||||
weapon: Option<String>,
|
||||
noscope: bool,
|
||||
headshot: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct HeadToHead {
|
||||
pub row_players: Vec<String>,
|
||||
pub column_players: Vec<String>,
|
||||
pub entries: Vec<Vec<(i16, i16)>>,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct BaseDemoInfo {
|
||||
pub uploaded_at: chrono::naive::NaiveDateTime,
|
||||
pub team2_score: i16,
|
||||
pub team3_score: i16,
|
||||
pub player_team: i16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
26
frontend/colors.css
Normal file
26
frontend/colors.css
Normal file
@@ -0,0 +1,26 @@
|
||||
* {
|
||||
/** Generated using https://colorffy.com/dark-theme-generator?colors=40f7f4-121212 */
|
||||
/** Dark theme primary colors */
|
||||
--color-primary-a0: #40f7f4;
|
||||
--color-primary-a10: #66f8f5;
|
||||
--color-primary-a20: #81faf6;
|
||||
--color-primary-a30: #98fbf8;
|
||||
--color-primary-a40: #acfcf9;
|
||||
--color-primary-a50: #befdfa;
|
||||
|
||||
/** Dark theme surface colors */
|
||||
--color-surface-a0: #121212;
|
||||
--color-surface-a10: #282828;
|
||||
--color-surface-a20: #3f3f3f;
|
||||
--color-surface-a30: #575757;
|
||||
--color-surface-a40: #717171;
|
||||
--color-surface-a50: #8b8b8b;
|
||||
|
||||
/** Dark theme mixed surface colors */
|
||||
--color-surface-mixed-a0: #1b2525;
|
||||
--color-surface-mixed-a10: #303939;
|
||||
--color-surface-mixed-a20: #474f4f;
|
||||
--color-surface-mixed-a30: #5e6666;
|
||||
--color-surface-mixed-a40: #777e7d;
|
||||
--color-surface-mixed-a50: #919696;
|
||||
}
|
||||
@@ -3,9 +3,17 @@
|
||||
<head>
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
|
||||
<link data-trunk rel="css" href="colors.css">
|
||||
<link data-trunk rel="copy-dir" href="static/"/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: var(--color-surface-a0);
|
||||
color: var(--color-primary-a50);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
||||
@@ -42,27 +42,8 @@ pub fn demo() -> impl leptos::IntoView {
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let selected_tab = move || {
|
||||
let loc = leptos_router::use_location();
|
||||
let loc_path = loc.pathname.get();
|
||||
let trailing = loc_path.split('/').last();
|
||||
trailing.unwrap_or("/").to_owned()
|
||||
};
|
||||
|
||||
let style = stylers::style! {
|
||||
"Demo",
|
||||
.analysis_bar {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
column-gap: 20px;
|
||||
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.analysis_selector {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
|
||||
@@ -73,19 +54,93 @@ pub fn demo() -> impl leptos::IntoView {
|
||||
.current {
|
||||
background-color: #5d5d5d;
|
||||
}
|
||||
|
||||
.demo_heading {
|
||||
display: grid;
|
||||
margin: 2vh 0px;
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
};
|
||||
|
||||
view! {class = style,
|
||||
<h2>Demo - { id } - { map }</h2>
|
||||
|
||||
<button on:click=move |_| rerun_analysis.dispatch(())>Rerun analysis</button>
|
||||
<div class="analysis_bar">
|
||||
<div class="analysis_selector" class:current=move || selected_tab() == "scoreboard"><A href="scoreboard"><span>Scoreboard</span></A></div>
|
||||
<div class="analysis_selector" class:current=move || selected_tab() == "perround"><A href="perround"><span>Per Round</span></A></div>
|
||||
<div class="analysis_selector" class:current=move || selected_tab() == "heatmaps"><A href="heatmaps"><span>Heatmaps</span></A></div>
|
||||
<div class="demo_heading">
|
||||
<h2>Demo - { id } - { map }</h2>
|
||||
<button on:click=move |_| rerun_analysis.dispatch(()) style="display: inline-block;">Rerun Analysis</button>
|
||||
</div>
|
||||
|
||||
<TabBar prefix=move || format!("/demo/{}/", id()) parts=&[("scoreboard", "Scoreboard"), ("perround", "Per Round"), ("heatmaps", "Heatmaps")] />
|
||||
|
||||
<div>
|
||||
<Outlet/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[leptos::component]
|
||||
pub fn tab_bar<P>(
|
||||
prefix: P,
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> impl leptos::IntoView
|
||||
where
|
||||
P: Fn() -> String + Copy + 'static,
|
||||
{
|
||||
let selected_tab = move || {
|
||||
let prefix = prefix();
|
||||
let loc = leptos_router::use_location();
|
||||
let loc_path = loc.pathname.get();
|
||||
let trailing = loc_path
|
||||
.strip_prefix(&prefix)
|
||||
.unwrap_or(&loc_path)
|
||||
.split('/')
|
||||
.filter(|l| !l.is_empty())
|
||||
.next();
|
||||
trailing
|
||||
.or(parts.first().map(|p| p.0))
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
};
|
||||
|
||||
let style = stylers::style! {
|
||||
"TabBar",
|
||||
.analysis_bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--rows), auto);
|
||||
column-gap: 20px;
|
||||
|
||||
background-color: var(--color-surface-a10);
|
||||
}
|
||||
|
||||
.analysis_selector {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
|
||||
padding: 1vh 1vw;
|
||||
color: #d5d5d5;
|
||||
background-color: var(--color-surface-a20);
|
||||
}
|
||||
.current {
|
||||
background-color: var(--color-surface-a30);
|
||||
}
|
||||
};
|
||||
|
||||
let tabs = move || {
|
||||
parts.into_iter().map(|(routename, name)| {
|
||||
view! {class=style,
|
||||
<div class="analysis_selector" class:current=move || selected_tab() == routename.to_string()>
|
||||
<A href=routename.to_string()>
|
||||
<span>{ name.to_string() }</span>
|
||||
</A>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
view! {class = style,
|
||||
<div class="analysis_bar" style=format!("--rows: {}", parts.len())>
|
||||
{ tabs }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ pub fn per_round() -> impl leptos::IntoView {
|
||||
.round_overview {
|
||||
display: inline-grid;
|
||||
|
||||
margin-top: 2vh;
|
||||
|
||||
width: 90vw;
|
||||
grid-template-columns: auto repeat(12, 1fr) 5px repeat(12, 1fr) 5px repeat(3, 1fr) 5px repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, auto);
|
||||
@@ -79,24 +81,33 @@ pub fn per_round() -> impl leptos::IntoView {
|
||||
|
||||
match (current_round, teams) {
|
||||
(Some(round), Some(teams)) => {
|
||||
round.events.iter().map(|event| {
|
||||
round.events.into_iter().map(|event| {
|
||||
match event {
|
||||
common::demo_analysis::RoundEvent::BombPlanted => view! { <li>Bomb has been planted</li> }.into_view(),
|
||||
common::demo_analysis::RoundEvent::BombDefused => view! { <li>Bomb has been defused</li> }.into_view(),
|
||||
common::demo_analysis::RoundEvent::Killed { attacker, died } => {
|
||||
let mut attacker_t = teams.iter().find(|t| t.players.contains(attacker)).map(|t| t.name == "TERRORIST").unwrap_or(false);
|
||||
let mut died_t = teams.iter().find(|t| t.players.contains(died)).map(|t| t.name == "TERRORIST").unwrap_or(false);
|
||||
common::demo_analysis::RoundEvent::Killed { attacker, died, weapon, headshot, noscope } => {
|
||||
let mut attacker_t = teams.iter().find(|t| t.players.contains(&attacker)).map(|t| t.name == "TERRORIST").unwrap_or(false);
|
||||
let mut died_t = teams.iter().find(|t| t.players.contains(&died)).map(|t| t.name == "TERRORIST").unwrap_or(false);
|
||||
|
||||
if (12..27).contains(&round_index) {
|
||||
attacker_t = !attacker_t;
|
||||
died_t = !died_t;
|
||||
}
|
||||
|
||||
let weapon_display = move || {
|
||||
let parts = weapon.as_ref().into_iter().map(|w| w.as_str())
|
||||
.chain(headshot.then_some("Headshot").into_iter())
|
||||
.chain(noscope.then_some("Noscope").into_iter());
|
||||
|
||||
format!("(using {})", parts.collect::<Vec<_>>().join(","))
|
||||
};
|
||||
|
||||
view! {
|
||||
class=style,
|
||||
<li>{"'"}
|
||||
<span class:t_player=move || attacker_t class:ct_player=move || !attacker_t>{ attacker }</span>{"'"} killed {"'"}
|
||||
<span class:t_player=move || died_t class:ct_player=move || !died_t>{ died }</span>{"'"}
|
||||
<li>
|
||||
{"'"}<span class:t_player=move || attacker_t class:ct_player=move || !attacker_t>{ attacker }</span>{"'"}
|
||||
killed { weapon_display }
|
||||
{"'"}<span class:t_player=move || died_t class:ct_player=move || !died_t>{ died }</span>{"'"}
|
||||
</li>
|
||||
}.into_view()
|
||||
},
|
||||
@@ -192,8 +203,6 @@ pub fn per_round() -> impl leptos::IntoView {
|
||||
|
||||
view! {
|
||||
class=style,
|
||||
<h3>Per Round</h3>
|
||||
|
||||
<div class="round_overview">
|
||||
{ team_names }
|
||||
{ round_overview }
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
use leptos::*;
|
||||
use leptos_router::Outlet;
|
||||
|
||||
pub mod general;
|
||||
pub mod headtohead;
|
||||
|
||||
use crate::demo::TabBar;
|
||||
|
||||
#[leptos::component]
|
||||
pub fn scoreboard() -> impl leptos::IntoView {
|
||||
use leptos::Suspense;
|
||||
|
||||
let scoreboard_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/scoreboard", id))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
res.json::<common::demo_analysis::ScoreBoard>()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let (ordering, set_ordering) = create_signal::<orderings::Ordering>(orderings::DAMAGE);
|
||||
|
||||
let scoreboards = move || {
|
||||
scoreboard_resource
|
||||
.get()
|
||||
.into_iter()
|
||||
.flat_map(|v| v.teams.into_iter())
|
||||
.map(|(team, players)| {
|
||||
view! {
|
||||
<TeamScoreboard value=players team_name=format!("Team {}", team) />
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let params = leptos_router::use_params_map();
|
||||
let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());
|
||||
|
||||
view! {
|
||||
<h2>Scoreboard</h2>
|
||||
<TabBar prefix=move || format!("/demo/{}/scoreboard", id()) parts=&[("general", "General"), ("headtohead", "Head-to-Head")] />
|
||||
|
||||
<Suspense
|
||||
fallback=move || view! { <p>Loading Scoreboard data</p> }
|
||||
>
|
||||
{ scoreboards }
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +77,7 @@ fn team_scoreboard(
|
||||
let style = stylers::style! {
|
||||
"Team-Scoreboard",
|
||||
tr:nth-child(even) {
|
||||
background-color: #dddddd;
|
||||
background-color: var(--color-surface-a10);
|
||||
}
|
||||
|
||||
th {
|
||||
|
||||
45
frontend/src/demo/scoreboard/general.rs
Normal file
45
frontend/src/demo/scoreboard/general.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use leptos::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[leptos::component]
|
||||
pub fn general() -> impl leptos::IntoView {
|
||||
use leptos::Suspense;
|
||||
|
||||
let scoreboard_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/scoreboard", id))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
res.json::<common::demo_analysis::ScoreBoard>()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let (ordering, set_ordering) = create_signal::<orderings::Ordering>(orderings::DAMAGE);
|
||||
|
||||
let scoreboards = move || {
|
||||
scoreboard_resource
|
||||
.get()
|
||||
.into_iter()
|
||||
.flat_map(|v| v.teams.into_iter())
|
||||
.map(|team| {
|
||||
view! {
|
||||
<TeamScoreboard value=team.players team_name=format!("Team {} - {}", team.number, team.score) />
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
view! {
|
||||
<Suspense
|
||||
fallback=move || view! { <p>Loading Scoreboard data</p> }
|
||||
>
|
||||
{ scoreboards }
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
98
frontend/src/demo/scoreboard/headtohead.rs
Normal file
98
frontend/src/demo/scoreboard/headtohead.rs
Normal file
@@ -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::<common::demo_analysis::HeadToHead>()
|
||||
.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! {
|
||||
<Matrix data=r />
|
||||
});
|
||||
|
||||
view! {
|
||||
class=style,
|
||||
<Suspense fallback=move || view! { <p>Loading Head-to-Head data...</p> }>
|
||||
<div>
|
||||
{ matrix_view }
|
||||
</div>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<span style=format!("grid-row: {}; grid-column: 1;", idx + 2)> {name} </span>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let column_player_view = move || {
|
||||
data.column_players.iter().enumerate().map(|(idx, name)| {
|
||||
view! {
|
||||
<span style=format!("grid-row: 1; grid-column: {}; text-align: right", idx + 2)> {name} </span>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
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! {
|
||||
<div style="grid-row: 2; grid-column: 1; text-align: center;">{ row_kills }</div>
|
||||
<div style="grid-row: 1; grid-column: 2; text-align: center;">{ column_kills }</div>
|
||||
};
|
||||
|
||||
view! {
|
||||
class = style,
|
||||
<div class="cell" style=format!("grid-row: {}; grid-column: {};", row + 2, column + 2)>
|
||||
<div class="cell_back"></div>
|
||||
{ entry }
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
view! {
|
||||
{ row_player_view }
|
||||
{ column_player_view }
|
||||
{ entry_view }
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ use leptos::*;
|
||||
#[leptos::component]
|
||||
pub fn homepage(get_notification: ReadSignal<u8>) -> impl leptos::IntoView {
|
||||
let demo_data = create_resource(
|
||||
move || {
|
||||
get_notification.get()
|
||||
},
|
||||
move || get_notification.get(),
|
||||
|_| async move {
|
||||
let res = reqwasm::http::Request::get("/api/demos/list")
|
||||
.send()
|
||||
@@ -16,11 +14,24 @@ pub fn homepage(get_notification: ReadSignal<u8>) -> impl leptos::IntoView {
|
||||
},
|
||||
);
|
||||
|
||||
let pending_display = move || {
|
||||
demo_data
|
||||
.get()
|
||||
.map(|d| d.pending)
|
||||
.filter(|p| p.len() > 0)
|
||||
.map(|pending| {
|
||||
view! {
|
||||
<p>{pending.len()} demos are pending/waiting for analysis</p>
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<div>
|
||||
<div>
|
||||
<h2>Demos</h2>
|
||||
</div>
|
||||
{ pending_display }
|
||||
<DemoList demos=demo_data />
|
||||
</div>
|
||||
}
|
||||
@@ -34,18 +45,23 @@ fn demo_list(
|
||||
"DemoList",
|
||||
.list {
|
||||
display: inline-grid;
|
||||
width: 100%;
|
||||
|
||||
grid-template-columns: auto auto auto;
|
||||
row-gap: 1ch;
|
||||
row-gap: 1vh;
|
||||
}
|
||||
|
||||
.headers {
|
||||
font-size: 22px;
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
class=style,
|
||||
<div class="list">
|
||||
<span>Score</span>
|
||||
<span>Date</span>
|
||||
<span>Map</span>
|
||||
<span class="headers">Score</span>
|
||||
<span class="headers">Date</span>
|
||||
<span class="headers">Map</span>
|
||||
|
||||
{ move || demos.get().map(|d| d.done).unwrap_or_default().into_iter().enumerate().map(|(i, demo)| view! { <DemoListEntry demo idx=i+1 /> }).collect::<Vec<_>>() }
|
||||
</div>
|
||||
@@ -59,12 +75,14 @@ fn demo_list_entry(demo: common::BaseDemoInfo, idx: usize) -> impl leptos::IntoV
|
||||
.entry {
|
||||
display: inline-block;
|
||||
|
||||
border: solid #030303aa 1px;
|
||||
|
||||
grid-column: 1 / 4;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.background_entry {
|
||||
background-color: var(--color-surface-a20);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.score, .map {
|
||||
padding-left: 5px;
|
||||
@@ -90,14 +108,37 @@ fn demo_list_entry(demo: common::BaseDemoInfo, idx: usize) -> impl leptos::IntoV
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.won {
|
||||
color: #00aa00;
|
||||
}
|
||||
.lost {
|
||||
color: #aa0000;
|
||||
}
|
||||
};
|
||||
|
||||
let (player_score, enemy_score) = if demo.player_team == 2 {
|
||||
(demo.team2_score, demo.team3_score)
|
||||
} else {
|
||||
(demo.team3_score, demo.team2_score)
|
||||
};
|
||||
let won = move || player_score > enemy_score;
|
||||
let lost = move || enemy_score > player_score;
|
||||
let tie = move || player_score == enemy_score;
|
||||
|
||||
view! {
|
||||
class=style,
|
||||
<span class="score" style=format!("grid-row: {};", idx + 1)>{demo.team2_score}:{demo.team3_score}</span>
|
||||
<span class="entry background_entry" style=format!("grid-row: {};", idx + 1)></span>
|
||||
<span
|
||||
class="score"
|
||||
style=format!("grid-row: {};", idx + 1)
|
||||
class:won=won
|
||||
class:lost=lost
|
||||
class:tie=tie
|
||||
>{demo.team2_score}:{demo.team3_score}</span>
|
||||
<div class="date" style=format!("grid-row: {};", idx + 1)>
|
||||
<span>{demo.uploaded_at.format("%Y-%m-%d").to_string()}</span>
|
||||
<span>{demo.uploaded_at.format("%H-%M-%S").to_string()}</span>
|
||||
<span>{demo.uploaded_at.format("%H:%M:%S").to_string()}</span>
|
||||
</div>
|
||||
<span class="map" style=format!("grid-row: {};", idx + 1)>{demo.map}</span>
|
||||
<a class="entry" href=format!("demo/{}/scoreboard", demo.id) style=format!("grid-row: {};", idx + 1)></a>
|
||||
|
||||
@@ -47,27 +47,89 @@ pub fn upload_demo(
|
||||
.shown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.lds-ellipsis,
|
||||
.lds-ellipsis div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lds-ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 20px;
|
||||
}
|
||||
.lds-ellipsis div {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
.lds-ellipsis div:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(3) {
|
||||
left: 28px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(4) {
|
||||
left: 48px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(20px, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let uploading = RwSignal::new(false);
|
||||
|
||||
let handle_resp: std::rc::Rc<dyn Fn(&leptos::web_sys::Response)> = std::rc::Rc::new(move |resp: &leptos::web_sys::Response| {
|
||||
if resp.status() != 200 {
|
||||
// TODO
|
||||
// Display error somehow
|
||||
return;
|
||||
}
|
||||
let handle_resp: std::rc::Rc<dyn Fn(&leptos::web_sys::Response)> =
|
||||
std::rc::Rc::new(move |resp: &leptos::web_sys::Response| {
|
||||
if resp.status() != 200 {
|
||||
// TODO
|
||||
// Display error somehow
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.set(false);
|
||||
uploading.set(false);
|
||||
|
||||
// Remove the Upload popup
|
||||
update_shown.set(DemoUploadStatus::Hidden);
|
||||
// Remove the Upload popup
|
||||
update_shown.set(DemoUploadStatus::Hidden);
|
||||
|
||||
// Reload the demo list
|
||||
reload_demos.update(|v| {
|
||||
*v = v.wrapping_add(1);
|
||||
// Reload the demo list
|
||||
reload_demos.update(|v| {
|
||||
*v = v.wrapping_add(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let on_submit: std::rc::Rc<dyn Fn(&leptos::web_sys::FormData)> = std::rc::Rc::new(move |_| {
|
||||
uploading.set(true);
|
||||
@@ -86,10 +148,13 @@ pub fn upload_demo(
|
||||
Close
|
||||
</button>
|
||||
|
||||
<p
|
||||
<div
|
||||
class:shown=move || uploading.get()
|
||||
class:hidden=move || !uploading.get()
|
||||
>Uploading...</p>
|
||||
>
|
||||
<span>Uploading</span>
|
||||
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ fn main() {
|
||||
<Routes>
|
||||
<Route path="/" view=move || view! { <Homepage get_notification=get_reload_demos /> } />
|
||||
<Route path="/demo/:id" view=Demo>
|
||||
<Route path="scoreboard" view=frontend::demo::scoreboard::Scoreboard>
|
||||
<Route path="general" view=frontend::demo::scoreboard::general::General />
|
||||
<Route path="headtohead" view=frontend::demo::scoreboard::headtohead::HeadToHead />
|
||||
<Route path="" view=frontend::demo::scoreboard::general::General />
|
||||
</Route>
|
||||
<Route path="perround" view=frontend::demo::perround::PerRound />
|
||||
<Route path="scoreboard" view=frontend::demo::scoreboard::Scoreboard />
|
||||
<Route path="heatmaps" view=frontend::demo::heatmap::Heatmaps />
|
||||
<Route path="" view=frontend::demo::scoreboard::Scoreboard />
|
||||
</Route>
|
||||
|
||||
@@ -31,9 +31,7 @@ fn steam_login(height: &'static str, width: &'static str) -> impl leptos::IntoVi
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
{ tmp }
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
#[leptos::component]
|
||||
@@ -45,8 +43,9 @@ pub fn top_bar(update_demo_visible: WriteSignal<DemoUploadStatus>) -> impl lepto
|
||||
height: 4vh;
|
||||
padding-top: 0.5vh;
|
||||
padding-bottom: 0.5vh;
|
||||
border-radius: 6px;
|
||||
|
||||
background-color: #28282f;
|
||||
background-color: var(--color-surface-a10);
|
||||
color: #d5d5d5;
|
||||
|
||||
display: grid;
|
||||
@@ -60,7 +59,7 @@ pub fn top_bar(update_demo_visible: WriteSignal<DemoUploadStatus>) -> impl lepto
|
||||
}
|
||||
|
||||
.logo {
|
||||
color: #d5d5d5;
|
||||
color: var(--color-primary-a50);
|
||||
width: 15vw;
|
||||
font-size: 24px;
|
||||
padding: 0px;
|
||||
|
||||
2
migrations/2024-11-04-220702_head-to-head/down.sql
Normal file
2
migrations/2024-11-04-220702_head-to-head/down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
DROP TABLE demo_head_to_head;
|
||||
8
migrations/2024-11-04-220702_head-to-head/up.sql
Normal file
8
migrations/2024-11-04-220702_head-to-head/up.sql
Normal file
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user