Files
OpenFUT-Core/src/services/fut_champs.rs
T
funman300 26b8e9efef
CI / Build, lint & test (push) Failing after 1m33s
Phase 11: FUT Champions mode and Division Rivals weekly rewards
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>
2026-06-25 17:06:18 -07:00

292 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}))
}