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
+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,
}))
}