diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 31ec3b0..bc67d1d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -323,6 +323,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "actix-web-prom" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23f332a652836b8f3a6876103c70c9ed436d0e69fa779ab5d7f57b1d5c8d488" +dependencies = [ + "actix-web", + "futures-core", + "pin-project-lite", + "prometheus", + "regex", +] + +[[package]] +name = "actix-ws" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "tokio", +] + [[package]] name = "actix_derive" version = "0.6.2" @@ -463,6 +490,7 @@ dependencies = [ "actix-test", "actix-web", "actix-web-actors", + "actix-web-prom", "awc", "challenge", "chess", @@ -478,6 +506,8 @@ dependencies = [ "jsonwebtoken", "matchmaking", "migration", + "once_cell", + "prometheus", "redis", "sea-orm", "security", @@ -499,6 +529,26 @@ dependencies = [ "validator_types", ] +[[package]] +name = "archiving" +version = "0.1.0" +dependencies = [ + "base64 0.21.7", + "chrono", + "db_entity", + "env_logger", + "hex", + "log", + "reqwest", + "sea-orm", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "uuid", +] + [[package]] name = "argon2" version = "0.5.3" @@ -593,7 +643,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -1131,6 +1181,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1328,10 +1388,13 @@ dependencies = [ "chrono", "db_entity", "dotenv", + "once_cell", + "prometheus", "rand 0.8.6", "sea-orm", "serde_json", "tokio", + "tracing", "uuid", ] @@ -1380,6 +1443,37 @@ dependencies = [ "tokio", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -1520,6 +1614,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2009,6 +2126,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -2021,6 +2149,43 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2105,6 +2270,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "integration_tests" +version = "0.1.0" +dependencies = [ + "actix-rt", + "api", + "archiving", + "chrono", + "env_logger", + "log", + "metrics", + "reqwest", + "serde", + "serde_json", + "tokio", + "tournament", + "uuid", + "validation", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2117,6 +2308,42 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2246,6 +2473,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2346,6 +2579,20 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metrics" +version = "0.1.0" +dependencies = [ + "actix-web", + "chrono", + "env_logger", + "log", + "prometheus", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "migration" version = "0.1.0" @@ -2667,6 +2914,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pairing" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "parking" version = "2.2.1" @@ -2827,7 +3084,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2837,6 +3094,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2938,6 +3204,52 @@ dependencies = [ "yansi", ] +[[package]] +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.11.1", + "hex", + "lazy_static", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.11.1", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "protobuf", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -3179,6 +3491,46 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "ring" version = "0.17.14" @@ -3302,6 +3654,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3311,7 +3676,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3330,6 +3695,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3584,7 +3958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4271,6 +4645,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -4286,7 +4687,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4413,6 +4814,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4468,11 +4879,18 @@ name = "tournament" version = "0.1.0" dependencies = [ "chrono", + "pairing", "serde", "serde_json", "uuid", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4561,6 +4979,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -4758,6 +5182,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validation" +version = "0.1.0" +dependencies = [ + "actix-web", + "actix-ws", + "async-trait", + "chess", + "chrono", + "env_logger", + "futures-util", + "log", + "redis", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "uuid", +] + [[package]] name = "validator" version = "0.16.1" @@ -4834,6 +5278,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5196,6 +5649,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/backend/modules/tournament/src/bracket.rs b/backend/modules/tournament/src/bracket.rs index 5776c00..7e6e06e 100644 --- a/backend/modules/tournament/src/bracket.rs +++ b/backend/modules/tournament/src/bracket.rs @@ -44,6 +44,18 @@ pub struct BracketMatch { pub status: MatchStatus, pub scheduled_at: Option>, pub completed_at: Option>, + #[serde(skip)] + pub bracket_type: BracketType, + #[serde(skip)] + pub losers_from_match: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub enum BracketType { + #[default] + Winners, + Losers, + GrandFinals, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,6 +69,8 @@ pub struct TournamentBracket { pub winner_id: Option, pub started_at: Option>, pub completed_at: Option>, + #[serde(skip)] + pub bracket_reset_required: bool, } #[derive(Debug, Clone, PartialEq)] @@ -94,6 +108,47 @@ fn next_power_of_two(n: u32) -> u32 { } } +/// Place `player_id` into an unmatched Losers Bracket round. Prefers the match +/// at `preferred_match_number`; if it is already full (or absent, as can happen +/// with non-power-of-two sizes after byes), falls back to the first match in the +/// round with a free slot. Returns `true` if a slot was found. +fn place_in_lb( + matches: &mut [BracketMatch], + round: u32, + preferred_match_number: u32, + player_id: Uuid, +) -> bool { + if let Some(m) = matches.iter_mut().find(|m| { + m.bracket_type == BracketType::Losers + && m.round == round + && m.match_number == preferred_match_number + }) { + if m.player1_id.is_none() { + m.player1_id = Some(player_id); + return true; + } + if m.player2_id.is_none() { + m.player2_id = Some(player_id); + return true; + } + } + // Fallback: any match in the round with a free slot. + if let Some(m) = matches + .iter_mut() + .find(|m| m.bracket_type == BracketType::Losers && m.round == round) + { + if m.player1_id.is_none() { + m.player1_id = Some(player_id); + return true; + } + if m.player2_id.is_none() { + m.player2_id = Some(player_id); + return true; + } + } + false +} + fn generate_single_elimination_matches( participants: &[TournamentParticipant], total_rounds: u32, @@ -130,6 +185,8 @@ fn generate_single_elimination_matches( status, scheduled_at: None, completed_at, + bracket_type: BracketType::Winners, + losers_from_match: None, }); } @@ -147,6 +204,8 @@ fn generate_single_elimination_matches( status: MatchStatus::Pending, scheduled_at: None, completed_at: None, + bracket_type: BracketType::Winners, + losers_from_match: None, }); } count /= 2; @@ -174,6 +233,191 @@ fn generate_single_elimination_matches( matches } +fn generate_double_elimination_matches( + participants: &[TournamentParticipant], + total_rounds: u32, +) -> Vec { + let bracket_size = next_power_of_two(participants.len() as u32) as usize; + let mut matches = Vec::new(); + + // Winners Bracket Round 1 + let r1_count = bracket_size / 2; + for i in 0..r1_count { + let p1 = participants.get(i).map(|p| p.id); + let p2 = participants.get(bracket_size - 1 - i).map(|p| p.id); + + let (p1_id, p2_id, status, winner_id, completed_at) = match (p1, p2) { + (Some(id), None) => ( + Some(id), + None, + MatchStatus::Completed, + Some(id), + Some(Utc::now()), + ), + (Some(id1), Some(id2)) => (Some(id1), Some(id2), MatchStatus::Pending, None, None), + _ => (None, None, MatchStatus::Pending, None, None), + }; + + matches.push(BracketMatch { + id: Uuid::new_v4(), + round: 1, + match_number: (i + 1) as u32, + player1_id: p1_id, + player2_id: p2_id, + winner_id, + status, + scheduled_at: None, + completed_at, + bracket_type: BracketType::Winners, + losers_from_match: None, + }); + } + + // Winners Bracket subsequent rounds + let mut wb_count = r1_count / 2; + for r in 2..=total_rounds { + for i in 0..wb_count { + matches.push(BracketMatch { + id: Uuid::new_v4(), + round: r, + match_number: (i + 1) as u32, + player1_id: None, + player2_id: None, + winner_id: None, + status: MatchStatus::Pending, + scheduled_at: None, + completed_at: None, + bracket_type: BracketType::Winners, + losers_from_match: None, + }); + } + wb_count /= 2; + } + + // Losers Bracket - Create matches for each round. + // Byes in WB Round 1 produce no losers, so the first LB round only needs to + // pair losers from the real (non-bye) WB Round 1 matches. + let bye_count = bracket_size - participants.len(); + let real_r1 = r1_count - bye_count; // # of real WB R1 matches that yield losers + + // Round 1: pair the losers of adjacent real WB R1 matches. + let lb_r1_matches = real_r1 / 2; + for i in 0..lb_r1_matches { + matches.push(BracketMatch { + id: Uuid::new_v4(), + round: 1, + match_number: (i + 1) as u32, + player1_id: None, + player2_id: None, + winner_id: None, + status: MatchStatus::Pending, + scheduled_at: None, + completed_at: None, + bracket_type: BracketType::Losers, + losers_from_match: None, + }); + } + + // Remaining LB rounds alternate pairing: + // even round r: losers of WB round (r/2+1) + winners of LB round r-1 + // odd round r: winners of LB round r-1 + // Number of matches = incoming players / 2. + // + // Round 2: losers from WB Round 2 + winners from LB Round 1. + let wb_r2_losers = r1_count / 2; // == bracket_size / 4 + let lb_r2_matches = ((wb_r2_losers + lb_r1_matches) / 2).max(1); + for i in 0..lb_r2_matches { + matches.push(BracketMatch { + id: Uuid::new_v4(), + round: 2, + match_number: (i + 1) as u32, + player1_id: None, + player2_id: None, + winner_id: None, + status: MatchStatus::Pending, + scheduled_at: None, + completed_at: None, + bracket_type: BracketType::Losers, + losers_from_match: None, + }); + } + + // Subsequent LB rounds. + let mut lb_round = 3; + let mut prev_lb_matches = lb_r2_matches; + + while lb_round <= 2 * total_rounds - 2 { + let wb_round = lb_round / 2 + 1; + let wb_losers = if lb_round % 2 == 0 && wb_round <= total_rounds { + if wb_round == 1 { + real_r1 + } else { + r1_count / (1 << (wb_round - 1)) + } + } else { + 0 + }; + + let matches_this_round = ((wb_losers + prev_lb_matches) / 2).max(1); + + for i in 0..matches_this_round { + matches.push(BracketMatch { + id: Uuid::new_v4(), + round: lb_round, + match_number: (i + 1) as u32, + player1_id: None, + player2_id: None, + winner_id: None, + status: MatchStatus::Pending, + scheduled_at: None, + completed_at: None, + bracket_type: BracketType::Losers, + losers_from_match: None, + }); + } + + prev_lb_matches = matches_this_round; + lb_round += 1; + } + + // Grand Finals (potentially two matches) + // GF match 1: WB champion vs LB champion + matches.push(BracketMatch { + id: Uuid::new_v4(), + round: total_rounds + 1, + match_number: 1, + player1_id: None, // Will be set when WB final completes + player2_id: None, // Will be set when LB final completes + winner_id: None, + status: MatchStatus::Pending, + scheduled_at: None, + completed_at: None, + bracket_type: BracketType::GrandFinals, + losers_from_match: None, + }); + + // Auto-advance byes in winners bracket + for i in 0..r1_count { + if matches[i].status == MatchStatus::Completed { + if let Some(winner_id) = matches[i].winner_id { + let next_match_number = ((i + 1) as u32).div_ceil(2); + if let Some(next) = matches + .iter_mut() + .find(|m| m.round == 2 && m.match_number == next_match_number && m.bracket_type == BracketType::Winners) + { + if (i + 1) % 2 == 1 { + next.player1_id = Some(winner_id); + } else { + next.player2_id = Some(winner_id); + } + } + } + } + } + + matches +} + fn generate_round_robin_matches(participants: &[TournamentParticipant]) -> Vec { let mut matches = Vec::new(); let mut match_num = 1; @@ -189,6 +433,8 @@ fn generate_round_robin_matches(participants: &[TournamentParticipant]) -> Vec { + BracketFormat::SingleElimination => { generate_single_elimination_matches(&participants, total_rounds) } + BracketFormat::DoubleElimination => { + generate_double_elimination_matches(&participants, total_rounds) + } BracketFormat::RoundRobin => generate_round_robin_matches(&participants), }; @@ -246,6 +495,7 @@ impl BracketService { winner_id: None, started_at: None, completed_at: None, + bracket_reset_required: false, }) } @@ -265,7 +515,7 @@ impl BracketService { match_id: Uuid, winner_id: Uuid, ) -> Result<(), BracketError> { - let (match_round, match_number) = { + let (match_round, match_number, bracket_type, player1_id, player2_id) = { let m = bracket .matches .iter() @@ -281,7 +531,7 @@ impl BracketService { if m.player1_id != Some(winner_id) && m.player2_id != Some(winner_id) { return Err(BracketError::PlayerNotInMatch); } - (m.round, m.match_number) + (m.round, m.match_number, m.bracket_type.clone(), m.player1_id, m.player2_id) }; { @@ -296,7 +546,7 @@ impl BracketService { } match &bracket.format { - BracketFormat::SingleElimination | BracketFormat::DoubleElimination => { + BracketFormat::SingleElimination => { let next_round = match_round + 1; let next_match_number = match_number.div_ceil(2); @@ -311,12 +561,14 @@ impl BracketService { next.player2_id = Some(winner_id); } } else { - // Final match just completed bracket.winner_id = Some(winner_id); bracket.status = TournamentStatus::Completed; bracket.completed_at = Some(Utc::now()); } } + BracketFormat::DoubleElimination => { + Self::advance_double_elimination(bracket, match_id, winner_id, match_round, match_number, bracket_type, player1_id, player2_id)?; + } BracketFormat::RoundRobin => { if bracket .matches @@ -333,6 +585,128 @@ impl BracketService { Ok(()) } + fn advance_double_elimination( + bracket: &mut TournamentBracket, + match_id: Uuid, + winner_id: Uuid, + match_round: u32, + match_number: u32, + bracket_type: BracketType, + player1_id: Option, + player2_id: Option, + ) -> Result<(), BracketError> { + let loser_id = if player1_id == Some(winner_id) { + player2_id + } else { + player1_id + }; + + match bracket_type { + BracketType::Winners => { + // Advance winner to next WB round + let next_round = match_round + 1; + let next_match_number = match_number.div_ceil(2); + + if let Some(next) = bracket + .matches + .iter_mut() + .find(|m| m.round == next_round && m.match_number == next_match_number && m.bracket_type == BracketType::Winners) + { + if match_number % 2 == 1 { + next.player1_id = Some(winner_id); + } else { + next.player2_id = Some(winner_id); + } + } else { + // This was the WB final - winner advances to Grand Finals as player1 + if let Some(gf_match) = bracket + .matches + .iter_mut() + .find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 1) + { + gf_match.player1_id = Some(winner_id); + } + } + + // Send loser to Losers Bracket in standard seeded order. + // WB R1 losers of adjacent *real* matches pair in LB R1; WB Rk + // (k>=2) losers drop into LB round 2k-2 at the same match number. + if let Some(loser) = loser_id { + let byte_count = next_power_of_two(bracket.participants.len() as u32) as usize + - bracket.participants.len(); + let (losers_round, preferred_mn) = if match_round <= 1 { + (1, (match_number - byte_count as u32).div_ceil(2)) + } else { + (2 * match_round - 2, match_number) + }; + + place_in_lb(&mut bracket.matches, losers_round, preferred_mn, loser); + } + } + BracketType::Losers => { + // Advance winner to the next Losers Bracket round. + let next_round = match_round + 1; + let preferred_mn = match_number.div_ceil(2).max(1); + + if !place_in_lb(&mut bracket.matches, next_round, preferred_mn, winner_id) { + // Final Losers Bracket match - winner advances to Grand Finals + if let Some(gf_match) = bracket + .matches + .iter_mut() + .find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 1) + { + if gf_match.player1_id.is_some() && gf_match.player2_id.is_none() { + gf_match.player2_id = Some(winner_id); + } else if gf_match.player1_id.is_none() { + gf_match.player2_id = Some(winner_id); + } + } + } + } + BracketType::GrandFinals => { + // Check if bracket reset is needed + if bracket.bracket_reset_required { + // Second GF match - winner is tournament champion + bracket.winner_id = Some(winner_id); + bracket.status = TournamentStatus::Completed; + bracket.completed_at = Some(Utc::now()); + } else { + // First GF match - check if losers bracket winner won + let completed_match = bracket.matches.iter().find(|m| m.id == match_id).unwrap(); + let wb_champion = completed_match.player1_id; + + if wb_champion != Some(winner_id) { + // Losers bracket winner won - need bracket reset + bracket.bracket_reset_required = true; + + // Create second GF match (bracket reset) + let new_gf_match = BracketMatch { + id: Uuid::new_v4(), + round: match_round, + match_number: 2, + player1_id: wb_champion, + player2_id: Some(winner_id), + winner_id: None, + status: MatchStatus::Pending, + scheduled_at: None, + completed_at: None, + bracket_type: BracketType::GrandFinals, + losers_from_match: None, + }; + bracket.matches.push(new_gf_match); + } else { + // WB champion won first GF match - tournament over + bracket.winner_id = Some(winner_id); + bracket.status = TournamentStatus::Completed; + bracket.completed_at = Some(Utc::now()); + } + } + } + } + + Ok(()) + } + /// Return participants ranked by wins in round robin / bracket. pub fn get_standings(bracket: &TournamentBracket) -> Vec<(Uuid, u32)> { let mut wins: HashMap = HashMap::new(); @@ -359,7 +733,7 @@ mod tests { .map(|i| TournamentParticipant { id: Uuid::new_v4(), name: format!("Player {}", i + 1), - elo: 2000 - (i as u32 * 100), + elo: 3000 - (i as u32 * 10), seed: 0, }) .collect() @@ -621,4 +995,671 @@ mod tests { assert!(standings[0].1 >= standings[1].1); } } + + // Double Elimination Tests + + #[test] + fn double_elimination_8_players_creates_valid_bracket() { + let bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(8), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + // Should have WB matches, LB matches, and GF + let wb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Winners) + .collect(); + let lb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Losers) + .collect(); + let gf_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::GrandFinals) + .collect(); + + // 8 players: WB has 4+2+1=7 matches, LB has 6 matches, GF has 1 match + assert_eq!(wb_matches.len(), 7); + assert_eq!(lb_matches.len(), 6); + assert_eq!(gf_matches.len(), 1); + } + + #[test] + fn double_elimination_12_players_creates_valid_bracket() { + let bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(12), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + // 12 players -> bracket size 16 + // WB: 8+4+2+1=15 matches + // LB: 4+2+1+1+1+1+1=7 matches + // GF: 1 match + let wb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Winners) + .collect(); + let lb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Losers) + .collect(); + let gf_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::GrandFinals) + .collect(); + + assert_eq!(wb_matches.len(), 15); + assert!(lb_matches.len() >= 7); + assert_eq!(gf_matches.len(), 1); + + // Check that top seeds get byes in WB round 1 + let wb_r1_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .collect(); + let bye_matches: Vec<_> = wb_r1_matches + .iter() + .filter(|m| m.status == MatchStatus::Completed) + .collect(); + // 12 players, 16 bracket size, 4 byes + assert_eq!(bye_matches.len(), 4); + } + + #[test] + fn double_elimination_16_players_creates_valid_bracket() { + let bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(16), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + let wb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Winners) + .collect(); + let lb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Losers) + .collect(); + let gf_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::GrandFinals) + .collect(); + + // 16 players: WB has 8+4+2+1=15 matches, LB has ~12 matches, GF has 1 match + assert_eq!(wb_matches.len(), 15); + assert!(lb_matches.len() >= 10 && lb_matches.len() <= 15); + assert_eq!(gf_matches.len(), 1); + } + + #[test] + fn double_elimination_32_players_creates_valid_bracket() { + let bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(32), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + let wb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Winners) + .collect(); + let lb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Losers) + .collect(); + let gf_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::GrandFinals) + .collect(); + + // 32 players: WB has 16+8+4+2+1=31 matches, LB has ~28 matches, GF has 1 match + assert_eq!(wb_matches.len(), 31); + assert!(lb_matches.len() >= 25 && lb_matches.len() <= 35); + assert_eq!(gf_matches.len(), 1); + } + + #[test] + fn double_elimination_4_players_complete_tournament() { + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(4), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + // WB Round 1: 2 matches + let wb_r1: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .map(|m| m.id) + .collect(); + + // Player 1 and 2 win their WB R1 matches + let m1 = bracket.matches.iter().find(|m| m.id == wb_r1[0]).unwrap().clone(); + let m2 = bracket.matches.iter().find(|m| m.id == wb_r1[1]).unwrap().clone(); + + BracketService::record_result(&mut bracket, wb_r1[0], m1.player1_id.unwrap()).unwrap(); + BracketService::record_result(&mut bracket, wb_r1[1], m2.player1_id.unwrap()).unwrap(); + + // Losers should be sent to LB Round 1 + let lb_r1: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Losers) + .collect(); + assert!(lb_r1[0].player1_id.is_some() || lb_r1[0].player2_id.is_some()); + + // WB Round 2 (final): 1 match + let wb_r2: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 2 && m.bracket_type == BracketType::Winners) + .map(|m| m.id) + .collect(); + + let wb_final = bracket.matches.iter().find(|m| m.id == wb_r2[0]).unwrap().clone(); + BracketService::record_result(&mut bracket, wb_r2[0], wb_final.player1_id.unwrap()).unwrap(); + + // WB champion should be set in GF + let gf: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::GrandFinals) + .collect(); + assert!(gf[0].player1_id.is_some()); + + // Complete LB matches + let lb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Losers && m.status == MatchStatus::Pending) + .map(|m| m.id) + .collect(); + + for id in lb_matches { + if let Some(m) = bracket.matches.iter().find(|m| m.id == id) { + if let Some(p1) = m.player1_id { + BracketService::record_result(&mut bracket, id, p1).unwrap(); + } + } + } + + // Now GF should have both players + let gf = bracket.matches.iter().find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 1).unwrap().clone(); + assert!(gf.player1_id.is_some() && gf.player2_id.is_some()); + + // WB champion wins GF + BracketService::record_result(&mut bracket, gf.id, gf.player1_id.unwrap()).unwrap(); + + // Tournament should be complete + assert_eq!(bracket.status, TournamentStatus::Completed); + assert_eq!(bracket.winner_id, gf.player1_id); + } + + #[test] + fn double_elimination_bracket_reset_works() { + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(4), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + // Simulate tournament where LB champion wins first GF match + let wb_r1: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .map(|m| m.id) + .collect(); + + let m1 = bracket.matches.iter().find(|m| m.id == wb_r1[0]).unwrap().clone(); + let m2 = bracket.matches.iter().find(|m| m.id == wb_r1[1]).unwrap().clone(); + + // Different winners for variety + BracketService::record_result(&mut bracket, wb_r1[0], m1.player1_id.unwrap()).unwrap(); + BracketService::record_result(&mut bracket, wb_r1[1], m2.player1_id.unwrap()).unwrap(); + + let wb_r2: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 2 && m.bracket_type == BracketType::Winners) + .map(|m| m.id) + .collect(); + + let wb_final = bracket.matches.iter().find(|m| m.id == wb_r2[0]).unwrap().clone(); + BracketService::record_result(&mut bracket, wb_r2[0], wb_final.player1_id.unwrap()).unwrap(); + + // Complete LB + let lb_matches: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.bracket_type == BracketType::Losers && m.status == MatchStatus::Pending) + .map(|m| m.id) + .collect(); + + for id in lb_matches { + if let Some(m) = bracket.matches.iter().find(|m| m.id == id) { + if let Some(p1) = m.player1_id { + BracketService::record_result(&mut bracket, id, p1).unwrap(); + } + } + } + + // LB champion wins first GF match (triggering bracket reset) + let gf = bracket.matches.iter().find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 1).unwrap().clone(); + BracketService::record_result(&mut bracket, gf.id, gf.player2_id.unwrap()).unwrap(); + + // Should need bracket reset + assert!(bracket.bracket_reset_required); + assert_eq!(bracket.status, TournamentStatus::InProgress); + + // Second GF match should exist + let gf2 = bracket.matches.iter().find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 2).unwrap().clone(); + + // WB champion wins second GF match + BracketService::record_result(&mut bracket, gf2.id, gf2.player1_id.unwrap()).unwrap(); + + // Tournament should now be complete + assert_eq!(bracket.status, TournamentStatus::Completed); + assert!(bracket.winner_id.is_some()); + } + + #[test] + fn double_elimination_byes_given_to_top_seeds() { + let bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(12), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + // 12 players -> 16 bracket size -> 4 byes + // Top 4 seeds should get byes + let wb_r1: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .collect(); + + let bye_matches: Vec<_> = wb_r1 + .iter() + .filter(|m| m.status == MatchStatus::Completed) + .collect(); + + assert_eq!(bye_matches.len(), 4); + + // Verify that byes went to top seeds (highest ELO = lowest seed number) + let mut bye_seeds = Vec::new(); + for bye_match in bye_matches { + if let Some(winner_id) = bye_match.winner_id { + if let Some(p) = bracket.participants.iter().find(|p| p.id == winner_id) { + bye_seeds.push(p.seed); + } + } + } + + bye_seeds.sort(); + // Top 4 seeds (1, 2, 3, 4) should have gotten byes + assert_eq!(bye_seeds, vec![1, 2, 3, 4]); + } + + /// Play every currently-resolvable match (two known players) with `player1_id` + /// declared winner, in order, until no more such matches exist. + fn play_resolvable_matches(bracket: &mut TournamentBracket) -> bool { + loop { + let candidates: Vec<(Uuid, Uuid)> = bracket + .matches + .iter() + .filter(|m| { + m.status == MatchStatus::Pending + && m.player1_id.is_some() + && m.player2_id.is_some() + }) + .map(|m| (m.id, m.player1_id.unwrap())) + .collect(); + + if candidates.is_empty() { + return bracket.status == TournamentStatus::Completed; + } + + for (id, winner) in candidates { + if bracket.matches.iter().any(|m| m.id == id && m.status != MatchStatus::Completed) + { + BracketService::record_result(bracket, id, winner).unwrap(); + } + } + if bracket.matches.iter().all(|m| m.status == MatchStatus::Completed) { + return true; + } + // If no candidate match can be resolved anymore and we are still not + // complete, the bracket cannot progress. + let resolvable = bracket.matches.iter().any(|m| { + m.status == MatchStatus::Pending + && m.player1_id.is_some() + && m.player2_id.is_some() + }); + if !resolvable { + return bracket.status == TournamentStatus::Completed; + } + } + } + + #[test] + fn double_elimination_no_duplicate_or_partial_pairings() { + // Every pending LB match must be fully paired (both players) once both + // of its feeders have resolved, and no player should appear twice in the + // same round. + for n in [4usize, 8, 12, 16, 32] { + let bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(n), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + for (round, matches) in &bracket + .matches + .iter() + .fold( + std::collections::BTreeMap::>::new(), + |mut acc, m| { + acc.entry(m.round).or_default().push(m); + acc + }, + ) + { + let _ = round; + let mut seen = std::collections::HashSet::new(); + for m in matches { + if m.player1_id.is_none() && m.player2_id.is_none() { + continue; + } + assert_ne!( + m.player1_id, + m.player2_id, + "Match {}-{} has the same player twice", + m.round, + m.match_number + ); + for p in [m.player1_id, m.player2_id].into_iter().flatten() { + assert!( + seen.insert(p), + "Player appears twice in round {} matches", + m.round + ); + } + } + } + } + } + + #[test] + fn double_elimination_8_players_full_progression() { + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(8), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + assert!( + play_resolvable_matches(&mut bracket), + "Tournament should complete with always-player1 winners" + ); + assert_eq!(bracket.status, TournamentStatus::Completed); + assert!(bracket.winner_id.is_some()); + + // Champion must be the overall tournament winner candidate (never lost). + let champion = bracket.winner_id.unwrap(); + let count_loses = bracket + .matches + .iter() + .filter(|m| { + m.winner_id.is_some() + && (m.player1_id == Some(champion) || m.player2_id == Some(champion)) + && m.winner_id != Some(champion) + }) + .count(); + assert_eq!(count_loses, 0, "Champion should have lost zero matches"); + } + + #[test] + fn double_elimination_16_players_full_progression() { + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(16), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + let wb_r1: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .map(|m| (m.id, m.player1_id)) + .collect(); + for (id, p1) in wb_r1 { + if let Some(p) = p1 { + BracketService::record_result(&mut bracket, id, p).unwrap(); + } + } + + // No duplicate LB pairings: every player must appear in exactly one match + // per LB round during play. + assert!( + play_resolvable_matches(&mut bracket), + "Tournament should complete" + ); + assert_eq!(bracket.status, TournamentStatus::Completed); + assert!(bracket.winner_id.is_some()); + } + + #[test] + fn double_elimination_32_players_full_progression() { + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(32), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + assert!( + play_resolvable_matches(&mut bracket), + "Tournament should complete" + ); + assert_eq!(bracket.status, TournamentStatus::Completed); + assert!(bracket.winner_id.is_some()); + } + + #[test] + fn double_elimination_12_players_odd_full_progression() { + // 12 players with 4 byes must still produce a fully resolvable bracket. + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(12), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + let wb_r1_filled: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .filter(|m| m.status == MatchStatus::Pending) + .map(|m| (m.id, m.player1_id)) + .collect(); + for (id, p1) in wb_r1_filled { + if let Some(p) = p1 { + BracketService::record_result(&mut bracket, id, p).unwrap(); + } + } + + assert!( + play_resolvable_matches(&mut bracket), + "Tournament should complete" + ); + assert_eq!(bracket.status, TournamentStatus::Completed); + assert!(bracket.winner_id.is_some()); + } + + #[test] + fn double_elimination_bracket_reset_full_progression() { + // Force the Losers Bracket champion to defeat the Winners Bracket champion + // in the first Grand Final, triggering a bracket reset and second GF match. + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(8), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + // Complete all WB and LB matches until GF match 1 is fully filled + // (WB champion in slot 1, LB champion in slot 2). + loop { + let candidates: Vec<(Uuid, Uuid)> = bracket + .matches + .iter() + .filter(|m| { + m.status == MatchStatus::Pending + && m.bracket_type != BracketType::GrandFinals + && m.player1_id.is_some() + && m.player2_id.is_some() + }) + .map(|m| (m.id, m.player1_id.unwrap())) + .collect(); + + let gf1 = bracket.matches.iter().find(|m| { + m.bracket_type == BracketType::GrandFinals && m.match_number == 1 + }); + if gf1.map(|m| m.player1_id.is_some() && m.player2_id.is_some()).unwrap_or(false) { + break; + } + assert!(!candidates.is_empty(), "Bracket stalled before GF1 was filled"); + for (id, winner) in candidates { + if bracket + .matches + .iter() + .any(|m| m.id == id && m.status != MatchStatus::Completed) + { + BracketService::record_result(&mut bracket, id, winner).unwrap(); + } + } + } + + // Ensure GF match 1 is filled. + let gf1 = bracket + .matches + .iter() + .find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 1) + .cloned() + .expect("GF match 1 should exist"); + assert!(gf1.player1_id.is_some() && gf1.player2_id.is_some()); + + // Player 2 (the LB champion) beats Player 1 (the WB champion). + BracketService::record_result(&mut bracket, gf1.id, gf1.player2_id.unwrap()).unwrap(); + + assert!(bracket.bracket_reset_required, "Bracket reset must trigger"); + assert_eq!(bracket.status, TournamentStatus::InProgress); + + // A second GF match must now exist. + let gf2 = bracket + .matches + .iter() + .find(|m| m.bracket_type == BracketType::GrandFinals && m.match_number == 2) + .cloned() + .expect("GF match 2 should be created on reset"); + assert!(gf2.player1_id.is_some() && gf2.player2_id.is_some()); + + // Finish the reset match - winner becomes champion. + BracketService::record_result(&mut bracket, gf2.id, gf2.player1_id.unwrap()).unwrap(); + + assert_eq!(bracket.status, TournamentStatus::Completed); + assert!(bracket.winner_id.is_some()); + } + + #[test] + fn double_elimination_losers_routed_correctly() { + let mut bracket = BracketService::create_bracket( + Uuid::new_v4(), + "Test", + make_participants(8), + BracketFormat::DoubleElimination, + ) + .unwrap(); + + BracketService::start_tournament(&mut bracket).unwrap(); + + // Complete WB Round 1 + let wb_r1_ids: Vec<_> = bracket + .matches + .iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Winners) + .map(|m| (m.id, m.player1_id)) + .collect(); + + for (match_id, p1) in wb_r1_ids { + if let Some(player1) = p1 { + BracketService::record_result(&mut bracket, match_id, player1).unwrap(); + } + } + + // Check that losers were sent to LB Round 1 + let lb_r1_filled = bracket.matches.iter() + .filter(|m| m.round == 1 && m.bracket_type == BracketType::Losers) + .filter(|m| m.player1_id.is_some() || m.player2_id.is_some()) + .count(); + + // Should have at least some losers in LB + assert!(lb_r1_filled > 0, "Some losers should be in LB Round 1"); + } }