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
+73 -1
View File
@@ -5,8 +5,80 @@ use crate::{
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
objective::ObjectiveDefinition,
},
services::{club, objective, profile, statistics},
services::{card_db::CardDb, club, objective, profile, statistics},
};
use rand::{seq::SliceRandom, Rng};
/// Generate a random AI opponent squad for Squad Battles.
pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value {
let (min_overall, names): (u8, &[&str]) = match difficulty {
"professional" => (
70,
&[
"Athletic CF",
"City Wanderers",
"The Rovers",
"United Select",
"Blue Stars FC",
],
),
"world_class" => (
78,
&[
"Elite Stars FC",
"Champions Select",
"Premier XI",
"Galaxy United",
"Titan FC",
],
),
"legendary" => (
85,
&[
"Legends United",
"Ultimate XI",
"Gold Standard FC",
"The Icons",
"Heritage FC",
],
),
_ => (
58,
&[
"Amateur Town FC",
"Sunday League XI",
"Park FC",
"Village Stars",
"Reserve XI",
],
),
};
let mut rng = rand::thread_rng();
let all = card_db.by_min_overall(min_overall);
let mut indices: Vec<usize> = (0..all.len()).collect();
indices.shuffle(&mut rng);
let cards: Vec<_> = indices
.into_iter()
.take(11)
.map(|i| all[i].clone())
.collect();
let squad_rating = if cards.is_empty() {
0
} else {
cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64
};
let name = names[rng.gen_range(0..names.len())];
serde_json::json!({
"opponent_name": name,
"difficulty": difficulty,
"squad_rating": squad_rating,
"formation": "4-4-2",
"cards": cards,
})
}
const COINS_WIN: i64 = 400;
const COINS_DRAW: i64 = 150;