Phase 11: FUT Champions mode and Division Rivals weekly rewards
CI / Build, lint & test (push) Failing after 1m33s

FUT Champions (Weekend League):
- POST /fut-champs/start — open a new 30-match week (one active session at a time)
- GET /fut-champs — current session status with matches_remaining
- POST /fut-champs/:id/result — record a match; auto-completes and computes tier at 30
- POST /fut-champs/:id/claim — claim coins + pack reward (idempotent guard)
- GET /fut-champs/history — past sessions newest-first
- 11 reward tiers: Elite (27+ wins, 50k coins + icon pack) down to Bronze 1 (0 wins, 250 coins)

Division Rivals:
- POST /rivals/claim-weekly — claim weekly reward scaled by current division
- Week counter is monotonic (offline-safe, no real-time calendar dependency)
- Division 1-3 get a bonus pack alongside coins

Migration 0008 adds fut_champs_sessions table and two columns to seasons.
12 new integration tests — all 67 pass, clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:06:18 -07:00
parent 19c7b9989d
commit 26b8e9efef
9 changed files with 802 additions and 0 deletions
+21
View File
@@ -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;
+15
View File
@@ -194,6 +194,21 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.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))
+108
View File
@@ -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<String>,
pub reward_coins: Option<i64>,
pub reward_pack_id: Option<String>,
pub reward_claimed: bool,
pub started_at: String,
pub completed_at: Option<String>,
}
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,
}
}
}
+1
View File
@@ -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;
+123
View File
@@ -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<AppState>) -> AppResult<Json<Value>> {
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<AppState>) -> AppResult<Json<Value>> {
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<AppState>,
Path(session_id): Path<String>,
Json(req): Json<ChampsMatchRequest>,
) -> AppResult<Json<Value>> {
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<AppState>,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
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<AppState>) -> AppResult<Json<Value>> {
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<AppState>) -> AppResult<Json<Value>> {
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))
}
+1
View File
@@ -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;
+291
View File
@@ -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<Option<FutChampsSession>> {
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<FutChampsSession> {
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<Vec<FutChampsSession>> {
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<FutChampsSession> {
// 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<FutChampsSession> {
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<serde_json::Value> {
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<serde_json::Value> {
// 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 13 get a silver pack; Division 45 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,
}))
}
+1
View File
@@ -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;
+241
View File
@@ -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");
}