feat: Phase 2 — game feel complete

Chemistry & squads:
- Chemistry calculation on GET /squad (club/league/nation links, max 100)
- Formation validation on POST /squad (exactly 11 starters, exactly 1 GK)

Objectives:
- Weekly objectives JSON (4 objectives: warrior, goals, dedicated, SBC)
- Daily objectives auto-reset at midnight UTC (background task)

SBC validation expanded:
- max_overall per-player enforcement
- required_clubs validation
- min_players_from_same_nation validation
- min_players_from_same_club validation

Market:
- GET /market?min_overall=X&position=Y filtering
- Expiry cleanup runs before every NPC refresh
- NPC market auto-refresh every 24h (background task, runs at startup)

Matches:
- GET /matches/opponent?difficulty=beginner|professional|world_class|legendary
  generates a random AI opponent squad from the card pool

Statistics:
- win_streak and best_win_streak tracking (migration 0002)
- GET /statistics/history?limit=N — last N matches with summary stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 15:33:09 -07:00
parent 34bce2ce75
commit 0afce0dd59
13 changed files with 516 additions and 55 deletions
+41 -27
View File
@@ -1,20 +1,19 @@
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
let existing = sqlx::query_as::<_, Statistics>(
"SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at FROM statistics WHERE profile_id = ?"
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
if let Some(s) = existing {
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
.bind(profile_id)
.fetch_optional(pool)
.await?
{
return Ok(s);
}
let stats = Statistics::new(profile_id);
sqlx::query(
"INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)"
"INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)",
)
.bind(profile_id)
.bind(&stats.updated_at)
@@ -32,7 +31,7 @@ pub async fn record_match(
goals_against: i64,
coins: i64,
) -> AppResult<()> {
get_or_create(pool, profile_id).await?;
let current = get_or_create(pool, profile_id).await?;
let now = chrono::Utc::now().to_rfc3339();
let (w, d, l) = match outcome {
@@ -41,16 +40,25 @@ pub async fn record_match(
_ => (0, 0, 1),
};
let new_streak = if outcome == "win" {
current.win_streak + 1
} else {
0
};
let new_best = new_streak.max(current.best_win_streak);
sqlx::query(
"UPDATE statistics SET
matches_played = matches_played + 1,
matches_won = matches_won + ?,
matches_drawn = matches_drawn + ?,
matches_lost = matches_lost + ?,
goals_scored = goals_scored + ?,
goals_conceded = goals_conceded + ?,
matches_played = matches_played + 1,
matches_won = matches_won + ?,
matches_drawn = matches_drawn + ?,
matches_lost = matches_lost + ?,
goals_scored = goals_scored + ?,
goals_conceded = goals_conceded + ?,
total_coins_earned = total_coins_earned + ?,
updated_at = ?
win_streak = ?,
best_win_streak = ?,
updated_at = ?
WHERE profile_id = ?",
)
.bind(w)
@@ -59,6 +67,8 @@ pub async fn record_match(
.bind(goals_for)
.bind(goals_against)
.bind(coins)
.bind(new_streak)
.bind(new_best)
.bind(&now)
.bind(profile_id)
.execute(pool)
@@ -70,21 +80,25 @@ pub async fn record_match(
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
get_or_create(pool, profile_id).await?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?")
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?",
)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn increment_sbcs_completed(pool: &Pool, profile_id: &str) -> AppResult<()> {
get_or_create(pool, profile_id).await?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?")
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?",
)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
Ok(())
}