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("/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))
|
||||
|
||||
@@ -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 club;
|
||||
pub mod draft;
|
||||
pub mod fut_champs;
|
||||
pub mod event;
|
||||
pub mod season;
|
||||
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 division;
|
||||
pub mod draft;
|
||||
pub mod fut_champs;
|
||||
pub mod events;
|
||||
pub mod health;
|
||||
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 draft;
|
||||
pub mod event;
|
||||
pub mod fut_champs;
|
||||
pub mod season;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
|
||||
@@ -1168,3 +1168,244 @@ async fn test_upgrades_on_nonexistent_card_return_404() {
|
||||
).await;
|
||||
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