diff --git a/data/cards/hero_sample.json b/data/cards/hero_sample.json new file mode 100644 index 0000000..b321339 --- /dev/null +++ b/data/cards/hero_sample.json @@ -0,0 +1,7 @@ +[ + { "id": "card_hero_001", "name": "El Centrodelantero", "overall": 87, "position": "ST", "nation": "Spain", "league": "La Liga", "club": "Atletico Madrid", "pace": 84, "shooting": 88, "passing": 74, "dribbling": 83, "defending": 38, "physical": 80, "rarity": "hero", "image_path": null }, + { "id": "card_hero_002", "name": "The Playmaker", "overall": 86, "position": "CAM", "nation": "Netherlands", "league": "Eredivisie", "club": "Ajax", "pace": 78, "shooting": 79, "passing": 90, "dribbling": 88, "defending": 50, "physical": 65, "rarity": "hero", "image_path": null }, + { "id": "card_hero_003", "name": "Il Muro", "overall": 88, "position": "CB", "nation": "Italy", "league": "Serie A", "club": "Juventus", "pace": 80, "shooting": 42, "passing": 72, "dribbling": 70, "defending": 91, "physical": 90, "rarity": "hero", "image_path": null }, + { "id": "card_hero_004", "name": "The Enforcer", "overall": 85, "position": "CDM", "nation": "Nigeria", "league": "Premier League", "club": "Chelsea", "pace": 76, "shooting": 60, "passing": 80, "dribbling": 75, "defending": 89, "physical": 88, "rarity": "hero", "image_path": null }, + { "id": "card_hero_005", "name": "El Expreso", "overall": 87, "position": "LB", "nation": "Brazil", "league": "Brasileirao", "club": "Santos FC", "pace": 90, "shooting": 60, "passing": 82, "dribbling": 85, "defending": 88, "physical": 78, "rarity": "hero", "image_path": null } +] diff --git a/data/cards/icon_sample.json b/data/cards/icon_sample.json new file mode 100644 index 0000000..98830ef --- /dev/null +++ b/data/cards/icon_sample.json @@ -0,0 +1,7 @@ +[ + { "id": "card_icon_001", "name": "The Golden Boot", "overall": 95, "position": "ST", "nation": "Brazil", "league": "Icons", "club": "Icons", "pace": 92, "shooting": 96, "passing": 82, "dribbling": 93, "defending": 40, "physical": 84, "rarity": "icon", "image_path": null }, + { "id": "card_icon_002", "name": "The Maestro (Permanent)", "overall": 94, "position": "CAM", "nation": "Argentina", "league": "Icons", "club": "Icons", "pace": 80, "shooting": 88, "passing": 96, "dribbling": 95, "defending": 55, "physical": 68, "rarity": "icon", "image_path": null }, + { "id": "card_icon_003", "name": "The Wall", "overall": 94, "position": "GK", "nation": "Germany", "league": "Icons", "club": "Icons", "pace": 56, "shooting": 18, "passing": 76, "dribbling": 52, "defending": 96, "physical": 88, "rarity": "icon", "image_path": null }, + { "id": "card_icon_004", "name": "The General", "overall": 93, "position": "CB", "nation": "France", "league": "Icons", "club": "Icons", "pace": 84, "shooting": 48, "passing": 80, "dribbling": 78, "defending": 95, "physical": 92, "rarity": "icon", "image_path": null }, + { "id": "card_icon_005", "name": "El Capitán", "overall": 95, "position": "CM", "nation": "Spain", "league": "Icons", "club": "Icons", "pace": 82, "shooting": 86, "passing": 95, "dribbling": 90, "defending": 80, "physical": 80, "rarity": "icon", "image_path": null } +] diff --git a/data/cards/totw_sample.json b/data/cards/totw_sample.json new file mode 100644 index 0000000..27ad918 --- /dev/null +++ b/data/cards/totw_sample.json @@ -0,0 +1,7 @@ +[ + { "id": "card_totw_001", "name": "Gabriel Monteiro TOTW", "overall": 91, "position": "ST", "nation": "Brazil", "league": "Brasileirao", "club": "Flamengo", "pace": 94, "shooting": 92, "passing": 80, "dribbling": 89, "defending": 44, "physical": 84, "rarity": "totw", "image_path": null }, + { "id": "card_totw_002", "name": "Antoine Mercier TOTW", "overall": 89, "position": "CM", "nation": "France", "league": "Ligue 1", "club": "Paris Saint-Germain", "pace": 83, "shooting": 81, "passing": 92, "dribbling": 87, "defending": 76, "physical": 78, "rarity": "totw", "image_path": null }, + { "id": "card_totw_003", "name": "Sven Bauer TOTW", "overall": 90, "position": "CB", "nation": "Germany", "league": "Bundesliga", "club": "Bayer Leverkusen", "pace": 84, "shooting": 47, "passing": 78, "dribbling": 74, "defending": 92, "physical": 91, "rarity": "totw", "image_path": null }, + { "id": "card_totw_004", "name": "Nicolás Paredes TOTW", "overall": 92, "position": "LW", "nation": "Argentina", "league": "Primera Division", "club": "Boca Juniors", "pace": 95, "shooting": 88, "passing": 84, "dribbling": 93, "defending": 47, "physical": 74, "rarity": "totw", "image_path": null }, + { "id": "card_totw_005", "name": "Jin-Ho Lee TOTW", "overall": 88, "position": "RW", "nation": "South Korea", "league": "Bundesliga", "club": "RB Leipzig", "pace": 93, "shooting": 84, "passing": 80, "dribbling": 91, "defending": 45, "physical": 68, "rarity": "totw", "image_path": null } +] diff --git a/data/objectives/milestone.json b/data/objectives/milestone.json new file mode 100644 index 0000000..45c93bd --- /dev/null +++ b/data/objectives/milestone.json @@ -0,0 +1,68 @@ +[ + { + "id": "milestone_10_wins", + "title": "First Champion", + "description": "Win 10 matches total.", + "objective_type": "milestone", + "metric": "matcheswon", + "target": 10, + "reward_coins": 2000, + "reward_pack_id": null, + "reward_xp": 500 + }, + { + "id": "milestone_50_wins", + "title": "Seasoned Winner", + "description": "Win 50 matches total.", + "objective_type": "milestone", + "metric": "matcheswon", + "target": 50, + "reward_coins": 10000, + "reward_pack_id": "gold_pack", + "reward_xp": 2500 + }, + { + "id": "milestone_100_goals", + "title": "Century Scorer", + "description": "Score 100 goals total.", + "objective_type": "milestone", + "metric": "goalsscored", + "target": 100, + "reward_coins": 5000, + "reward_pack_id": null, + "reward_xp": 1000 + }, + { + "id": "milestone_500_goals", + "title": "Goal Machine Legend", + "description": "Score 500 goals total.", + "objective_type": "milestone", + "metric": "goalsscored", + "target": 500, + "reward_coins": 25000, + "reward_pack_id": "rare_gold_pack", + "reward_xp": 5000 + }, + { + "id": "milestone_10_sbcs", + "title": "SBC Aficionado", + "description": "Complete 10 Squad Building Challenges.", + "objective_type": "milestone", + "metric": "sbcscompleted", + "target": 10, + "reward_coins": 3000, + "reward_pack_id": "silver_pack", + "reward_xp": 750 + }, + { + "id": "milestone_25_packs", + "title": "Pack Addict", + "description": "Open 25 packs.", + "objective_type": "milestone", + "metric": "packsopened", + "target": 25, + "reward_coins": 5000, + "reward_pack_id": null, + "reward_xp": 1500 + } +] diff --git a/data/packs/special_packs.json b/data/packs/special_packs.json new file mode 100644 index 0000000..76c8388 --- /dev/null +++ b/data/packs/special_packs.json @@ -0,0 +1,44 @@ +[ + { + "id": "totw_pack", + "name": "TOTW Pack", + "description": "Contains 5 Team of the Week cards.", + "cost_coins": 30000, + "slots": [ + { + "count": 5, + "min_overall": 88, + "rarity_filter": ["totw"], + "guaranteed_rare": true + } + ] + }, + { + "id": "icon_pack", + "name": "Icon Pack", + "description": "Contains 3 legendary Icon cards.", + "cost_coins": 50000, + "slots": [ + { + "count": 3, + "min_overall": 93, + "rarity_filter": ["icon"], + "guaranteed_rare": true + } + ] + }, + { + "id": "hero_pack", + "name": "Hero Pack", + "description": "Contains 5 Hero cards.", + "cost_coins": 20000, + "slots": [ + { + "count": 5, + "min_overall": 85, + "rarity_filter": ["hero"], + "guaranteed_rare": true + } + ] + } +] diff --git a/migrations/0003_position_goals.sql b/migrations/0003_position_goals.sql new file mode 100644 index 0000000..2103fc4 --- /dev/null +++ b/migrations/0003_position_goals.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS position_goals ( + profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + position TEXT NOT NULL, + goals INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (profile_id, position) +); diff --git a/src/app.rs b/src/app.rs index 90bc321..a74d914 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,7 @@ use crate::{ }; use anyhow::Result; use axum::{ - routing::{get, post}, + routing::{delete, get, post, put}, Router, }; use std::sync::Arc; @@ -103,6 +103,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { let router = Router::new() .route("/health", get(routes::health::get_health)) + .route("/formations", get(routes::health::get_formations)) .route("/auth/local", post(routes::auth::post_auth_local)) .route("/profile", get(routes::profile::get_profile)) .route("/club", get(routes::club::get_club)) @@ -114,6 +115,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) .route("/squad", get(routes::squad::get_squad)) .route("/squad", post(routes::squad::post_squad)) + .route("/squads", get(routes::squad::get_squads)) + .route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) + .route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/objectives", get(routes::objectives::get_objectives)) .route( "/objectives/claim", @@ -123,8 +127,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/result", post(routes::matches::post_match_result)) .route("/sbc", get(routes::sbc::get_sbcs)) - .route("/sbc/:sbc_id", get(routes::sbc::get_sbc)) .route("/sbc/submit", post(routes::sbc::post_sbc_submit)) + .route("/sbc/:sbc_id", get(routes::sbc::get_sbc)) .route("/market", get(routes::market::get_market)) .route("/market/buy", post(routes::market::post_market_buy)) .route("/market/sell", post(routes::market::post_market_sell)) @@ -134,6 +138,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { "/statistics/history", get(routes::statistics::get_statistics_history), ) + .route("/settings", get(routes::settings::get_settings)) + .route("/settings", put(routes::settings::put_settings)) + .route("/draft/squad", get(routes::draft::get_draft_squad)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/src/models/match_result.rs b/src/models/match_result.rs index b8ce88a..f053283 100644 --- a/src/models/match_result.rs +++ b/src/models/match_result.rs @@ -17,6 +17,7 @@ pub struct SubmitMatchRequest { pub goals_for: i64, pub goals_against: i64, pub mode: String, + pub goal_positions: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/src/models/squad.rs b/src/models/squad.rs index 6835ee3..fa80c97 100644 --- a/src/models/squad.rs +++ b/src/models/squad.rs @@ -41,6 +41,7 @@ pub struct SquadPlayer { #[derive(Debug, Deserialize)] pub struct SaveSquadRequest { + pub squad_id: Option, pub name: Option, pub formation: Option, pub players: Vec, diff --git a/src/routes/draft.rs b/src/routes/draft.rs new file mode 100644 index 0000000..9bb62d2 --- /dev/null +++ b/src/routes/draft.rs @@ -0,0 +1,32 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{app::AppState, error::AppResult, services::match_service}; + +#[derive(Deserialize)] +pub struct DraftQuery { + pub difficulty: Option, +} + +/// Returns a randomly generated draft squad from the card pool. +/// Difficulty controls minimum card overall. The front-end is responsible +/// for presenting per-position pick choices; this endpoint provides the pool. +pub async fn get_draft_squad( + State(state): State, + Query(query): Query, +) -> AppResult> { + let difficulty = query.difficulty.as_deref().unwrap_or("beginner"); + let generated = match_service::generate_opponent(&state.card_db, difficulty); + + Ok(Json(json!({ + "mode": "draft", + "difficulty": difficulty, + "squad_rating": generated["squad_rating"], + "formation": generated["formation"], + "cards": generated["cards"], + }))) +} diff --git a/src/routes/health.rs b/src/routes/health.rs index 7e0ab10..0a5d8fb 100644 --- a/src/routes/health.rs +++ b/src/routes/health.rs @@ -11,3 +11,13 @@ pub async fn get_health() -> (StatusCode, Json) { })), ) } + +pub async fn get_formations() -> Json { + Json(json!({ + "formations": [ + "4-4-2", "4-3-3", "4-2-3-1", "4-5-1", + "3-5-2", "3-4-3", "5-3-2", "5-4-1", + "4-1-2-1-2", "4-3-2-1", "4-4-1-1", "4-2-2-2" + ] + })) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2c2a3a9..3adb35b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod cards; pub mod club; +pub mod draft; pub mod health; pub mod market; pub mod matches; @@ -8,5 +9,6 @@ pub mod objectives; pub mod packs; pub mod profile; pub mod sbc; +pub mod settings; pub mod squad; pub mod statistics; diff --git a/src/routes/settings.rs b/src/routes/settings.rs new file mode 100644 index 0000000..4158505 --- /dev/null +++ b/src/routes/settings.rs @@ -0,0 +1,30 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{app::AppState, error::AppResult, services::settings as settings_svc}; + +fn defaults(settings: &std::collections::HashMap) -> Value { + json!({ + "difficulty": settings.get("difficulty").cloned().unwrap_or_else(|| "beginner".into()), + "preferred_formation": settings.get("preferred_formation").cloned().unwrap_or_else(|| "4-4-2".into()), + }) +} + +pub async fn get_settings(State(state): State) -> AppResult> { + let settings = settings_svc::get_all(&state.pool).await?; + Ok(Json(defaults(&settings))) +} + +pub async fn put_settings( + State(state): State, + Json(req): Json, +) -> AppResult> { + if let Some(difficulty) = req.get("difficulty").and_then(|v| v.as_str()) { + settings_svc::upsert(&state.pool, "difficulty", difficulty).await?; + } + if let Some(formation) = req.get("preferred_formation").and_then(|v| v.as_str()) { + settings_svc::upsert(&state.pool, "preferred_formation", formation).await?; + } + let settings = settings_svc::get_all(&state.pool).await?; + Ok(Json(defaults(&settings))) +} diff --git a/src/routes/squad.rs b/src/routes/squad.rs index b3a414b..c206e81 100644 --- a/src/routes/squad.rs +++ b/src/routes/squad.rs @@ -1,4 +1,7 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::{Path, State}, + Json, +}; use serde_json::{json, Value}; use crate::{ @@ -15,28 +18,27 @@ pub async fn get_squad(State(state): State) -> AppResult> 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?; - 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(); + Ok(Json(squad_response(&squad, &players, chemistry))) +} - Ok(Json(json!({ - "squad": { - "id": squad.id, - "name": squad.name, - "formation": squad.formation, - }, - "players": enriched, - "chemistry": chemistry, - }))) +pub async fn get_squads(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).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, + Path(squad_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).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( @@ -53,3 +55,42 @@ pub async fn post_squad( let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; Ok(Json(json!({ "squad": squad }))) } + +pub async fn delete_squad( + State(state): State, + Path(squad_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).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, + }) +} diff --git a/src/routes/statistics.rs b/src/routes/statistics.rs index 6212aa2..c839919 100644 --- a/src/routes/statistics.rs +++ b/src/routes/statistics.rs @@ -8,14 +8,25 @@ use serde_json::{json, Value}; use crate::{ app::AppState, error::AppResult, - models::{match_result::Match, statistics::Statistics}, + models::match_result::Match, services::{profile as profile_svc, statistics as stats_svc}, }; -pub async fn get_statistics(State(state): State) -> AppResult> { +pub async fn get_statistics(State(state): State) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; - Ok(Json(stats)) + let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?; + + let pos_map: serde_json::Map = pos_goals + .into_iter() + .map(|(pos, goals)| (pos, Value::Number(goals.into()))) + .collect(); + + let mut val = serde_json::to_value(&stats)?; + if let Some(obj) = val.as_object_mut() { + obj.insert("position_goals".into(), Value::Object(pos_map)); + } + Ok(Json(val)) } #[derive(Deserialize)] diff --git a/src/services/match_service.rs b/src/services/match_service.rs index 0c0ea72..d9f809f 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -148,6 +148,10 @@ pub async fn process_match( ) .await?; + if let Some(positions) = &req.goal_positions { + statistics::record_position_goals(pool, profile_id, positions).await?; + } + let mut objectives_updated = Vec::new(); let mut completed = diff --git a/src/services/mod.rs b/src/services/mod.rs index a0013d1..af03235 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,5 +6,6 @@ pub mod objective; pub mod pack; pub mod profile; pub mod sbc; +pub mod settings; pub mod squad; pub mod statistics; diff --git a/src/services/settings.rs b/src/services/settings.rs new file mode 100644 index 0000000..4693e32 --- /dev/null +++ b/src/services/settings.rs @@ -0,0 +1,23 @@ +use crate::{db::Pool, error::AppResult}; +use std::collections::HashMap; + +pub async fn get_all(pool: &Pool) -> AppResult> { + let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM settings") + .fetch_all(pool) + .await?; + Ok(rows.into_iter().collect()) +} + +pub async fn upsert(pool: &Pool, key: &str, value: &str) -> AppResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(key) + .bind(value) + .bind(&now) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src/services/squad.rs b/src/services/squad.rs index f7cf1d5..904aaae 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -11,24 +11,55 @@ use uuid::Uuid; pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec)> { let squad = sqlx::query_as::<_, Squad>( - "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1" + "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1" ) .bind(club_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?; - let players = sqlx::query_as::<_, SquadPlayer>( - "SELECT id, squad_id, owned_card_id, position_index, is_captain, is_on_bench FROM squad_players WHERE squad_id = ?" - ) - .bind(&squad.id) - .fetch_all(pool) - .await?; - + let players = get_players(pool, &squad.id).await?; Ok((squad, players)) } -/// Validate that the squad has exactly 11 starters with exactly one GK. +pub async fn get_squad_by_id( + pool: &Pool, + club_id: &str, + squad_id: &str, +) -> AppResult<(Squad, Vec)> { + let squad = sqlx::query_as::<_, Squad>( + "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ? AND club_id = ?", + ) + .bind(squad_id) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("squad '{squad_id}' not found")))?; + + let players = get_players(pool, &squad.id).await?; + Ok((squad, players)) +} + +pub async fn list_squads(pool: &Pool, club_id: &str) -> AppResult> { + let squads = sqlx::query_as::<_, Squad>( + "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY updated_at DESC", + ) + .bind(club_id) + .fetch_all(pool) + .await?; + Ok(squads) +} + +async fn get_players(pool: &Pool, squad_id: &str) -> AppResult> { + let players = sqlx::query_as::<_, SquadPlayer>( + "SELECT id, squad_id, owned_card_id, position_index, is_captain, is_on_bench FROM squad_players WHERE squad_id = ?", + ) + .bind(squad_id) + .fetch_all(pool) + .await?; + Ok(players) +} + pub async fn validate_formation( pool: &Pool, card_db: &CardDb, @@ -74,7 +105,6 @@ pub async fn validate_formation( Ok(()) } -/// Calculate chemistry for the starting XI. Returns total and per-player scores. pub async fn calculate_chemistry( pool: &Pool, card_db: &CardDb, @@ -98,7 +128,6 @@ pub async fn calculate_chemistry( } } - // Per-player chemistry: club links (max 4) + league links (max 3) + nation links (max 3), capped at 10 let player_chems: Vec = cards .iter() .enumerate() @@ -137,27 +166,34 @@ pub async fn calculate_chemistry( pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult { let now = chrono::Utc::now().to_rfc3339(); - let existing = sqlx::query_scalar::<_, Option>( - "SELECT id FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1", - ) - .bind(club_id) - .fetch_one(pool) - .await?; + let squad_id = if let Some(ref id) = req.squad_id { + // Update existing squad — verify ownership + let verified = + sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?") + .bind(id) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?; + + sqlx::query( + "UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?", + ) + .bind(req.name.as_deref()) + .bind(req.formation.as_deref()) + .bind(&now) + .bind(&verified) + .execute(pool) + .await?; - let squad_id = if let Some(id) = existing { - sqlx::query("UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?") - .bind(req.name.as_deref()) - .bind(req.formation.as_deref()) - .bind(&now) - .bind(&id) - .execute(pool) - .await?; sqlx::query("DELETE FROM squad_players WHERE squad_id = ?") - .bind(&id) + .bind(&verified) .execute(pool) .await?; - id + + verified } else { + // Create a new squad let squad = Squad::new( club_id, req.name.as_deref().unwrap_or("My Squad"), @@ -201,3 +237,16 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A Ok(squad) } + +pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<()> { + let deleted = sqlx::query("DELETE FROM squads WHERE id = ? AND club_id = ?") + .bind(squad_id) + .bind(club_id) + .execute(pool) + .await?; + + if deleted.rows_affected() == 0 { + return Err(AppError::NotFound(format!("squad '{squad_id}' not found"))); + } + Ok(()) +} diff --git a/src/services/statistics.rs b/src/services/statistics.rs index 64c8741..d213761 100644 --- a/src/services/statistics.rs +++ b/src/services/statistics.rs @@ -102,3 +102,31 @@ pub async fn increment_sbcs_completed(pool: &Pool, profile_id: &str) -> AppResul .await?; Ok(()) } + +pub async fn record_position_goals( + pool: &Pool, + profile_id: &str, + positions: &[String], +) -> AppResult<()> { + for position in positions { + sqlx::query( + "INSERT INTO position_goals (profile_id, position, goals) VALUES (?, ?, 1) \ + ON CONFLICT(profile_id, position) DO UPDATE SET goals = goals + 1", + ) + .bind(profile_id) + .bind(position) + .execute(pool) + .await?; + } + Ok(()) +} + +pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult> { + let rows: Vec<(String, i64)> = sqlx::query_as( + "SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC", + ) + .bind(profile_id) + .fetch_all(pool) + .await?; + Ok(rows) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 33657e8..de3ba6c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -5,7 +5,6 @@ use axum::{ use serde_json::Value; use tower::ServiceExt; -// Bring in the crate modules via the library path // We test by spinning up the full app against an in-memory SQLite database. async fn build_test_app() -> axum::Router { @@ -19,43 +18,16 @@ async fn build_test_app() -> axum::Router { .await .expect("migrations"); - // Seed with test card + pack data let data_dir = "data"; openfut_core::build_app(pool, data_dir) .await .expect("app build") } -#[tokio::test] -async fn test_health_endpoint() { - let app = build_test_app().await; - - let resp = app - .oneshot( - Request::builder() - .uri("/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::OK); - - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let json: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["status"], "ok"); -} - -#[tokio::test] -async fn test_auth_local_creates_profile_and_club() { - let app = build_test_app().await; - - let payload = serde_json::json!({ "username": "TestPlayer" }); - +async fn auth(app: &axum::Router, username: &str) -> Value { + let payload = serde_json::json!({ "username": username }); let resp = app + .clone() .oneshot( Request::builder() .method("POST") @@ -66,13 +38,69 @@ async fn test_auth_local_creates_profile_and_club() { ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); - let json: Value = serde_json::from_slice(&body).unwrap(); + serde_json::from_slice(&body).unwrap() +} + +async fn json_get(app: &axum::Router, uri: &str) -> (StatusCode, Value) { + let resp = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) { + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +// ── Existing tests ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_health_endpoint() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/health").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn test_formations_endpoint() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/formations").await; + assert_eq!(status, StatusCode::OK); + assert!(json["formations"].is_array()); + assert!(json["formations"].as_array().unwrap().len() >= 8); +} + +#[tokio::test] +async fn test_auth_local_creates_profile_and_club() { + let app = build_test_app().await; + let json = auth(&app, "TestPlayer").await; assert_eq!(json["profile"]["username"], "TestPlayer"); assert_eq!(json["club"]["name"], "OpenFUT FC"); assert_eq!(json["club"]["coins"], 5000); @@ -81,40 +109,15 @@ async fn test_auth_local_creates_profile_and_club() { #[tokio::test] async fn test_profile_not_found_before_auth() { let app = build_test_app().await; - - let resp = app - .oneshot( - Request::builder() - .uri("/profile") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let (status, _) = json_get(&app, "/profile").await; + assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_match_result_awards_coins() { let app = build_test_app().await; + auth(&app, "MatchPlayer").await; - // First create a profile - let auth_resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/auth/local") - .header("content-type", "application/json") - .body(Body::from(r#"{"username":"MatchPlayer"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(auth_resp.status(), StatusCode::OK); - - // Submit a match win let payload = serde_json::json!({ "squad_id": "dummy-squad", "opponent_name": "AI Club", @@ -122,24 +125,8 @@ async fn test_match_result_awards_coins() { "goals_against": 1, "mode": "squad_battles" }); - - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/matches/result") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let json: Value = serde_json::from_slice(&body).unwrap(); + let (status, json) = json_post(&app, "/matches/result", payload).await; + assert_eq!(status, StatusCode::OK); assert_eq!(json["match_record"]["outcome"], "win"); assert!(json["coins_awarded"].as_i64().unwrap() > 0); } @@ -147,16 +134,251 @@ async fn test_match_result_awards_coins() { #[tokio::test] async fn test_sbc_list_returns_definitions() { let app = build_test_app().await; + let (status, json) = json_get(&app, "/sbc").await; + assert_eq!(status, StatusCode::OK); + assert!(json["sbcs"].is_array()); +} + +// ── Phase 3 new tests ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_match_with_goal_positions_tracks_stats() { + let app = build_test_app().await; + auth(&app, "GoalTracker").await; + + let payload = serde_json::json!({ + "squad_id": "dummy-squad", + "opponent_name": "AI Club", + "goals_for": 3, + "goals_against": 0, + "mode": "squad_battles", + "goal_positions": ["ST", "ST", "CAM"] + }); + let (status, _) = json_post(&app, "/matches/result", payload).await; + assert_eq!(status, StatusCode::OK); + + let (status, stats) = json_get(&app, "/statistics").await; + assert_eq!(status, StatusCode::OK); + assert!(stats["position_goals"].is_object()); + assert_eq!(stats["position_goals"]["ST"], 2); + assert_eq!(stats["position_goals"]["CAM"], 1); +} + +#[tokio::test] +async fn test_buy_and_open_pack() { + let app = build_test_app().await; + auth(&app, "PackOpener").await; + + // Starter gold_pack is granted on registration + let (status, packs_json) = json_get(&app, "/packs").await; + assert_eq!(status, StatusCode::OK); + let packs = packs_json["packs"].as_array().unwrap(); + assert!(!packs.is_empty(), "starter pack should be in inventory"); + + let pack_id = packs[0]["pack_id"].as_str().unwrap(); + let (status, open_json) = json_post( + &app, + &format!("/packs/open/{pack_id}"), + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK, "open failed: {open_json}"); + assert!(!open_json["cards"].as_array().unwrap().is_empty()); + + let (status, coll) = json_get(&app, "/collection").await; + assert_eq!(status, StatusCode::OK); + assert!(!coll["collection"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn test_buy_bronze_pack_deducts_coins() { + let app = build_test_app().await; + auth(&app, "CoinSpender").await; + + let (status, buy_json) = json_post( + &app, + "/packs/buy", + serde_json::json!({ "pack_definition_id": "bronze_pack" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "buy failed: {buy_json}"); + + let (_, club_json) = json_get(&app, "/club").await; + assert_eq!(club_json["coins"], 5000 - 400); +} + +#[tokio::test] +async fn test_sbc_submit_with_bronze_cards() { + let app = build_test_app().await; + auth(&app, "SBCPlayer").await; + + // Buy and open a bronze pack + let (s, _) = json_post( + &app, + "/packs/buy", + serde_json::json!({ "pack_definition_id": "bronze_pack" }), + ) + .await; + assert_eq!(s, StatusCode::OK); + + let (_, packs_json) = json_get(&app, "/packs").await; + let bronze_pack = packs_json["packs"] + .as_array() + .unwrap() + .iter() + .find(|p| p["definition_id"] == "bronze_pack") + .expect("bronze pack in inventory"); + let pack_id = bronze_pack["pack_id"].as_str().unwrap(); + + let (s, open_json) = json_post( + &app, + &format!("/packs/open/{pack_id}"), + serde_json::json!({}), + ) + .await; + assert_eq!(s, StatusCode::OK, "{open_json}"); + + // Collect bronze card IDs (overall ≤ 64) + let (_, coll) = json_get(&app, "/collection").await; + let bronze_ids: Vec<&str> = coll["collection"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["card"]["overall"].as_i64().unwrap_or(99) <= 64) + .take(11) + .map(|c| c["owned_card_id"].as_str().unwrap()) + .collect(); + + if bronze_ids.len() < 11 { + // Card pool variance — not enough bronze to fill SBC; skip gracefully + return; + } + + let (s, result) = json_post( + &app, + "/sbc/submit", + serde_json::json!({ + "sbc_id": "sbc_bronze_upgrade", + "owned_card_ids": bronze_ids + }), + ) + .await; + assert_eq!(s, StatusCode::OK, "SBC submit: {result}"); + assert_eq!(result["passed"], true); + // bronze_upgrade rewards a silver pack (coins = 0) + assert!(result["reward"].is_object()); +} + +#[tokio::test] +async fn test_settings_read_write() { + let app = build_test_app().await; + auth(&app, "SettingsUser").await; + + let (status, defaults) = json_get(&app, "/settings").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(defaults["difficulty"], "beginner"); + assert_eq!(defaults["preferred_formation"], "4-4-2"); let resp = app - .oneshot(Request::builder().uri("/sbc").body(Body::empty()).unwrap()) + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/settings") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "difficulty": "world_class", + "preferred_formation": "4-3-3" + }) + .to_string(), + )) + .unwrap(), + ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); - let json: Value = serde_json::from_slice(&body).unwrap(); - assert!(json["sbcs"].is_array()); + let updated: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(updated["difficulty"], "world_class"); + assert_eq!(updated["preferred_formation"], "4-3-3"); + + let (s, persisted) = json_get(&app, "/settings").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(persisted["difficulty"], "world_class"); +} + +#[tokio::test] +async fn test_multiple_squads() { + let app = build_test_app().await; + auth(&app, "SquadManager").await; + + let (s1, j1) = json_post( + &app, + "/squad", + serde_json::json!({ "name": "Attack Squad", "formation": "4-3-3", "players": [] }), + ) + .await; + assert_eq!(s1, StatusCode::OK, "{j1}"); + let squad1_id = j1["squad"]["id"].as_str().unwrap().to_string(); + + let (s2, j2) = json_post( + &app, + "/squad", + serde_json::json!({ "name": "Defense Squad", "formation": "5-4-1", "players": [] }), + ) + .await; + assert_eq!(s2, StatusCode::OK, "{j2}"); + let squad2_id = j2["squad"]["id"].as_str().unwrap().to_string(); + + assert_ne!(squad1_id, squad2_id); + + let (status, squads_json) = json_get(&app, "/squads").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(squads_json["squads"].as_array().unwrap().len(), 2); + + let (status, sq1) = json_get(&app, &format!("/squads/{squad1_id}")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(sq1["squad"]["name"], "Attack Squad"); +} + +#[tokio::test] +async fn test_draft_squad_endpoint() { + let app = build_test_app().await; + auth(&app, "DraftPlayer").await; + + let (status, json) = json_get(&app, "/draft/squad?difficulty=beginner").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["mode"], "draft"); + assert_eq!(json["difficulty"], "beginner"); + assert!(json["cards"].is_array()); +} + +#[tokio::test] +async fn test_win_streak_tracking() { + let app = build_test_app().await; + auth(&app, "StreakPlayer").await; + + for _ in 0..3 { + let (s, _) = json_post( + &app, + "/matches/result", + serde_json::json!({ + "squad_id": "dummy", + "opponent_name": "Bot", + "goals_for": 2, + "goals_against": 0, + "mode": "squad_battles" + }), + ) + .await; + assert_eq!(s, StatusCode::OK); + } + + let (s, stats) = json_get(&app, "/statistics").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(stats["win_streak"], 3); + assert_eq!(stats["best_win_streak"], 3); }