3084a46dcc
Formatting-only follow-up to the squad-ext routes; no behavior change.
Pre-existing Core fmt drift elsewhere (e.g. app.rs route chain) predates
this branch (615c5fd is already not fmt-clean) and is intentionally left
untouched — not reformatting frozen Core beyond the squad-ext change.
238 lines
8.0 KiB
Rust
238 lines
8.0 KiB
Rust
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<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
|
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<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
|
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<AppState>,
|
|
game: GameId,
|
|
Path(squad_id): Path<String>,
|
|
) -> AppResult<Json<Value>> {
|
|
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<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<SaveSquadRequest>,
|
|
) -> AppResult<Json<Value>> {
|
|
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<AppState>,
|
|
game: GameId,
|
|
Path(squad_id): Path<String>,
|
|
) -> AppResult<Json<Value>> {
|
|
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<Value> = 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<AppState>,
|
|
game: GameId,
|
|
Query(q): Query<ExtQuery>,
|
|
) -> AppResult<Json<Value>> {
|
|
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<String>,
|
|
#[serde(default)]
|
|
pub formation: Option<String>,
|
|
pub slots: Vec<SlotReq>,
|
|
#[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<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<ReplaceReq>,
|
|
) -> AppResult<Json<Value>> {
|
|
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,
|
|
})))
|
|
}
|