Phase 11: FUT Champions mode and Division Rivals weekly rewards
CI / Build, lint & test (push) Failing after 1m33s
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:
@@ -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
@@ -194,6 +194,21 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/division", get(routes::division::get_division))
|
.route("/division", get(routes::division::get_division))
|
||||||
.route("/notifications", get(routes::notifications::get_notifications))
|
.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/squad", get(routes::draft::get_draft_squad))
|
||||||
.route("/draft/start", post(routes::draft::post_draft_start))
|
.route("/draft/start", post(routes::draft::post_draft_start))
|
||||||
.route("/draft/sessions/:id", get(routes::draft::get_draft_session))
|
.route("/draft/sessions/:id", get(routes::draft::get_draft_session))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod card;
|
|||||||
pub mod chemistry_style;
|
pub mod chemistry_style;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod fut_champs;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod season;
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod cards;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod fut_champs;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod card_db;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod fut_champs;
|
||||||
pub mod season;
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
|
|||||||
@@ -1168,3 +1168,244 @@ async fn test_upgrades_on_nonexistent_card_return_404() {
|
|||||||
).await;
|
).await;
|
||||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user