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
+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;