Add Head-to-Head kill stats
Add basic head-to-head kill analysis and the corresponding matrix display in the UI
This commit is contained in:
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 endofgame;
|
||||||
pub mod heatmap;
|
pub mod heatmap;
|
||||||
pub mod perround;
|
pub mod perround;
|
||||||
|
pub mod head_to_head;
|
||||||
|
|||||||
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 base;
|
||||||
pub mod heatmap;
|
pub mod heatmap;
|
||||||
pub mod perround;
|
pub mod perround;
|
||||||
|
pub mod head_to_head;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AnalysisData {
|
pub enum AnalysisData {
|
||||||
@@ -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::LazyLock::new(|| {
|
||||||
[
|
[
|
||||||
std::sync::Arc::new(base::BaseAnalysis::new()),
|
std::sync::Arc::new(base::BaseAnalysis::new()),
|
||||||
std::sync::Arc::new(heatmap::HeatmapAnalysis::new()),
|
std::sync::Arc::new(heatmap::HeatmapAnalysis::new()),
|
||||||
std::sync::Arc::new(perround::PerRoundAnalysis::new()),
|
std::sync::Arc::new(perround::PerRoundAnalysis::new()),
|
||||||
|
std::sync::Arc::new(head_to_head::HeadToHeadAnalysis::new()),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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(())
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/scoreboard", axum::routing::get(scoreboard))
|
||||||
.route("/:id/analysis/perround", axum::routing::get(perround))
|
.route("/:id/analysis/perround", axum::routing::get(perround))
|
||||||
.route("/:id/analysis/heatmap", axum::routing::get(heatmap))
|
.route("/:id/analysis/heatmap", axum::routing::get(heatmap))
|
||||||
|
.route("/:id/analysis/headtohead", axum::routing::get(head_to_head))
|
||||||
.with_state(Arc::new(DemoState { storage }))
|
.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<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
|
// 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'
|
// files in 'game/csgo/pak01_dir.vpk' and then 'resource/overviews/{map}.txt'
|
||||||
static MINIMAP_COORDINATES: phf::Map<&str, MiniMapDefinition> = phf::phf_map! {
|
static MINIMAP_COORDINATES: phf::Map<&str, MiniMapDefinition> = phf::phf_map! {
|
||||||
|
|||||||
@@ -120,3 +120,13 @@ pub struct DemoRound {
|
|||||||
pub win_reason: String,
|
pub win_reason: String,
|
||||||
pub events: serde_json::Value,
|
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! {
|
diesel::table! {
|
||||||
demo_heatmaps (demo_id, steam_id, team) {
|
demo_heatmaps (demo_id, steam_id, team) {
|
||||||
demo_id -> Text,
|
demo_id -> Text,
|
||||||
@@ -97,6 +106,7 @@ diesel::table! {
|
|||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
analysis_queue,
|
analysis_queue,
|
||||||
|
demo_head_to_head,
|
||||||
demo_heatmaps,
|
demo_heatmaps,
|
||||||
demo_info,
|
demo_info,
|
||||||
demo_player_stats,
|
demo_player_stats,
|
||||||
|
|||||||
@@ -82,3 +82,10 @@ pub enum RoundEvent {
|
|||||||
headshot: 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)>>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use leptos::*;
|
|||||||
use leptos_router::Outlet;
|
use leptos_router::Outlet;
|
||||||
|
|
||||||
pub mod general;
|
pub mod general;
|
||||||
pub mod utility;
|
pub mod headtohead;
|
||||||
|
|
||||||
use crate::demo::TabBar;
|
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());
|
let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<TabBar prefix=move || format!("/demo/{}/scoreboard", id()) parts=&[("general", "General"), ("utility", "Utility")] />
|
<TabBar prefix=move || format!("/demo/{}/scoreboard", id()) parts=&[("general", "General"), ("headtohead", "Head-to-Head")] />
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
}
|
}
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
use leptos::*;
|
|
||||||
|
|
||||||
#[leptos::component]
|
|
||||||
pub fn utility() -> impl leptos::IntoView {
|
|
||||||
view! {
|
|
||||||
Utility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ fn main() {
|
|||||||
<Route path="/demo/:id" view=Demo>
|
<Route path="/demo/:id" view=Demo>
|
||||||
<Route path="scoreboard" view=frontend::demo::scoreboard::Scoreboard>
|
<Route path="scoreboard" view=frontend::demo::scoreboard::Scoreboard>
|
||||||
<Route path="general" view=frontend::demo::scoreboard::general::General />
|
<Route path="general" view=frontend::demo::scoreboard::general::General />
|
||||||
<Route path="utility" view=frontend::demo::scoreboard::utility::Utility />
|
<Route path="headtohead" view=frontend::demo::scoreboard::headtohead::HeadToHead />
|
||||||
<Route path="" view=frontend::demo::scoreboard::general::General />
|
<Route path="" view=frontend::demo::scoreboard::general::General />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="perround" view=frontend::demo::perround::PerRound />
|
<Route path="perround" view=frontend::demo::perround::PerRound />
|
||||||
|
|||||||
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