···4455## Project
6677-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.
77+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.
88+99+### Lore
1010+1111+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.
1212+1313+### Current scope (engine)
1414+1515+- Three gauges: `money`, `internal_support`, `mental_load`.
1616+ - Game over: `money <= 0`, `internal_support <= 0`, or `mental_load >= 100`.
1717+- Turn-based, solo. One action played per turn.
1818+- Card-based actions. Hand of 3, draws 1 at the start of each turn → player picks 1 of 4.
1919+- Action effects are deterministic for now (no randomness on outcomes).
2020+2121+### Planned (not implemented yet)
2222+2323+- Per-turn events with 3 player choices.
2424+- Match results affecting gauges.
2525+- Multiple teams (pro, youth, women's, solidarity squads).
2626+- More divisions, deeper club structure (academies, partnerships).
2727+2828+This is a learning project — the user is discovering game development, Rust, and Svelte simultaneously. Prioritize explanations and trade-offs over raw code output.
829930## Learning mode (important)
1031
···22pub use state::{GameOverReason, GameState, GameStatus, Gauges};
3344pub mod action;
55-pub use action::{Action, GaugeDelta};
55+pub use action::{Action, GameError, GaugeDelta, apply_action};
66+77+pub mod catalog;
88+pub use catalog::default_catalog;
···55 response::IntoResponse,
66 routing::{get, post},
77};
88-use serde::{Deserialize, Serialize};
98use sqlx::PgPool;
109use tower_http::cors::CorsLayer;
1110use tower_http::trace::TraceLayer;
1211use tracing_subscriber;
13121413mod game;
1515-use crate::game::Gauges;
1414+use crate::game::{GameState, default_catalog};
16151716// === App State ===
1817// Ce qui est partage entre tous les handlers (clone-able, passe via State)
···2221 db: PgPool,
2322}
24232525-// === Models ===
2626-// Pour l'instant un GameState minimal — on le fera grandir plus tard
2727-2828-#[derive(Debug, Clone, Serialize, Deserialize)]
2929-struct GameState {
3030- turn: u32,
3131- phase: String, // deviendra un enum plus tard
3232- resources: Resources,
3333-}
3434-3535-#[derive(Debug, Clone, Serialize, Deserialize)]
3636-struct Resources {
3737- gold: i32,
3838- food: i32,
3939- population: i32,
4040-}
4141-4242-impl GameState {
4343- fn new() -> Self {
4444- Self {
4545- turn: 1,
4646- phase: "draw".to_string(),
4747- resources: Resources {
4848- gold: 100,
4949- food: 50,
5050- population: 10,
5151- },
5252- }
5353- }
5454-}
5555-5624// === Errors ===
5725// Un type d'erreur applicatif simple avec anyhow en interne
5826···8957}
90589159async fn create_game(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
9292- let game = GameState::new();
6060+ let ids = default_catalog().keys().cloned().collect::<Vec<_>>();
6161+ let game = GameState::new(&ids);
9362 let game_json = serde_json::to_value(&game).unwrap();
94639564 let row = sqlx::query_scalar!(
+161-19
todo.md
···17171818- [x] `Gauges` — struct avec `money: i32`, `internal_support: i32`, `mental_load: i32`.
1919 Dérive `Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq`.
2020-- [ ] `Gauges::starting()` — valeurs de départ (`money: 1000`, `internal_support: 50`,
2020+- [x] `Gauges::starting()` — valeurs de départ (`money: 1000`, `internal_support: 50`,
2121 `mental_load: 20`).
2222-- [ ] `GameOverReason` — enum unitaire: `Bankruptcy`, `LostSupport`, `Burnout`.
2222+- [x] `GameOverReason` — enum unitaire: `Bankruptcy`, `LostSupport`, `Burnout`.
2323- [ ] `GameStatus` — enum: `Running` | `Lost(GameOverReason)`. Utiliser
2424 `#[serde(tag = "kind", content = "reason")]` pour un JSON propre.
2525-- [ ] `GameState` — struct: `turn: u32`, `gauges: Gauges`, `status: GameStatus`.
2626-- [ ] `GameState::new()` — construit l'état initial en utilisant `Gauges::starting()`
2525+- [x] `GameState` — struct: `turn: u32`, `gauges: Gauges`, `status: GameStatus`.
2626+- [x] `GameState::new()` — construit l'état initial en utilisant `Gauges::starting()`
2727 et `GameStatus::from_gauges(...)`.
28282929## 3. Règle de fin de partie (`backend/src/game/state.rs`)
30303131-- [ ] `GameStatus::from_gauges(Gauges) -> GameStatus`.
3131+- [x] `GameStatus::from_gauges(Gauges) -> GameStatus`.
3232 - `money <= 0` → `Lost(Bankruptcy)`
3333 - `internal_support <= 0` → `Lost(LostSupport)`
3434 - `mental_load >= 100` → `Lost(Burnout)`
···38383939## 4. Actions et transition (`backend/src/game/action.rs`)
40404141-- [ ] `GaugeDelta` — même forme que `Gauges`. Dérive `Default` pour construire avec
4141+- [x] `GaugeDelta` — même forme que `Gauges`. Dérive `Default` pour construire avec
4242 `..Default::default()`.
4343-- [ ] `Action` — struct: `id: String`, `label: String`, `effect: GaugeDelta`.
4444-- [ ] `GameError` — enum avec au moins `GameOver` pour l'instant.
4545-- [ ] `apply_action(&GameState, &Action) -> Result<GameState, GameError>`.
4343+- [x] `Action` — struct: `id: String`, `label: String`, `effect: GaugeDelta`.
4444+- [x] `GameError` — enum avec au moins `GameOver` pour l'instant.
4545+- [x] `apply_action(&GameState, &Action) -> Result<GameState, GameError>`.
4646 - Si `state.status` n'est pas `Running` → `Err(GameOver)`.
4747 - Sinon: additionner `effect` à `gauges`, `turn + 1`, recalculer `status`.
4848 - Cœur de la boucle de jeu — état immuable en entrée, nouvel état en sortie.
49495050## 5. Câblage dans `main.rs`
51515252-- [ ] Supprimer les anciens `struct GameState` et `struct Resources` (placeholders
5252+- [x] Supprimer les anciens `struct GameState` et `struct Resources` (placeholders
5353 `gold/food/population`) devenus obsolètes.
5454-- [ ] Adapter `create_game` pour utiliser le nouveau `game::GameState::new()`.
5454+- [x] Adapter `create_game` pour utiliser le nouveau `game::GameState::new()`.
5555- [ ] Vérifier que `cargo build` passe (SQLx a besoin de Postgres lancé).
56565757## 6. Tests unitaires (optionnel mais recommandé)
58585959-- [ ] Dans `state.rs`, module `#[cfg(test)]` qui vérifie `from_gauges` sur chaque
5959+- [x] Dans `state.rs`, module `#[cfg(test)]` qui vérifie `from_gauges` sur chaque
6060 condition de fin + cas de coexistence (money ET support à 0 en même temps).
6161-- [ ] Dans `action.rs`, test qui applique une action et vérifie le nouvel état
6161+- [x] Dans `action.rs`, test qui applique une action et vérifie le nouvel état
6262 (gauges, turn, status).
63636464---
65656666-## Plus tard (hors scope de cette itération)
6666+# Phase 2 — Boucle d'actions HTTP (cartes en main)
67676868-- Endpoint `POST /games/{id}/actions` qui charge l'état, appelle `apply_action`,
6969- resauvegarde le JSONB.
7070-- Catalogue d'actions côté backend (ou côté frontend?) — à décider.
7171-- Système d'événements (3 choix par tour, tirés d'un pool).
7272-- Résultats de matchs et leur influence sur les jauges.
6868+Objectif: jouable de bout en bout depuis un client HTTP. Le joueur démarre avec
6969+3 cartes en main, pioche 1 au début de chaque tour, joue 1 parmi 4.
7070+7171+## 7. Catalogue d'actions
7272+7373+- [x] `src/game/catalog.rs` créé par Claude (10 actions thématiques).
7474+- [x] Déclarer `pub mod catalog;` dans `mod.rs` et re-exporter `default_catalog`.
7575+- [x] Vérifier que ça compile (`cargo check`) — révèlera probablement les
7676+ éléments à corriger ci-dessous.
7777+7878+## 8. Finir la "publication" d'`Action`
7979+8080+`catalog.rs` construit des `Action { id, label, effect }` directement → les
8181+champs doivent être `pub` (idem pour `GaugeDelta`). Sinon erreur "field is
8282+private".
8383+8484+- [x] `pub` sur les champs d'`Action`.
8585+- [x] Vérifier que `GaugeDelta` a aussi tous ses champs en `pub`.
8686+8787+## 9. Sérialisation d'`Action` et `GaugeDelta`
8888+8989+- [x] Dériver `Serialize, Deserialize, Clone, Debug, PartialEq` sur les deux.
9090+- [x] Ajouter `#[serde(deny_unknown_fields)]` sur les deux. (Ces types
9191+ ne sont jamais persistés en JSONB — uniquement reçus en requête,
9292+ donc on peut être strict.)
9393+9494+## 10. Mécanique de main et pioche dans `GameState`
9595+9696+Le cœur de la phase 2. Décision d'architecture à prendre:
9797+9898+**Question A — quoi stocker dans la main?**
9999+- (a) `hand: Vec<String>` (ids d'actions, on relie au catalogue à la lecture).
100100+- (b) `hand: Vec<Action>` (actions complètes dupliquées dans le state).
101101+102102+→ Reco: **(a)**. Plus léger, plus cohérent (le catalogue reste source unique
103103+de vérité). Coût: chaque lecture du JSONB doit "hydrater" les ids depuis le
104104+catalogue avant de répondre — pas grave.
105105+106106+**Question B — gérer un discard pile ou pas?**
107107+- (a) `deck: Vec<String>` + `discard: Vec<String>`, on reshuffle quand le
108108+ deck est vide.
109109+- (b) Juste `deck: Vec<String>`, les cartes jouées sont consommées; partie
110110+ perdue par épuisement du deck (ou règle alternative).
111111+112112+→ Reco: **(b)** pour démarrer. Tu pourras ajouter un discard plus tard quand
113113+ça aura un sens gameplay (ex: certaines cartes "se recyclent", d'autres pas).
114114+115115+À implémenter:
116116+- [x] Étendre `GameState` avec `hand: Vec<String>` et `deck: Vec<String>`.
117117+- [x] Constante (ou fonction) `HAND_SIZE: usize = 3`.
118118+- [x] `GameState::new(catalog_ids: &[String])` — prend la liste des ids
119119+ disponibles, les mélange dans le deck, pioche `HAND_SIZE` dans la main.
120120+ Note: `GameState::new` doit rester un constructeur **pur** (pas de
121121+ dépendance HTTP/DB). Lui passer la liste d'ids est plus propre que
122122+ lui passer la `HashMap` du catalogue.
123123+- [x] Ajouter la dépendance `rand` à `Cargo.toml` (`rand = "0.8"` ou récent)
124124+ pour `SliceRandom::shuffle`.
125125+126126+## 11. Étendre `apply_action`
127127+128128+L'API change. Nouvelle signature à débattre:
129129+130130+```rust
131131+pub fn apply_action(
132132+ state: &GameState,
133133+ action_id: &str,
134134+ catalog: &HashMap<String, Action>,
135135+) -> Result<GameState, GameError>
136136+```
137137+138138+Logique:
139139+1. Vérifier `status == Running` (existant).
140140+2. Vérifier que `action_id` est dans `state.hand` → sinon
141141+ `Err(GameError::CardNotInHand)`.
142142+3. Récupérer l'`Action` depuis le catalogue → sinon `UnknownAction`.
143143+4. Retirer la carte jouée de la main.
144144+5. Piocher la prochaine carte du deck (si vide → décision: continuer sans
145145+ pioche? `Err(GameError::DeckEmpty)`? À toi de trancher pour la sensation
146146+ de jeu).
147147+6. Appliquer l'effet, incrémenter le tour, recalculer le status.
148148+149149+- [x] Étendre `GameError` avec les nouvelles variantes (`CardNotInHand`,
150150+ `UnknownAction`, et éventuellement `DeckEmpty`).
151151+- [x] Mettre à jour les tests unitaires existants (signature changée).
152152+153153+## 12. `AppState` partage le catalogue
154154+155155+Actuellement `AppState` n'a que `db`. Le handler HTTP aura besoin du
156156+catalogue. Comme `AppState` est cloné à chaque requête, il faut éviter de
157157+cloner la `HashMap` à chaque fois.
158158+159159+- [ ] Ajouter `catalog: Arc<HashMap<String, Action>>` à `AppState`.
160160+- [ ] Dans `main`, construire le catalogue une fois et l'envelopper dans
161161+ `Arc::new(...)` avant de le passer à `AppState`.
162162+163163+## 13. Étendre `AppError`
164164+165165+Pour mapper `GameError` vers du HTTP:
166166+167167+- [ ] Ajouter au moins `BadRequest(String)` (→ 400) et `Conflict(String)`
168168+ (→ 409, pour les erreurs de règle comme `GameOver`/`CardNotInHand`).
169169+- [ ] Implémenter `From<GameError> for AppError` — choix: lesquelles
170170+ mappent vers 400 vs 409 vs 422? (`GameOver` = 409 conflict, le client
171171+ ne peut plus jouer; `UnknownAction` = 400 bad input; etc.)
172172+173173+## 14. Handler `POST /games/{id}/actions`
174174+175175+- [ ] Définir un type `PlayActionRequest { action_id: String }` avec
176176+ `Deserialize` et `deny_unknown_fields`.
177177+- [ ] Handler async:
178178+ 1. Charger la ligne `games` par id (`fetch_optional` → 404 si absent).
179179+ 2. Désérialiser le `state` JSONB en `GameState`.
180180+ 3. Appeler `apply_action(&game_state, &body.action_id, &state.catalog)`.
181181+ 4. Sérialiser le nouvel état.
182182+ 5. `UPDATE games SET state = $1, updated_at = NOW() WHERE id = $2`.
183183+ 6. Retourner le nouvel état en JSON.
184184+- [ ] Enregistrer la route: `.route("/games/{id}/actions", post(...))`.
185185+186186+Pas de transaction pour l'instant (jeu solo, on accepte le risque de race).
187187+À durcir plus tard avec `pool.begin()` + `SELECT ... FOR UPDATE`.
188188+189189+## 15. Mettre à jour `create_game`
190190+191191+- [ ] `GameState::new(...)` prend maintenant la liste d'ids du catalogue.
192192+ Récupérer `state.catalog.keys()` et les passer.
193193+- [ ] La réponse renvoie le nouvel état (avec `hand` peuplée) — le frontend
194194+ verra ses 3 cartes initiales.
195195+196196+## 16. Test bout en bout (manuel)
197197+198198+- [ ] `curl -X POST localhost:3000/games` → noter l'`id` et regarder la `hand`.
199199+- [ ] `curl -X POST localhost:3000/games/{id}/actions -d '{"action_id":"..."}'`
200200+ avec un id de la main → vérifier que le nouvel état revient avec une
201201+ main rafraîchie et un tour incrémenté.
202202+- [ ] Tenter une action_id qui n'est pas dans la main → 409.
203203+- [ ] Tenter une action_id inexistante → 400.
204204+205205+---
206206+207207+## Plus tard (toujours hors scope)
208208+209209+- Système d'événements (3 choix par tour, tirés d'un pool d'événements).
210210+- Résultats de matchs (équipes pro/jeunes/féminines/solidaires) et leur
211211+ influence sur les jauges.
212212+- Persistence du nombre de tours survécus comme score.
213213+- Concurrence: `SELECT ... FOR UPDATE` dans une transaction.
214214+- Système de discard / recyclage de cartes.