diff --git a/migrations/0008_fut_champs.sql b/migrations/0008_fut_champs.sql new file mode 100644 index 0000000..d2449b3 --- /dev/null +++ b/migrations/0008_fut_champs.sql @@ -0,0 +1,21 @@ +-- Phase 11: FUT Champions / Weekend League sessions +CREATE TABLE IF NOT EXISTS fut_champs_sessions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + week_number INTEGER NOT NULL DEFAULT 1, + matches_played INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + draws INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + reward_tier TEXT, + reward_coins INTEGER, + reward_pack_id TEXT, + reward_claimed INTEGER NOT NULL DEFAULT 0, + started_at TEXT NOT NULL, + completed_at TEXT +); + +-- Division Rivals: track when each profile last claimed their weekly reward +ALTER TABLE seasons ADD COLUMN rivals_week_claimed INTEGER NOT NULL DEFAULT 0; +ALTER TABLE seasons ADD COLUMN rivals_total_points INTEGER NOT NULL DEFAULT 0; diff --git a/src/app.rs b/src/app.rs index 1f74143..7a3681c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -194,6 +194,21 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", put(routes::settings::put_settings)) .route("/division", get(routes::division::get_division)) .route("/notifications", get(routes::notifications::get_notifications)) + .route("/fut-champs", get(routes::fut_champs::get_fut_champs)) + .route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs)) + .route("/fut-champs/history", get(routes::fut_champs::get_champs_history)) + .route( + "/fut-champs/:session_id/result", + post(routes::fut_champs::post_champs_result), + ) + .route( + "/fut-champs/:session_id/claim", + post(routes::fut_champs::post_claim_champs_rewards), + ) + .route( + "/rivals/claim-weekly", + post(routes::fut_champs::post_claim_rivals_reward), + ) .route("/draft/squad", get(routes::draft::get_draft_squad)) .route("/draft/start", post(routes::draft::post_draft_start)) .route("/draft/sessions/:id", get(routes::draft::get_draft_session)) diff --git a/src/models/fut_champs.rs b/src/models/fut_champs.rs new file mode 100644 index 0000000..2994170 --- /dev/null +++ b/src/models/fut_champs.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; + +pub const MAX_MATCHES: i64 = 30; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct FutChampsSession { + pub id: String, + pub profile_id: String, + pub week_number: i64, + pub matches_played: i64, + pub wins: i64, + pub draws: i64, + pub losses: i64, + pub status: String, + pub reward_tier: Option, + pub reward_coins: Option, + pub reward_pack_id: Option, + pub reward_claimed: bool, + pub started_at: String, + pub completed_at: Option, +} + +impl FutChampsSession { + pub fn matches_remaining(&self) -> i64 { + (MAX_MATCHES - self.matches_played).max(0) + } + + pub fn is_complete(&self) -> bool { + self.status == "completed" + } +} + +/// Reward tier based on wins out of 30 matches — mirrors FUT 23 structure. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ChampsTier { + Elite, + WorldClass, + Gold3, + Gold2, + Gold1, + Silver3, + Silver2, + Silver1, + Bronze3, + Bronze2, + Bronze1, +} + +impl ChampsTier { + pub fn from_wins(wins: i64) -> Self { + match wins { + 27.. => Self::Elite, + 23..=26 => Self::WorldClass, + 20..=22 => Self::Gold3, + 17..=19 => Self::Gold2, + 14..=16 => Self::Gold1, + 11..=13 => Self::Silver3, + 8..=10 => Self::Silver2, + 5..=7 => Self::Silver1, + 2..=4 => Self::Bronze3, + 1 => Self::Bronze2, + _ => Self::Bronze1, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Elite => "Elite", + Self::WorldClass => "World Class", + Self::Gold3 => "Gold 3", + Self::Gold2 => "Gold 2", + Self::Gold1 => "Gold 1", + Self::Silver3 => "Silver 3", + Self::Silver2 => "Silver 2", + Self::Silver1 => "Silver 1", + Self::Bronze3 => "Bronze 3", + Self::Bronze2 => "Bronze 2", + Self::Bronze1 => "Bronze 1", + } + } + + pub fn reward_coins(&self) -> i64 { + match self { + Self::Elite => 50_000, + Self::WorldClass => 30_000, + Self::Gold3 => 15_000, + Self::Gold2 => 10_000, + Self::Gold1 => 7_500, + Self::Silver3 => 5_000, + Self::Silver2 => 3_500, + Self::Silver1 => 2_000, + Self::Bronze3 => 1_000, + Self::Bronze2 => 500, + Self::Bronze1 => 250, + } + } + + pub fn reward_pack(&self) -> Option<&'static str> { + match self { + Self::Elite => Some("icon_pack"), + Self::WorldClass => Some("rare_gold_pack"), + Self::Gold3 | Self::Gold2 | Self::Gold1 => Some("gold_pack"), + Self::Silver3 | Self::Silver2 | Self::Silver1 => Some("silver_pack"), + Self::Bronze3 => Some("bronze_pack"), + Self::Bronze2 | Self::Bronze1 => None, + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index a64742a..15fdfdf 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod card; pub mod chemistry_style; pub mod club; pub mod draft; +pub mod fut_champs; pub mod event; pub mod season; pub mod market; diff --git a/src/routes/fut_champs.rs b/src/routes/fut_champs.rs new file mode 100644 index 0000000..fcf39f1 --- /dev/null +++ b/src/routes/fut_champs.rs @@ -0,0 +1,123 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc}, +}; + +/// GET /fut-champs — current active session, or null if none. +pub async fn get_fut_champs(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let session = champs_svc::get_active_session(&state.pool, &profile.id).await?; + + Ok(Json(json!({ + "session": session, + "max_matches": crate::models::fut_champs::MAX_MATCHES, + }))) +} + +/// POST /fut-champs/start — open a new FUT Champions week. +pub async fn post_start_fut_champs(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let session = champs_svc::start_session(&state.pool, &profile.id).await?; + + Ok(Json(json!({ + "session": session, + "message": "FUT Champions week started — play up to 30 matches", + }))) +} + +#[derive(Deserialize)] +pub struct ChampsMatchRequest { + pub goals_for: i64, + pub goals_against: i64, +} + +/// POST /fut-champs/:session_id/result — record a match in this session. +pub async fn post_champs_result( + State(state): State, + Path(session_id): Path, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + + let session = champs_svc::record_match( + &state.pool, + &profile.id, + &session_id, + req.goals_for, + req.goals_against, + ) + .await?; + + let outcome = if req.goals_for > req.goals_against { + "win" + } else if req.goals_for == req.goals_against { + "draw" + } else { + "loss" + }; + + Ok(Json(json!({ + "session": session, + "outcome": outcome, + "matches_remaining": session.matches_remaining(), + "auto_completed": session.is_complete(), + }))) +} + +/// POST /fut-champs/:session_id/claim — claim end-of-week rewards. +pub async fn post_claim_champs_rewards( + State(state): State, + Path(session_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 result = champs_svc::claim_rewards( + &state.pool, + &profile.id, + &session_id, + &club.id, + &state.pack_defs, + ) + .await?; + + Ok(Json(result)) +} + +/// GET /fut-champs/history — past sessions, newest first. +pub async fn get_champs_history(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let history = champs_svc::get_history(&state.pool, &profile.id).await?; + + Ok(Json(json!({ + "history": history, + "total": history.len(), + }))) +} + +/// POST /rivals/claim-weekly — claim weekly Division Rivals reward. +pub async fn post_claim_rivals_reward(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?; + + // Ensure a season row exists + season_svc::get_or_create(&state.pool, &profile.id).await?; + + let result = champs_svc::claim_rivals_reward( + &state.pool, + &profile.id, + &club.id, + &state.pack_defs, + ) + .await?; + + Ok(Json(result)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 80209c7..aef7f7d 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod cards; pub mod club; pub mod division; pub mod draft; +pub mod fut_champs; pub mod events; pub mod health; pub mod market; diff --git a/src/services/fut_champs.rs b/src/services/fut_champs.rs new file mode 100644 index 0000000..c834d3f --- /dev/null +++ b/src/services/fut_champs.rs @@ -0,0 +1,291 @@ +use uuid::Uuid; + +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::fut_champs::{ChampsTier, FutChampsSession, MAX_MATCHES}, + models::pack::PackDefinition, + services::{club as club_svc, pack as pack_svc}, +}; + +const SESSION_SELECT: &str = + "SELECT id, profile_id, week_number, matches_played, wins, draws, losses, \ + status, reward_tier, reward_coins, reward_pack_id, reward_claimed, \ + started_at, completed_at FROM fut_champs_sessions"; + +pub async fn get_active_session( + pool: &Pool, + profile_id: &str, +) -> AppResult> { + sqlx::query_as::<_, FutChampsSession>(&format!( + "{SESSION_SELECT} WHERE profile_id = ? AND status = 'active' ORDER BY started_at DESC LIMIT 1" + )) + .bind(profile_id) + .fetch_optional(pool) + .await + .map_err(Into::into) +} + +pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult { + sqlx::query_as::<_, FutChampsSession>(&format!( + "{SESSION_SELECT} WHERE id = ? AND profile_id = ?" + )) + .bind(session_id) + .bind(profile_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("FUT Champions session not found".into())) +} + +pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult> { + sqlx::query_as::<_, FutChampsSession>(&format!( + "{SESSION_SELECT} WHERE profile_id = ? ORDER BY started_at DESC LIMIT 20" + )) + .bind(profile_id) + .fetch_all(pool) + .await + .map_err(Into::into) +} + +pub async fn start_session(pool: &Pool, profile_id: &str) -> AppResult { + // Only one active session at a time + if let Some(existing) = get_active_session(pool, profile_id).await? { + return Err(AppError::Conflict(format!( + "active FUT Champions session already exists: {}", + existing.id + ))); + } + + // Determine next week number from history + let week_number: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(week_number), 0) + 1 FROM fut_champs_sessions WHERE profile_id = ?", + ) + .bind(profile_id) + .fetch_one(pool) + .await?; + + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO fut_champs_sessions \ + (id, profile_id, week_number, matches_played, wins, draws, losses, \ + status, reward_claimed, started_at) \ + VALUES (?, ?, ?, 0, 0, 0, 0, 'active', 0, ?)", + ) + .bind(&id) + .bind(profile_id) + .bind(week_number) + .bind(&now) + .execute(pool) + .await?; + + get_session(pool, &id, profile_id).await +} + +/// Record a FUT Champions match result. Auto-completes the session at 30 matches. +pub async fn record_match( + pool: &Pool, + profile_id: &str, + session_id: &str, + goals_for: i64, + goals_against: i64, +) -> AppResult { + let session = get_session(pool, session_id, profile_id).await?; + + if session.is_complete() { + return Err(AppError::BadRequest( + "this FUT Champions session is already complete".into(), + )); + } + + let (win_delta, draw_delta, loss_delta) = if goals_for > goals_against { + (1i64, 0i64, 0i64) + } else if goals_for == goals_against { + (0, 1, 0) + } else { + (0, 0, 1) + }; + + sqlx::query( + "UPDATE fut_champs_sessions \ + SET matches_played = matches_played + 1, \ + wins = wins + ?, \ + draws = draws + ?, \ + losses = losses + ? \ + WHERE id = ?", + ) + .bind(win_delta) + .bind(draw_delta) + .bind(loss_delta) + .bind(session_id) + .execute(pool) + .await?; + + let updated = get_session(pool, session_id, profile_id).await?; + + if updated.matches_played >= MAX_MATCHES { + complete_session(pool, &updated).await?; + get_session(pool, session_id, profile_id).await + } else { + Ok(updated) + } +} + +/// Compute tier and write reward fields; mark session completed. +async fn complete_session(pool: &Pool, session: &FutChampsSession) -> AppResult<()> { + let tier = ChampsTier::from_wins(session.wins); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "UPDATE fut_champs_sessions \ + SET status = 'completed', completed_at = ?, \ + reward_tier = ?, reward_coins = ?, reward_pack_id = ? \ + WHERE id = ?", + ) + .bind(&now) + .bind(tier.label()) + .bind(tier.reward_coins()) + .bind(tier.reward_pack()) + .bind(&session.id) + .execute(pool) + .await?; + + Ok(()) +} + +/// Claim the rewards for a completed session. Idempotent — double-claim returns an error. +pub async fn claim_rewards( + pool: &Pool, + profile_id: &str, + session_id: &str, + club_id: &str, + pack_defs: &[PackDefinition], +) -> AppResult { + let session = get_session(pool, session_id, profile_id).await?; + + if !session.is_complete() { + return Err(AppError::BadRequest( + "session is still active — play all 30 matches first".into(), + )); + } + if session.reward_claimed { + return Err(AppError::Conflict("rewards already claimed".into())); + } + + let coins = session.reward_coins.unwrap_or(0); + let new_balance = club_svc::add_coins(pool, club_id, coins).await?; + + let pack = if let Some(ref pack_id) = session.reward_pack_id { + // Only grant if the pack definition exists; ignore unknown packs gracefully + if pack_defs.iter().any(|d| &d.id == pack_id) { + let granted = pack_svc::grant_pack(pool, club_id, pack_id).await?; + Some(granted.id) + } else { + None + } + } else { + None + }; + + sqlx::query("UPDATE fut_champs_sessions SET reward_claimed = 1 WHERE id = ?") + .bind(session_id) + .execute(pool) + .await?; + + Ok(serde_json::json!({ + "session_id": session_id, + "tier": session.reward_tier, + "coins_awarded": coins, + "new_balance": new_balance, + "pack_granted": pack, + })) +} + +// ── Division Rivals weekly reward ───────────────────────────────────────────── + +/// Rivals points awarded per Division Rivals match outcome. +pub fn rivals_points(goals_for: i64, goals_against: i64) -> i64 { + if goals_for > goals_against { + 25 + } else if goals_for == goals_against { + 10 + } else { + 5 + } +} + +/// Weekly coin reward for Division Rivals, scaling by division. +pub fn rivals_weekly_coins(division: i64) -> i64 { + match division { + 1 => 10_000, + 2 => 7_500, + 3 => 5_000, + 4 => 3_500, + 5 => 2_500, + 6 | 7 => 1_500, + _ => 1_000, + } +} + +/// Claim the weekly Division Rivals reward. +/// +/// "Week" is tracked by a monotonic counter — since this is offline, +/// the player claims whenever they feel like ending their rivals week. +pub async fn claim_rivals_reward( + pool: &Pool, + profile_id: &str, + club_id: &str, + pack_defs: &[PackDefinition], +) -> AppResult { + // Fetch current season row (must exist) + let row: Option<(i64, i64, i64)> = sqlx::query_as( + "SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?", + ) + .bind(profile_id) + .fetch_optional(pool) + .await?; + + let (division, week_claimed, total_pts) = + row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?; + + let next_week = week_claimed + 1; + let coins = rivals_weekly_coins(division); + let new_balance = club_svc::add_coins(pool, club_id, coins).await?; + + // Division 1–3 get a silver pack; Division 4–5 get a bronze pack + let pack_id = match division { + 1..=3 => Some("silver_pack"), + 4..=5 => Some("bronze_pack"), + _ => None, + }; + + let pack = if let Some(pid) = pack_id { + if pack_defs.iter().any(|d| d.id == pid) { + let granted = pack_svc::grant_pack(pool, club_id, pid).await?; + Some(granted.id) + } else { + None + } + } else { + None + }; + + sqlx::query( + "UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \ + WHERE profile_id = ?", + ) + .bind(next_week) + .bind(profile_id) + .execute(pool) + .await?; + + Ok(serde_json::json!({ + "week_number": next_week, + "division": division, + "rivals_total_points": total_pts + 100, + "coins_awarded": coins, + "new_balance": new_balance, + "pack_granted": pack, + })) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index d33dcbb..15f0099 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod card_db; pub mod club; pub mod draft; pub mod event; +pub mod fut_champs; pub mod season; pub mod market; pub mod match_service; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 320db82..ce91694 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1168,3 +1168,244 @@ async fn test_upgrades_on_nonexistent_card_return_404() { ).await; assert_eq!(s, StatusCode::NOT_FOUND); } + +// ── Phase 11: FUT Champions / Division Rivals ───────────────────────────────── + +#[tokio::test] +async fn test_fut_champs_no_active_session_initially() { + let app = build_test_app().await; + auth(&app, "ChampsCheck").await; + + let (s, json) = json_get(&app, "/fut-champs").await; + assert_eq!(s, StatusCode::OK); + assert!(json["session"].is_null(), "no session should exist on fresh profile"); +} + +#[tokio::test] +async fn test_fut_champs_start_session() { + let app = build_test_app().await; + auth(&app, "ChampsStarter").await; + + let (s, json) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["session"]["week_number"], 1); + assert_eq!(json["session"]["matches_played"], 0); + assert_eq!(json["session"]["status"], "active"); +} + +#[tokio::test] +async fn test_fut_champs_cannot_start_two_sessions() { + let app = build_test_app().await; + auth(&app, "DoubleStart").await; + + json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let (s, json) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + assert_eq!(s, StatusCode::CONFLICT, "{json}"); +} + +#[tokio::test] +async fn test_fut_champs_record_match_win() { + let app = build_test_app().await; + auth(&app, "ChampsWinner").await; + + let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let session_id = start["session"]["id"].as_str().unwrap(); + + let (s, json) = json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + serde_json::json!({ "goals_for": 3, "goals_against": 1 }), + ).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["outcome"], "win"); + assert_eq!(json["session"]["wins"], 1); + assert_eq!(json["session"]["matches_played"], 1); + assert_eq!(json["matches_remaining"], 29); +} + +#[tokio::test] +async fn test_fut_champs_record_match_draw_and_loss() { + let app = build_test_app().await; + auth(&app, "ChampsDrawLoss").await; + + let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let session_id = start["session"]["id"].as_str().unwrap(); + + let (_, draw) = json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + serde_json::json!({ "goals_for": 1, "goals_against": 1 }), + ).await; + assert_eq!(draw["outcome"], "draw"); + assert_eq!(draw["session"]["draws"], 1); + + let (_, loss) = json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + serde_json::json!({ "goals_for": 0, "goals_against": 2 }), + ).await; + assert_eq!(loss["outcome"], "loss"); + assert_eq!(loss["session"]["losses"], 1); +} + +/// Play all 30 matches and verify the session auto-completes at the correct tier. +#[tokio::test] +async fn test_fut_champs_session_auto_completes_at_30_matches() { + let app = build_test_app().await; + auth(&app, "Champs30Player").await; + + let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let session_id = start["session"]["id"].as_str().unwrap().to_string(); + + let mut last_json = serde_json::json!({}); + for i in 0..30 { + let (s, json) = json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + // Win 20 out of 30 → Gold 3 tier + serde_json::json!({ + "goals_for": if i < 20 { 2 } else { 0 }, + "goals_against": if i < 20 { 0 } else { 2 } + }), + ).await; + assert_eq!(s, StatusCode::OK, "match {i} failed: {json}"); + last_json = json; + } + + assert_eq!(last_json["auto_completed"], true); + assert_eq!(last_json["session"]["status"], "completed"); + assert_eq!(last_json["session"]["wins"], 20); + assert_eq!(last_json["session"]["reward_tier"], "Gold 3"); + assert_eq!(last_json["session"]["reward_coins"], 15_000); +} + +#[tokio::test] +async fn test_fut_champs_claim_rewards() { + let app = build_test_app().await; + auth(&app, "ChampsClaimPlayer").await; + + let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let session_id = start["session"]["id"].as_str().unwrap().to_string(); + + // Play all 30 (win all → Elite tier) + for _ in 0..30 { + json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + serde_json::json!({ "goals_for": 2, "goals_against": 0 }), + ).await; + } + + let (s, json) = json_post( + &app, + &format!("/fut-champs/{session_id}/claim"), + serde_json::json!({}), + ).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["tier"], "Elite"); + assert_eq!(json["coins_awarded"], 50_000); + assert!(json["pack_granted"].is_string(), "Elite should grant an icon pack"); + + // Double-claim should fail + let (s, _) = json_post( + &app, + &format!("/fut-champs/{session_id}/claim"), + serde_json::json!({}), + ).await; + assert_eq!(s, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_fut_champs_claim_active_session_fails() { + let app = build_test_app().await; + auth(&app, "EarlyClaimPlayer").await; + + let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let session_id = start["session"]["id"].as_str().unwrap(); + + // Only 1 match played — claim should reject + json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + serde_json::json!({ "goals_for": 2, "goals_against": 0 }), + ).await; + + let (s, _) = json_post( + &app, + &format!("/fut-champs/{session_id}/claim"), + serde_json::json!({}), + ).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_fut_champs_history_grows() { + let app = build_test_app().await; + auth(&app, "HistoryGrowPlayer").await; + + let (_, hist) = json_get(&app, "/fut-champs/history").await; + assert_eq!(hist["total"], 0); + + // Complete a session + let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await; + let session_id = start["session"]["id"].as_str().unwrap().to_string(); + for _ in 0..30 { + json_post( + &app, + &format!("/fut-champs/{session_id}/result"), + serde_json::json!({ "goals_for": 1, "goals_against": 0 }), + ).await; + } + + let (_, hist) = json_get(&app, "/fut-champs/history").await; + assert_eq!(hist["total"], 1); + assert_eq!(hist["history"][0]["status"], "completed"); +} + +#[tokio::test] +async fn test_fut_champs_tier_boundaries() { + // Verify tier calculation for edge win counts + use openfut_core::models::fut_champs::ChampsTier; + assert_eq!(ChampsTier::from_wins(27).label(), "Elite"); + assert_eq!(ChampsTier::from_wins(26).label(), "World Class"); + assert_eq!(ChampsTier::from_wins(23).label(), "World Class"); + assert_eq!(ChampsTier::from_wins(20).label(), "Gold 3"); + assert_eq!(ChampsTier::from_wins(0).label(), "Bronze 1"); + assert_eq!(ChampsTier::from_wins(1).label(), "Bronze 2"); + assert_eq!(ChampsTier::from_wins(5).label(), "Silver 1"); + assert_eq!(ChampsTier::from_wins(14).label(), "Gold 1"); +} + +#[tokio::test] +async fn test_rivals_weekly_reward_claim() { + let app = build_test_app().await; + auth(&app, "RivalsClaimPlayer").await; + + // Play a match to create the season row + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + })).await; + + let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["week_number"], 1); + assert!(json["coins_awarded"].as_i64().unwrap() > 0); + assert!(json["new_balance"].as_i64().unwrap() > 0); +} + +#[tokio::test] +async fn test_rivals_reward_increments_week_counter() { + let app = build_test_app().await; + auth(&app, "RivalsWeekCounter").await; + + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + })).await; + + json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; + let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK, "{json}"); + assert_eq!(json["week_number"], 2, "second claim should be week 2"); +}