26b8e9efef
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>
292 lines
8.5 KiB
Rust
292 lines
8.5 KiB
Rust
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 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,
|
||
}))
|
||
}
|