this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): implemented deck rotations

+811 -84
+22 -1
CLAUDE.md
··· 4 4 5 5 ## Project 6 6 7 - Turn-based management game with a card-based UI (Magic: The Gathering style), playable in a web browser. This is a learning project — the user is discovering game development, Rust, and Svelte simultaneously. Prioritize explanations and trade-offs over raw code output. 7 + A turn-based management game with a card-based UI, playable in a web browser. The player runs a citizen-managed football (soccer) club rebuilt from bankruptcy. 8 + 9 + ### Lore 10 + 11 + The club was driven into bankruptcy by its previous owner, a predatory financier obsessed with his own legacy. A citizen collective bought the name and crest from the commercial court. The player is the **general secretary**, elected by the collective. The club has been demoted to the lower divisions and must rebuild — but **the goal is to endure**, not to climb back to the top flight. The score is the number of turns survived. 12 + 13 + ### Current scope (engine) 14 + 15 + - Three gauges: `money`, `internal_support`, `mental_load`. 16 + - Game over: `money <= 0`, `internal_support <= 0`, or `mental_load >= 100`. 17 + - Turn-based, solo. One action played per turn. 18 + - Card-based actions. Hand of 3, draws 1 at the start of each turn → player picks 1 of 4. 19 + - Action effects are deterministic for now (no randomness on outcomes). 20 + 21 + ### Planned (not implemented yet) 22 + 23 + - Per-turn events with 3 player choices. 24 + - Match results affecting gauges. 25 + - Multiple teams (pro, youth, women's, solidarity squads). 26 + - More divisions, deeper club structure (academies, partnerships). 27 + 28 + This is a learning project — the user is discovering game development, Rust, and Svelte simultaneously. Prioritize explanations and trade-offs over raw code output. 8 29 9 30 ## Learning mode (important) 10 31
+250 -10
backend/Cargo.lock
··· 103 103 "anyhow", 104 104 "axum", 105 105 "dotenvy", 106 + "rand 0.10.1", 106 107 "serde", 107 108 "serde_json", 108 109 "sqlx", ··· 161 162 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 162 163 163 164 [[package]] 165 + name = "chacha20" 166 + version = "0.10.0" 167 + source = "registry+https://github.com/rust-lang/crates.io-index" 168 + checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" 169 + dependencies = [ 170 + "cfg-if", 171 + "cpufeatures 0.3.0", 172 + "rand_core 0.10.1", 173 + ] 174 + 175 + [[package]] 164 176 name = "concurrent-queue" 165 177 version = "2.5.0" 166 178 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 180 192 version = "0.2.17" 181 193 source = "registry+https://github.com/rust-lang/crates.io-index" 182 194 checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 195 + dependencies = [ 196 + "libc", 197 + ] 198 + 199 + [[package]] 200 + name = "cpufeatures" 201 + version = "0.3.0" 202 + source = "registry+https://github.com/rust-lang/crates.io-index" 203 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" 183 204 dependencies = [ 184 205 "libc", 185 206 ] ··· 430 451 ] 431 452 432 453 [[package]] 454 + name = "getrandom" 455 + version = "0.4.2" 456 + source = "registry+https://github.com/rust-lang/crates.io-index" 457 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 458 + dependencies = [ 459 + "cfg-if", 460 + "libc", 461 + "r-efi", 462 + "rand_core 0.10.1", 463 + "wasip2", 464 + "wasip3", 465 + ] 466 + 467 + [[package]] 433 468 name = "hashbrown" 434 469 version = "0.15.5" 435 470 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 657 692 ] 658 693 659 694 [[package]] 695 + name = "id-arena" 696 + version = "2.3.0" 697 + source = "registry+https://github.com/rust-lang/crates.io-index" 698 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 699 + 700 + [[package]] 660 701 name = "idna" 661 702 version = "1.1.0" 662 703 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 685 726 dependencies = [ 686 727 "equivalent", 687 728 "hashbrown 0.17.0", 729 + "serde", 730 + "serde_core", 688 731 ] 689 732 690 733 [[package]] ··· 701 744 dependencies = [ 702 745 "spin", 703 746 ] 747 + 748 + [[package]] 749 + name = "leb128fmt" 750 + version = "0.1.0" 751 + source = "registry+https://github.com/rust-lang/crates.io-index" 752 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 704 753 705 754 [[package]] 706 755 name = "libc" ··· 825 874 "num-integer", 826 875 "num-iter", 827 876 "num-traits", 828 - "rand", 877 + "rand 0.8.6", 829 878 "smallvec", 830 879 "zeroize", 831 880 ] ··· 968 1017 ] 969 1018 970 1019 [[package]] 1020 + name = "prettyplease" 1021 + version = "0.2.37" 1022 + source = "registry+https://github.com/rust-lang/crates.io-index" 1023 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 1024 + dependencies = [ 1025 + "proc-macro2", 1026 + "syn", 1027 + ] 1028 + 1029 + [[package]] 971 1030 name = "proc-macro2" 972 1031 version = "1.0.106" 973 1032 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 986 1045 ] 987 1046 988 1047 [[package]] 1048 + name = "r-efi" 1049 + version = "6.0.0" 1050 + source = "registry+https://github.com/rust-lang/crates.io-index" 1051 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 1052 + 1053 + [[package]] 989 1054 name = "rand" 990 1055 version = "0.8.6" 991 1056 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 993 1058 dependencies = [ 994 1059 "libc", 995 1060 "rand_chacha", 996 - "rand_core", 1061 + "rand_core 0.6.4", 1062 + ] 1063 + 1064 + [[package]] 1065 + name = "rand" 1066 + version = "0.10.1" 1067 + source = "registry+https://github.com/rust-lang/crates.io-index" 1068 + checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" 1069 + dependencies = [ 1070 + "chacha20", 1071 + "getrandom 0.4.2", 1072 + "rand_core 0.10.1", 997 1073 ] 998 1074 999 1075 [[package]] ··· 1003 1079 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1004 1080 dependencies = [ 1005 1081 "ppv-lite86", 1006 - "rand_core", 1082 + "rand_core 0.6.4", 1007 1083 ] 1008 1084 1009 1085 [[package]] ··· 1012 1088 source = "registry+https://github.com/rust-lang/crates.io-index" 1013 1089 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1014 1090 dependencies = [ 1015 - "getrandom", 1091 + "getrandom 0.2.17", 1016 1092 ] 1093 + 1094 + [[package]] 1095 + name = "rand_core" 1096 + version = "0.10.1" 1097 + source = "registry+https://github.com/rust-lang/crates.io-index" 1098 + checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" 1017 1099 1018 1100 [[package]] 1019 1101 name = "redox_syscall" ··· 1063 1145 "num-traits", 1064 1146 "pkcs1", 1065 1147 "pkcs8", 1066 - "rand_core", 1148 + "rand_core 0.6.4", 1067 1149 "signature", 1068 1150 "spki", 1069 1151 "subtle", ··· 1081 1163 version = "1.2.0" 1082 1164 source = "registry+https://github.com/rust-lang/crates.io-index" 1083 1165 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1166 + 1167 + [[package]] 1168 + name = "semver" 1169 + version = "1.0.28" 1170 + source = "registry+https://github.com/rust-lang/crates.io-index" 1171 + checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" 1084 1172 1085 1173 [[package]] 1086 1174 name = "serde" ··· 1155 1243 checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1156 1244 dependencies = [ 1157 1245 "cfg-if", 1158 - "cpufeatures", 1246 + "cpufeatures 0.2.17", 1159 1247 "digest", 1160 1248 ] 1161 1249 ··· 1166 1254 checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 1167 1255 dependencies = [ 1168 1256 "cfg-if", 1169 - "cpufeatures", 1257 + "cpufeatures 0.2.17", 1170 1258 "digest", 1171 1259 ] 1172 1260 ··· 1196 1284 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 1197 1285 dependencies = [ 1198 1286 "digest", 1199 - "rand_core", 1287 + "rand_core 0.6.4", 1200 1288 ] 1201 1289 1202 1290 [[package]] ··· 1357 1445 "memchr", 1358 1446 "once_cell", 1359 1447 "percent-encoding", 1360 - "rand", 1448 + "rand 0.8.6", 1361 1449 "rsa", 1362 1450 "serde", 1363 1451 "sha1", ··· 1395 1483 "md-5", 1396 1484 "memchr", 1397 1485 "once_cell", 1398 - "rand", 1486 + "rand 0.8.6", 1399 1487 "serde", 1400 1488 "serde_json", 1401 1489 "sha2", ··· 1715 1803 checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" 1716 1804 1717 1805 [[package]] 1806 + name = "unicode-xid" 1807 + version = "0.2.6" 1808 + source = "registry+https://github.com/rust-lang/crates.io-index" 1809 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 1810 + 1811 + [[package]] 1718 1812 name = "url" 1719 1813 version = "2.5.8" 1720 1814 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1757 1851 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1758 1852 1759 1853 [[package]] 1854 + name = "wasip2" 1855 + version = "1.0.3+wasi-0.2.9" 1856 + source = "registry+https://github.com/rust-lang/crates.io-index" 1857 + checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" 1858 + dependencies = [ 1859 + "wit-bindgen 0.57.1", 1860 + ] 1861 + 1862 + [[package]] 1863 + name = "wasip3" 1864 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 1865 + source = "registry+https://github.com/rust-lang/crates.io-index" 1866 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 1867 + dependencies = [ 1868 + "wit-bindgen 0.51.0", 1869 + ] 1870 + 1871 + [[package]] 1760 1872 name = "wasite" 1761 1873 version = "0.1.0" 1762 1874 source = "registry+https://github.com/rust-lang/crates.io-index" 1763 1875 checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" 1876 + 1877 + [[package]] 1878 + name = "wasm-encoder" 1879 + version = "0.244.0" 1880 + source = "registry+https://github.com/rust-lang/crates.io-index" 1881 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 1882 + dependencies = [ 1883 + "leb128fmt", 1884 + "wasmparser", 1885 + ] 1886 + 1887 + [[package]] 1888 + name = "wasm-metadata" 1889 + version = "0.244.0" 1890 + source = "registry+https://github.com/rust-lang/crates.io-index" 1891 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 1892 + dependencies = [ 1893 + "anyhow", 1894 + "indexmap", 1895 + "wasm-encoder", 1896 + "wasmparser", 1897 + ] 1898 + 1899 + [[package]] 1900 + name = "wasmparser" 1901 + version = "0.244.0" 1902 + source = "registry+https://github.com/rust-lang/crates.io-index" 1903 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 1904 + dependencies = [ 1905 + "bitflags", 1906 + "hashbrown 0.15.5", 1907 + "indexmap", 1908 + "semver", 1909 + ] 1764 1910 1765 1911 [[package]] 1766 1912 name = "whoami" ··· 1852 1998 version = "0.48.5" 1853 1999 source = "registry+https://github.com/rust-lang/crates.io-index" 1854 2000 checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 2001 + 2002 + [[package]] 2003 + name = "wit-bindgen" 2004 + version = "0.51.0" 2005 + source = "registry+https://github.com/rust-lang/crates.io-index" 2006 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 2007 + dependencies = [ 2008 + "wit-bindgen-rust-macro", 2009 + ] 2010 + 2011 + [[package]] 2012 + name = "wit-bindgen" 2013 + version = "0.57.1" 2014 + source = "registry+https://github.com/rust-lang/crates.io-index" 2015 + checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 2016 + 2017 + [[package]] 2018 + name = "wit-bindgen-core" 2019 + version = "0.51.0" 2020 + source = "registry+https://github.com/rust-lang/crates.io-index" 2021 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 2022 + dependencies = [ 2023 + "anyhow", 2024 + "heck", 2025 + "wit-parser", 2026 + ] 2027 + 2028 + [[package]] 2029 + name = "wit-bindgen-rust" 2030 + version = "0.51.0" 2031 + source = "registry+https://github.com/rust-lang/crates.io-index" 2032 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 2033 + dependencies = [ 2034 + "anyhow", 2035 + "heck", 2036 + "indexmap", 2037 + "prettyplease", 2038 + "syn", 2039 + "wasm-metadata", 2040 + "wit-bindgen-core", 2041 + "wit-component", 2042 + ] 2043 + 2044 + [[package]] 2045 + name = "wit-bindgen-rust-macro" 2046 + version = "0.51.0" 2047 + source = "registry+https://github.com/rust-lang/crates.io-index" 2048 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 2049 + dependencies = [ 2050 + "anyhow", 2051 + "prettyplease", 2052 + "proc-macro2", 2053 + "quote", 2054 + "syn", 2055 + "wit-bindgen-core", 2056 + "wit-bindgen-rust", 2057 + ] 2058 + 2059 + [[package]] 2060 + name = "wit-component" 2061 + version = "0.244.0" 2062 + source = "registry+https://github.com/rust-lang/crates.io-index" 2063 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 2064 + dependencies = [ 2065 + "anyhow", 2066 + "bitflags", 2067 + "indexmap", 2068 + "log", 2069 + "serde", 2070 + "serde_derive", 2071 + "serde_json", 2072 + "wasm-encoder", 2073 + "wasm-metadata", 2074 + "wasmparser", 2075 + "wit-parser", 2076 + ] 2077 + 2078 + [[package]] 2079 + name = "wit-parser" 2080 + version = "0.244.0" 2081 + source = "registry+https://github.com/rust-lang/crates.io-index" 2082 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 2083 + dependencies = [ 2084 + "anyhow", 2085 + "id-arena", 2086 + "indexmap", 2087 + "log", 2088 + "semver", 2089 + "serde", 2090 + "serde_derive", 2091 + "serde_json", 2092 + "unicode-xid", 2093 + "wasmparser", 2094 + ] 1855 2095 1856 2096 [[package]] 1857 2097 name = "writeable"
+1
backend/Cargo.toml
··· 7 7 anyhow = "1.0.102" 8 8 axum = "0.8.9" 9 9 dotenvy = "0.15.7" 10 + rand = "0.10.1" 10 11 serde = { version = "1.0.228", features = ["derive"] } 11 12 serde_json = "1.0.149" 12 13 sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "json"] }
+156 -5
backend/src/game/action.rs
··· 1 - pub struct Action { 2 - id: String, 3 - label: String, 4 - effect: GaugeDelta, 5 - } 1 + use std::collections::HashMap; 2 + 3 + use serde::{Deserialize, Serialize}; 4 + 5 + use crate::game::{GameState, GameStatus, Gauges}; 6 6 7 + #[derive(Default, PartialEq, Serialize, Deserialize, Debug, Clone)] 8 + #[serde(deny_unknown_fields)] 7 9 pub struct GaugeDelta { 8 10 pub money: i32, 9 11 pub internal_support: i32, 10 12 pub mental_load: i32, 11 13 } 14 + 15 + #[derive(Debug, Serialize, Deserialize, Clone)] 16 + #[serde(deny_unknown_fields)] 17 + pub struct Action { 18 + pub id: String, 19 + pub label: String, 20 + pub effect: GaugeDelta, 21 + } 22 + 23 + #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 24 + pub enum GameError { 25 + GameOver, 26 + CardNotInHand, 27 + CardNotInGame, 28 + } 29 + 30 + pub fn apply_action( 31 + game_state: &GameState, 32 + action_id: &str, 33 + catalog: &HashMap<String, Action>, 34 + ) -> Result<GameState, GameError> { 35 + if game_state.status != GameStatus::Running { 36 + return Err(GameError::GameOver); 37 + } 38 + 39 + if game_state.hand.iter().any(|id| id == action_id) { 40 + let action = catalog.get(action_id).ok_or(GameError::CardNotInGame)?; 41 + let next_gauges = Gauges { 42 + money: game_state.gauges.money + action.effect.money, 43 + mental_load: game_state.gauges.mental_load + action.effect.mental_load, 44 + internal_support: game_state.gauges.internal_support + action.effect.internal_support, 45 + }; 46 + 47 + let next_game_status = GameStatus::from_gauges(next_gauges); 48 + 49 + let mut next_deck = game_state.deck.clone(); 50 + 51 + let mut next_hand = game_state 52 + .hand 53 + .iter() 54 + .filter(|id| id != &action_id) 55 + .cloned() 56 + .collect::<Vec<_>>(); 57 + 58 + match next_deck.pop() { 59 + Some(id) => { 60 + next_hand.push(id); 61 + } 62 + None => { 63 + // When the deck is empty refills it with the catalog minus the current hand 64 + let available: Vec<String> = catalog 65 + .keys() 66 + .filter(|id| !next_hand.contains(id)) 67 + .cloned() 68 + .collect(); 69 + next_deck = GameState::fresh_deck(&available); 70 + // fresh_deck panics if ids is empty so it garantise next_deck.pop() has a Some 71 + next_hand.push(next_deck.pop().unwrap()); 72 + } 73 + } 74 + 75 + Ok(GameState { 76 + turn: game_state.turn + 1, 77 + gauges: next_gauges, 78 + status: next_game_status, 79 + hand: next_hand, 80 + deck: next_deck, 81 + }) 82 + } else { 83 + Err(GameError::CardNotInHand) 84 + } 85 + } 86 + 87 + #[cfg(test)] 88 + mod tests { 89 + use crate::game::{GameOverReason, default_catalog, state::HAND_SIZE}; 90 + 91 + use super::*; 92 + 93 + #[test] 94 + fn test_apply_action_new_game() { 95 + let catalog = default_catalog(); 96 + let ids = catalog.keys().cloned().collect::<Vec<_>>(); 97 + let new_game = GameState::new(&ids); 98 + let next_action_id = new_game.hand[0].clone(); 99 + let next_game_state = apply_action(&new_game, &next_action_id, &catalog).unwrap(); 100 + assert_eq!(next_game_state.turn, 2); 101 + } 102 + 103 + #[test] 104 + fn test_apply_action_new_turn() { 105 + let catalog = default_catalog(); 106 + let ids = catalog.keys().cloned().collect::<Vec<_>>(); 107 + let new_game = GameState::new(&ids); 108 + let next_action_id = new_game.hand[0].clone(); 109 + let next_game_state = apply_action(&new_game, &next_action_id, &catalog).unwrap(); 110 + assert_eq!(next_game_state.turn, 2); 111 + assert_eq!(next_game_state.hand.len(), HAND_SIZE); 112 + assert_eq!(new_game.deck.len(), ids.len() - HAND_SIZE); 113 + assert!(next_game_state.hand.iter().all(|id| !id.is_empty())); 114 + } 115 + 116 + #[test] 117 + fn test_apply_action_reset_deck_when_empty() { 118 + let catalog = default_catalog(); 119 + let ids = catalog.keys().cloned().take(1).collect::<Vec<_>>(); 120 + let new_game = GameState::new(&ids); 121 + let next_action_id = new_game.hand[0].clone(); 122 + let next_game_state = apply_action(&new_game, &next_action_id, &catalog).unwrap(); 123 + assert_eq!(next_game_state.turn, 2); 124 + assert_eq!(next_game_state.hand.len(), 1); 125 + // All the catalog minus the action just played. 126 + assert_eq!(next_game_state.deck.len(), catalog.len() - 1); 127 + } 128 + 129 + #[test] 130 + fn test_apply_action_game_over() { 131 + let action_id = String::from("test_action"); 132 + let action = Action { 133 + id: action_id.clone(), 134 + label: String::from("Test action"), 135 + effect: GaugeDelta { 136 + money: 0, 137 + internal_support: -1, 138 + mental_load: 0, 139 + }, 140 + }; 141 + let mut catalog = default_catalog(); 142 + catalog.insert(action_id.clone(), action); 143 + let mut new_game = GameState { 144 + turn: 1, 145 + status: GameStatus::Running, 146 + gauges: Gauges { 147 + mental_load: 1, 148 + money: 50, 149 + internal_support: 1, 150 + }, 151 + hand: Vec::new(), 152 + deck: Vec::new(), 153 + }; 154 + // Manually add the cart into the hand 155 + new_game.hand.push(action_id.clone()); 156 + let next_game_state = apply_action(&new_game, &action_id, &catalog).unwrap(); 157 + assert_eq!( 158 + next_game_state.status, 159 + GameStatus::Lost(GameOverReason::LostSupport) 160 + ); 161 + } 162 + }
+100
backend/src/game/catalog.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use super::action::{Action, GaugeDelta}; 4 + 5 + pub fn default_catalog() -> HashMap<String, Action> { 6 + let actions = vec![ 7 + Action { 8 + id: "tombola_associative".to_string(), 9 + label: "Organiser une tombola associative".to_string(), 10 + effect: GaugeDelta { 11 + money: 150, 12 + internal_support: 5, 13 + mental_load: 5, 14 + }, 15 + }, 16 + Action { 17 + id: "vente_jeune_espoir".to_string(), 18 + label: "Vendre un jeune joueur formé au club".to_string(), 19 + effect: GaugeDelta { 20 + money: 400, 21 + internal_support: -20, 22 + mental_load: 10, 23 + }, 24 + }, 25 + Action { 26 + id: "partenariat_boulangerie".to_string(), 27 + label: "Sceller un partenariat avec la boulangerie du quartier".to_string(), 28 + effect: GaugeDelta { 29 + money: 80, 30 + internal_support: 5, 31 + mental_load: 0, 32 + }, 33 + }, 34 + Action { 35 + id: "journee_chantier_benevole".to_string(), 36 + label: "Organiser une journée de chantier au stade".to_string(), 37 + effect: GaugeDelta { 38 + money: 50, 39 + internal_support: 10, 40 + mental_load: -5, 41 + }, 42 + }, 43 + Action { 44 + id: "assemblee_generale".to_string(), 45 + label: "Convoquer une assemblée générale du collectif".to_string(), 46 + effect: GaugeDelta { 47 + money: -20, 48 + internal_support: 15, 49 + mental_load: 10, 50 + }, 51 + }, 52 + Action { 53 + id: "reporter_salaires".to_string(), 54 + label: "Reporter les salaires de l'équipe pro d'un mois".to_string(), 55 + effect: GaugeDelta { 56 + money: 300, 57 + internal_support: -25, 58 + mental_load: 15, 59 + }, 60 + }, 61 + Action { 62 + id: "dossier_subvention_municipale".to_string(), 63 + label: "Déposer un dossier de subvention municipale".to_string(), 64 + effect: GaugeDelta { 65 + money: 200, 66 + internal_support: 0, 67 + mental_load: 15, 68 + }, 69 + }, 70 + Action { 71 + id: "refus_sponsor_petrolier".to_string(), 72 + label: "Refuser un sponsor pétrolier".to_string(), 73 + effect: GaugeDelta { 74 + money: -100, 75 + internal_support: 20, 76 + mental_load: 0, 77 + }, 78 + }, 79 + Action { 80 + id: "weekend_repos".to_string(), 81 + label: "S'accorder un week-end de repos".to_string(), 82 + effect: GaugeDelta { 83 + money: -30, 84 + internal_support: -3, 85 + mental_load: -20, 86 + }, 87 + }, 88 + Action { 89 + id: "entrainement_ouvert_quartier".to_string(), 90 + label: "Ouvrir les entraînements aux écoles du quartier".to_string(), 91 + effect: GaugeDelta { 92 + money: -40, 93 + internal_support: 10, 94 + mental_load: 5, 95 + }, 96 + }, 97 + ]; 98 + 99 + actions.into_iter().map(|a| (a.id.clone(), a)).collect() 100 + }
+4 -1
backend/src/game/mod.rs
··· 2 2 pub use state::{GameOverReason, GameState, GameStatus, Gauges}; 3 3 4 4 pub mod action; 5 - pub use action::{Action, GaugeDelta}; 5 + pub use action::{Action, GameError, GaugeDelta, apply_action}; 6 + 7 + pub mod catalog; 8 + pub use catalog::default_catalog;
+114 -14
backend/src/game/state.rs
··· 1 + use rand::rng; 2 + use rand::seq::SliceRandom; 1 3 use serde::{Deserialize, Serialize}; 4 + 5 + pub const HAND_SIZE: usize = 3; 2 6 3 7 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] 4 8 pub struct Gauges { ··· 33 37 34 38 impl GameStatus { 35 39 pub fn from_gauges(gauges: Gauges) -> Self { 36 - // TODO(human): inspect `gauges` and return the correct GameStatus. 37 - // - money <= 0 → Lost(Bankruptcy) 38 - // - internal_support <= 0 → Lost(LostSupport) 39 - // - mental_load >= 100 → Lost(Burnout) 40 - // - otherwise -> Running 41 - let min_internal_support = 1; 42 - let max_mental_load = 99; 43 - let min_money = 1; 44 - if gauges.internal_support < min_internal_support { 40 + // Priorité en cas de cumul: support > burnout > bankruptcy 41 + if gauges.internal_support <= 0 { 45 42 return GameStatus::Lost(GameOverReason::LostSupport); 46 43 } 47 - if gauges.mental_load > max_mental_load { 44 + if gauges.mental_load >= 100 { 48 45 return GameStatus::Lost(GameOverReason::Burnout); 49 46 } 50 - if gauges.money < min_money { 47 + if gauges.money <= 0 { 51 48 return GameStatus::Lost(GameOverReason::Bankruptcy); 52 49 } 53 50 GameStatus::Running ··· 56 53 57 54 #[derive(Debug, Clone, Serialize, Deserialize)] 58 55 pub struct GameState { 59 - turn: u32, 60 - gauges: Gauges, 61 - status: GameStatus, 56 + pub turn: u32, 57 + pub gauges: Gauges, 58 + pub status: GameStatus, 59 + pub hand: Vec<String>, 60 + pub deck: Vec<String>, 61 + } 62 + 63 + impl GameState { 64 + pub fn new(catalog_ids: &[String]) -> Self { 65 + let starting_gauges = Gauges::starting(); 66 + 67 + let mut deck = GameState::fresh_deck(&catalog_ids); 68 + 69 + let hand: Vec<String> = deck.drain(0..HAND_SIZE.min(deck.len())).collect(); 70 + 71 + Self { 72 + turn: 1, 73 + gauges: starting_gauges, 74 + status: GameStatus::from_gauges(starting_gauges), 75 + deck, 76 + hand, 77 + } 78 + } 79 + 80 + pub fn fresh_deck(catalog_ids: &[String]) -> Vec<String> { 81 + if catalog_ids.is_empty() { 82 + panic!("Catalog must not be empty."); 83 + } 84 + let mut deck = catalog_ids.to_vec(); 85 + deck.shuffle(&mut rng()); 86 + deck 87 + } 88 + } 89 + 90 + #[cfg(test)] 91 + mod tests { 92 + use super::*; 93 + 94 + #[test] 95 + fn game_status_from_gauges() { 96 + let game_status = GameStatus::from_gauges(Gauges::starting()); 97 + assert_eq!(game_status, GameStatus::Running); 98 + 99 + assert_eq!( 100 + GameStatus::from_gauges(Gauges { 101 + money: 1, 102 + internal_support: 1, 103 + mental_load: 99 104 + }), 105 + GameStatus::Running 106 + ); 107 + 108 + assert_eq!( 109 + GameStatus::from_gauges(Gauges { 110 + money: 0, 111 + internal_support: 1, 112 + mental_load: 99 113 + }), 114 + GameStatus::Lost(GameOverReason::Bankruptcy) 115 + ); 116 + 117 + assert_eq!( 118 + GameStatus::from_gauges(Gauges { 119 + money: 1, 120 + internal_support: 0, 121 + mental_load: 99 122 + }), 123 + GameStatus::Lost(GameOverReason::LostSupport) 124 + ); 125 + 126 + assert_eq!( 127 + GameStatus::from_gauges(Gauges { 128 + money: 1, 129 + internal_support: 1, 130 + mental_load: 100 131 + }), 132 + GameStatus::Lost(GameOverReason::Burnout) 133 + ); 134 + 135 + assert_eq!( 136 + GameStatus::from_gauges(Gauges { 137 + money: 1, 138 + internal_support: 0, 139 + mental_load: 100 140 + }), 141 + GameStatus::Lost(GameOverReason::LostSupport) 142 + ); 143 + 144 + assert_eq!( 145 + GameStatus::from_gauges(Gauges { 146 + money: 0, 147 + internal_support: 1, 148 + mental_load: 100 149 + }), 150 + GameStatus::Lost(GameOverReason::Burnout) 151 + ); 152 + 153 + assert_eq!( 154 + GameStatus::from_gauges(Gauges { 155 + money: 0, 156 + internal_support: 0, 157 + mental_load: 100 158 + }), 159 + GameStatus::Lost(GameOverReason::LostSupport) 160 + ); 161 + } 62 162 }
+3 -34
backend/src/main.rs
··· 5 5 response::IntoResponse, 6 6 routing::{get, post}, 7 7 }; 8 - use serde::{Deserialize, Serialize}; 9 8 use sqlx::PgPool; 10 9 use tower_http::cors::CorsLayer; 11 10 use tower_http::trace::TraceLayer; 12 11 use tracing_subscriber; 13 12 14 13 mod game; 15 - use crate::game::Gauges; 14 + use crate::game::{GameState, default_catalog}; 16 15 17 16 // === App State === 18 17 // Ce qui est partage entre tous les handlers (clone-able, passe via State) ··· 22 21 db: PgPool, 23 22 } 24 23 25 - // === Models === 26 - // Pour l'instant un GameState minimal — on le fera grandir plus tard 27 - 28 - #[derive(Debug, Clone, Serialize, Deserialize)] 29 - struct GameState { 30 - turn: u32, 31 - phase: String, // deviendra un enum plus tard 32 - resources: Resources, 33 - } 34 - 35 - #[derive(Debug, Clone, Serialize, Deserialize)] 36 - struct Resources { 37 - gold: i32, 38 - food: i32, 39 - population: i32, 40 - } 41 - 42 - impl GameState { 43 - fn new() -> Self { 44 - Self { 45 - turn: 1, 46 - phase: "draw".to_string(), 47 - resources: Resources { 48 - gold: 100, 49 - food: 50, 50 - population: 10, 51 - }, 52 - } 53 - } 54 - } 55 - 56 24 // === Errors === 57 25 // Un type d'erreur applicatif simple avec anyhow en interne 58 26 ··· 89 57 } 90 58 91 59 async fn create_game(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> { 92 - let game = GameState::new(); 60 + let ids = default_catalog().keys().cloned().collect::<Vec<_>>(); 61 + let game = GameState::new(&ids); 93 62 let game_json = serde_json::to_value(&game).unwrap(); 94 63 95 64 let row = sqlx::query_scalar!(
+161 -19
todo.md
··· 17 17 18 18 - [x] `Gauges` — struct avec `money: i32`, `internal_support: i32`, `mental_load: i32`. 19 19 Dérive `Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq`. 20 - - [ ] `Gauges::starting()` — valeurs de départ (`money: 1000`, `internal_support: 50`, 20 + - [x] `Gauges::starting()` — valeurs de départ (`money: 1000`, `internal_support: 50`, 21 21 `mental_load: 20`). 22 - - [ ] `GameOverReason` — enum unitaire: `Bankruptcy`, `LostSupport`, `Burnout`. 22 + - [x] `GameOverReason` — enum unitaire: `Bankruptcy`, `LostSupport`, `Burnout`. 23 23 - [ ] `GameStatus` — enum: `Running` | `Lost(GameOverReason)`. Utiliser 24 24 `#[serde(tag = "kind", content = "reason")]` pour un JSON propre. 25 - - [ ] `GameState` — struct: `turn: u32`, `gauges: Gauges`, `status: GameStatus`. 26 - - [ ] `GameState::new()` — construit l'état initial en utilisant `Gauges::starting()` 25 + - [x] `GameState` — struct: `turn: u32`, `gauges: Gauges`, `status: GameStatus`. 26 + - [x] `GameState::new()` — construit l'état initial en utilisant `Gauges::starting()` 27 27 et `GameStatus::from_gauges(...)`. 28 28 29 29 ## 3. Règle de fin de partie (`backend/src/game/state.rs`) 30 30 31 - - [ ] `GameStatus::from_gauges(Gauges) -> GameStatus`. 31 + - [x] `GameStatus::from_gauges(Gauges) -> GameStatus`. 32 32 - `money <= 0` → `Lost(Bankruptcy)` 33 33 - `internal_support <= 0` → `Lost(LostSupport)` 34 34 - `mental_load >= 100` → `Lost(Burnout)` ··· 38 38 39 39 ## 4. Actions et transition (`backend/src/game/action.rs`) 40 40 41 - - [ ] `GaugeDelta` — même forme que `Gauges`. Dérive `Default` pour construire avec 41 + - [x] `GaugeDelta` — même forme que `Gauges`. Dérive `Default` pour construire avec 42 42 `..Default::default()`. 43 - - [ ] `Action` — struct: `id: String`, `label: String`, `effect: GaugeDelta`. 44 - - [ ] `GameError` — enum avec au moins `GameOver` pour l'instant. 45 - - [ ] `apply_action(&GameState, &Action) -> Result<GameState, GameError>`. 43 + - [x] `Action` — struct: `id: String`, `label: String`, `effect: GaugeDelta`. 44 + - [x] `GameError` — enum avec au moins `GameOver` pour l'instant. 45 + - [x] `apply_action(&GameState, &Action) -> Result<GameState, GameError>`. 46 46 - Si `state.status` n'est pas `Running` → `Err(GameOver)`. 47 47 - Sinon: additionner `effect` à `gauges`, `turn + 1`, recalculer `status`. 48 48 - Cœur de la boucle de jeu — état immuable en entrée, nouvel état en sortie. 49 49 50 50 ## 5. Câblage dans `main.rs` 51 51 52 - - [ ] Supprimer les anciens `struct GameState` et `struct Resources` (placeholders 52 + - [x] Supprimer les anciens `struct GameState` et `struct Resources` (placeholders 53 53 `gold/food/population`) devenus obsolètes. 54 - - [ ] Adapter `create_game` pour utiliser le nouveau `game::GameState::new()`. 54 + - [x] Adapter `create_game` pour utiliser le nouveau `game::GameState::new()`. 55 55 - [ ] Vérifier que `cargo build` passe (SQLx a besoin de Postgres lancé). 56 56 57 57 ## 6. Tests unitaires (optionnel mais recommandé) 58 58 59 - - [ ] Dans `state.rs`, module `#[cfg(test)]` qui vérifie `from_gauges` sur chaque 59 + - [x] Dans `state.rs`, module `#[cfg(test)]` qui vérifie `from_gauges` sur chaque 60 60 condition de fin + cas de coexistence (money ET support à 0 en même temps). 61 - - [ ] Dans `action.rs`, test qui applique une action et vérifie le nouvel état 61 + - [x] Dans `action.rs`, test qui applique une action et vérifie le nouvel état 62 62 (gauges, turn, status). 63 63 64 64 --- 65 65 66 - ## Plus tard (hors scope de cette itération) 66 + # Phase 2 — Boucle d'actions HTTP (cartes en main) 67 67 68 - - Endpoint `POST /games/{id}/actions` qui charge l'état, appelle `apply_action`, 69 - resauvegarde le JSONB. 70 - - Catalogue d'actions côté backend (ou côté frontend?) — à décider. 71 - - Système d'événements (3 choix par tour, tirés d'un pool). 72 - - Résultats de matchs et leur influence sur les jauges. 68 + Objectif: jouable de bout en bout depuis un client HTTP. Le joueur démarre avec 69 + 3 cartes en main, pioche 1 au début de chaque tour, joue 1 parmi 4. 70 + 71 + ## 7. Catalogue d'actions 72 + 73 + - [x] `src/game/catalog.rs` créé par Claude (10 actions thématiques). 74 + - [x] Déclarer `pub mod catalog;` dans `mod.rs` et re-exporter `default_catalog`. 75 + - [x] Vérifier que ça compile (`cargo check`) — révèlera probablement les 76 + éléments à corriger ci-dessous. 77 + 78 + ## 8. Finir la "publication" d'`Action` 79 + 80 + `catalog.rs` construit des `Action { id, label, effect }` directement → les 81 + champs doivent être `pub` (idem pour `GaugeDelta`). Sinon erreur "field is 82 + private". 83 + 84 + - [x] `pub` sur les champs d'`Action`. 85 + - [x] Vérifier que `GaugeDelta` a aussi tous ses champs en `pub`. 86 + 87 + ## 9. Sérialisation d'`Action` et `GaugeDelta` 88 + 89 + - [x] Dériver `Serialize, Deserialize, Clone, Debug, PartialEq` sur les deux. 90 + - [x] Ajouter `#[serde(deny_unknown_fields)]` sur les deux. (Ces types 91 + ne sont jamais persistés en JSONB — uniquement reçus en requête, 92 + donc on peut être strict.) 93 + 94 + ## 10. Mécanique de main et pioche dans `GameState` 95 + 96 + Le cœur de la phase 2. Décision d'architecture à prendre: 97 + 98 + **Question A — quoi stocker dans la main?** 99 + - (a) `hand: Vec<String>` (ids d'actions, on relie au catalogue à la lecture). 100 + - (b) `hand: Vec<Action>` (actions complètes dupliquées dans le state). 101 + 102 + → Reco: **(a)**. Plus léger, plus cohérent (le catalogue reste source unique 103 + de vérité). Coût: chaque lecture du JSONB doit "hydrater" les ids depuis le 104 + catalogue avant de répondre — pas grave. 105 + 106 + **Question B — gérer un discard pile ou pas?** 107 + - (a) `deck: Vec<String>` + `discard: Vec<String>`, on reshuffle quand le 108 + deck est vide. 109 + - (b) Juste `deck: Vec<String>`, les cartes jouées sont consommées; partie 110 + perdue par épuisement du deck (ou règle alternative). 111 + 112 + → Reco: **(b)** pour démarrer. Tu pourras ajouter un discard plus tard quand 113 + ça aura un sens gameplay (ex: certaines cartes "se recyclent", d'autres pas). 114 + 115 + À implémenter: 116 + - [x] Étendre `GameState` avec `hand: Vec<String>` et `deck: Vec<String>`. 117 + - [x] Constante (ou fonction) `HAND_SIZE: usize = 3`. 118 + - [x] `GameState::new(catalog_ids: &[String])` — prend la liste des ids 119 + disponibles, les mélange dans le deck, pioche `HAND_SIZE` dans la main. 120 + Note: `GameState::new` doit rester un constructeur **pur** (pas de 121 + dépendance HTTP/DB). Lui passer la liste d'ids est plus propre que 122 + lui passer la `HashMap` du catalogue. 123 + - [x] Ajouter la dépendance `rand` à `Cargo.toml` (`rand = "0.8"` ou récent) 124 + pour `SliceRandom::shuffle`. 125 + 126 + ## 11. Étendre `apply_action` 127 + 128 + L'API change. Nouvelle signature à débattre: 129 + 130 + ```rust 131 + pub fn apply_action( 132 + state: &GameState, 133 + action_id: &str, 134 + catalog: &HashMap<String, Action>, 135 + ) -> Result<GameState, GameError> 136 + ``` 137 + 138 + Logique: 139 + 1. Vérifier `status == Running` (existant). 140 + 2. Vérifier que `action_id` est dans `state.hand` → sinon 141 + `Err(GameError::CardNotInHand)`. 142 + 3. Récupérer l'`Action` depuis le catalogue → sinon `UnknownAction`. 143 + 4. Retirer la carte jouée de la main. 144 + 5. Piocher la prochaine carte du deck (si vide → décision: continuer sans 145 + pioche? `Err(GameError::DeckEmpty)`? À toi de trancher pour la sensation 146 + de jeu). 147 + 6. Appliquer l'effet, incrémenter le tour, recalculer le status. 148 + 149 + - [x] Étendre `GameError` avec les nouvelles variantes (`CardNotInHand`, 150 + `UnknownAction`, et éventuellement `DeckEmpty`). 151 + - [x] Mettre à jour les tests unitaires existants (signature changée). 152 + 153 + ## 12. `AppState` partage le catalogue 154 + 155 + Actuellement `AppState` n'a que `db`. Le handler HTTP aura besoin du 156 + catalogue. Comme `AppState` est cloné à chaque requête, il faut éviter de 157 + cloner la `HashMap` à chaque fois. 158 + 159 + - [ ] Ajouter `catalog: Arc<HashMap<String, Action>>` à `AppState`. 160 + - [ ] Dans `main`, construire le catalogue une fois et l'envelopper dans 161 + `Arc::new(...)` avant de le passer à `AppState`. 162 + 163 + ## 13. Étendre `AppError` 164 + 165 + Pour mapper `GameError` vers du HTTP: 166 + 167 + - [ ] Ajouter au moins `BadRequest(String)` (→ 400) et `Conflict(String)` 168 + (→ 409, pour les erreurs de règle comme `GameOver`/`CardNotInHand`). 169 + - [ ] Implémenter `From<GameError> for AppError` — choix: lesquelles 170 + mappent vers 400 vs 409 vs 422? (`GameOver` = 409 conflict, le client 171 + ne peut plus jouer; `UnknownAction` = 400 bad input; etc.) 172 + 173 + ## 14. Handler `POST /games/{id}/actions` 174 + 175 + - [ ] Définir un type `PlayActionRequest { action_id: String }` avec 176 + `Deserialize` et `deny_unknown_fields`. 177 + - [ ] Handler async: 178 + 1. Charger la ligne `games` par id (`fetch_optional` → 404 si absent). 179 + 2. Désérialiser le `state` JSONB en `GameState`. 180 + 3. Appeler `apply_action(&game_state, &body.action_id, &state.catalog)`. 181 + 4. Sérialiser le nouvel état. 182 + 5. `UPDATE games SET state = $1, updated_at = NOW() WHERE id = $2`. 183 + 6. Retourner le nouvel état en JSON. 184 + - [ ] Enregistrer la route: `.route("/games/{id}/actions", post(...))`. 185 + 186 + Pas de transaction pour l'instant (jeu solo, on accepte le risque de race). 187 + À durcir plus tard avec `pool.begin()` + `SELECT ... FOR UPDATE`. 188 + 189 + ## 15. Mettre à jour `create_game` 190 + 191 + - [ ] `GameState::new(...)` prend maintenant la liste d'ids du catalogue. 192 + Récupérer `state.catalog.keys()` et les passer. 193 + - [ ] La réponse renvoie le nouvel état (avec `hand` peuplée) — le frontend 194 + verra ses 3 cartes initiales. 195 + 196 + ## 16. Test bout en bout (manuel) 197 + 198 + - [ ] `curl -X POST localhost:3000/games` → noter l'`id` et regarder la `hand`. 199 + - [ ] `curl -X POST localhost:3000/games/{id}/actions -d '{"action_id":"..."}'` 200 + avec un id de la main → vérifier que le nouvel état revient avec une 201 + main rafraîchie et un tour incrémenté. 202 + - [ ] Tenter une action_id qui n'est pas dans la main → 409. 203 + - [ ] Tenter une action_id inexistante → 400. 204 + 205 + --- 206 + 207 + ## Plus tard (toujours hors scope) 208 + 209 + - Système d'événements (3 choix par tour, tirés d'un pool d'événements). 210 + - Résultats de matchs (équipes pro/jeunes/féminines/solidaires) et leur 211 + influence sur les jauges. 212 + - Persistence du nombre de tours survécus comme score. 213 + - Concurrence: `SELECT ... FOR UPDATE` dans une transaction. 214 + - Système de discard / recyclage de cartes.