use crate::extractors::GameId; use axum::{ extract::{Path, Query, State}, Json, }; use serde::Deserialize; use serde_json::{json, Value}; use crate::{ app::AppState, error::{AppError, AppResult}, models::game_ext::OpaqueExtensionWrite, models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement}, services::{ club as club_svc, profile as profile_svc, squad as squad_svc, squad::SquadExtState, squad_rules::{ClientReportedEvaluation, DefaultSquadRules}, }, }; pub async fn get_squad(State(state): State, game: GameId) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?; let chemistry = squad_svc::calculate_chemistry(&state.pool, &state.card_db, &players).await?; Ok(Json(squad_response(&squad, &players, chemistry))) } pub async fn get_squads(State(state): State, game: GameId) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let squads = squad_svc::list_squads(&state.pool, &club.id).await?; Ok(Json(json!({ "squads": squads }))) } pub async fn get_squad_by_id( State(state): State, game: GameId, Path(squad_id): Path, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?; let chemistry = squad_svc::calculate_chemistry(&state.pool, &state.card_db, &players).await?; Ok(Json(squad_response(&squad, &players, chemistry))) } pub async fn post_squad( State(state): State, game: GameId, Json(req): Json, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; if !req.players.is_empty() { squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?; } let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?; Ok(Json(json!({ "squad": squad }))) } pub async fn delete_squad( State(state): State, game: GameId, Path(squad_id): Path, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?; Ok(Json(json!({ "deleted": squad_id }))) } fn squad_response( squad: &crate::models::squad::Squad, players: &[crate::models::squad::SquadPlayer], chemistry: Value, ) -> Value { let enriched: Vec = players .iter() .map(|sp| { json!({ "squad_player_id": sp.id, "owned_card_id": sp.owned_card_id, "position_index": sp.position_index, "is_captain": sp.is_captain, "is_on_bench": sp.is_on_bench, }) }) .collect(); json!({ "squad": { "id": squad.id, "name": squad.name, "formation": squad.formation, }, "players": enriched, "chemistry": chemistry, }) } // ─────────── Game-extension-aware squad transport (host composition) ───────── // // These two routes expose the already-existing extension services // (`read_squad_with_ext` / `replace_squad_with_extension`) over HTTP so a game // host can read/write the canonical squad AND its opaque game extension in one // Core round-trip. They add no domain logic — Core still owns validation, // ownership, the atomic transaction, the server fingerprint, and staleness; it // never interprets the extension payload. #[derive(Deserialize)] pub struct ExtQuery { /// Opaque adapter namespace, e.g. `"fifa17.squad"`. pub namespace: String, } /// `GET /squad/ext?namespace=…` — the active squad, its players, and its opaque /// extension with an explicit Fresh/Stale/Missing verdict. Never projects a /// stale blob; the caller decides policy. pub async fn get_squad_ext( State(state): State, game: GameId, Query(q): Query, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let (squad, players, state_ext) = squad_svc::read_squad_with_ext(&state.pool, game.as_str(), &club.id, &q.namespace).await?; let extension = match state_ext { SquadExtState::Fresh(row) => json!({ "state": "fresh", "schema_version": row.schema_version, "payload": row.payload, "stored_fingerprint": row.canonical_fingerprint, }), SquadExtState::Stale { stored, current_fingerprint, } => json!({ "state": "stale", "schema_version": stored.schema_version, "payload": stored.payload, "stored_fingerprint": stored.canonical_fingerprint, "current_fingerprint": current_fingerprint, }), SquadExtState::Missing => json!({ "state": "missing" }), }; Ok(Json(json!({ "squad": squad, "players": players, "extension": extension, }))) } #[derive(Deserialize)] pub struct SlotReq { pub owned_card_id: String, pub slot: i64, #[serde(default)] pub is_captain: bool, #[serde(default)] pub is_on_bench: bool, } #[derive(Deserialize)] pub struct ReplaceReq { #[serde(default)] pub name: Option, #[serde(default)] pub formation: Option, pub slots: Vec, #[serde(default)] pub client_reported: ClientReportedEvaluation, pub extension: OpaqueExtensionWrite, } /// `PUT /squad/replace` — full-replacement of the active squad's canonical slots /// plus its opaque game extension, in ONE Core transaction. Resolves the active /// squad in place (creates one if none exists). Ownership, duplicate, and size /// validation happen inside the service before any write. pub async fn put_squad_replace( State(state): State, game: GameId, Json(req): Json, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; // Replace the club's active squad in place; if there is none yet, create it. let squad_id = match squad_svc::get_squad(&state.pool, &club.id).await { Ok((s, _)) => Some(s.id), Err(AppError::NotFound(_)) => None, Err(e) => return Err(e), }; let replacement = SquadReplacement { name: req.name, formation: req.formation, slots: req .slots .into_iter() .map(|s| SlotAssignment { owned_card_id: s.owned_card_id, slot: s.slot, is_captain: s.is_captain, is_on_bench: s.is_on_bench, }) .collect(), }; let out = squad_svc::replace_squad_with_extension( &state.pool, &state.card_db, &DefaultSquadRules, game.as_str(), &club.id, squad_id.as_deref(), &replacement, &req.client_reported, &req.extension, ) .await?; Ok(Json(json!({ "squad_id": out.squad.id, "canonical_fingerprint": out.canonical_fingerprint, "slots_written": out.slots_written, }))) }