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
+4
View File
@@ -35,6 +35,10 @@ pub async fn get_active_listings(
/// Refresh NPC market with random listings from the card pool.
pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<usize> {
// Clean up expired listings first
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool)
.await?;
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
.execute(pool)
.await?;
+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;
+55 -11
View File
@@ -130,14 +130,23 @@ fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec<Str
if let Some(min) = req.min_overall {
let avg = cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64;
if avg < min as i64 {
failures.push(format!("average overall {avg} < required {min}"));
failures.push(format!("average overall {avg} < required minimum {min}"));
}
}
if let Some(max) = req.max_overall {
if let Some(card) = cards.iter().find(|c| c.overall > max) {
failures.push(format!(
"'{}' has overall {} which exceeds maximum {max}",
card.name, card.overall
));
}
}
if !req.required_leagues.is_empty() {
for league in &req.required_leagues {
if !cards.iter().any(|c| &c.league == league) {
failures.push(format!("need at least one player from league {league}"));
failures.push(format!("need at least one player from league '{league}'"));
}
}
}
@@ -145,22 +154,57 @@ fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec<Str
if !req.required_nations.is_empty() {
for nation in &req.required_nations {
if !cards.iter().any(|c| &c.nation == nation) {
failures.push(format!("need at least one player from nation {nation}"));
failures.push(format!("need at least one player from nation '{nation}'"));
}
}
}
if !req.required_clubs.is_empty() {
for club in &req.required_clubs {
if !cards.iter().any(|c| &c.club == club) {
failures.push(format!("need at least one player from club '{club}'"));
}
}
}
if let Some(min_same_league) = req.min_players_from_same_league {
let league_counts: std::collections::HashMap<&str, usize> =
cards.iter().fold(Default::default(), |mut m, c| {
*m.entry(c.league.as_str()).or_default() += 1;
m
});
let max = league_counts.values().copied().max().unwrap_or(0);
if max < min_same_league as usize {
failures.push(format!("need {min_same_league} players from same league"));
let counts = count_by(cards, |c| c.league.as_str());
if counts.values().copied().max().unwrap_or(0) < min_same_league as usize {
failures.push(format!(
"need at least {min_same_league} players from the same league"
));
}
}
if let Some(min_same_nation) = req.min_players_from_same_nation {
let counts = count_by(cards, |c| c.nation.as_str());
if counts.values().copied().max().unwrap_or(0) < min_same_nation as usize {
failures.push(format!(
"need at least {min_same_nation} players from the same nation"
));
}
}
if let Some(min_same_club) = req.min_players_from_same_club {
let counts = count_by(cards, |c| c.club.as_str());
if counts.values().copied().max().unwrap_or(0) < min_same_club as usize {
failures.push(format!(
"need at least {min_same_club} players from the same club"
));
}
}
(failures.is_empty(), failures)
}
fn count_by<'a, F>(cards: &'a [CardDefinition], key: F) -> std::collections::HashMap<&'a str, usize>
where
F: Fn(&'a CardDefinition) -> &'a str,
{
cards
.iter()
.fold(std::collections::HashMap::new(), |mut m, c| {
*m.entry(key(c)).or_default() += 1;
m
})
}
+113 -3
View File
@@ -1,7 +1,11 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::squad::{SaveSquadRequest, Squad, SquadPlayer},
models::{
card::{CardDefinition, OwnedCard},
squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput},
},
services::card_db::CardDb,
};
use uuid::Uuid;
@@ -24,6 +28,112 @@ pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<Squa
Ok((squad, players))
}
/// Validate that the squad has exactly 11 starters with exactly one GK.
pub async fn validate_formation(
pool: &Pool,
card_db: &CardDb,
players: &[SquadPlayerInput],
) -> AppResult<()> {
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
if starters.len() != 11 {
return Err(AppError::BadRequest(format!(
"need exactly 11 starters, got {}",
starters.len()
)));
}
let mut gk_count = 0usize;
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?",
)
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
if let Some(card) = card_db.get(&owned.card_id) {
if card.position == "GK" {
gk_count += 1;
}
}
}
if gk_count == 0 {
return Err(AppError::BadRequest(
"squad must include exactly one goalkeeper (GK)".into(),
));
}
if gk_count > 1 {
return Err(AppError::BadRequest(
"squad cannot have more than one starting goalkeeper".into(),
));
}
Ok(())
}
/// Calculate chemistry for the starting XI. Returns total and per-player scores.
pub async fn calculate_chemistry(
pool: &Pool,
card_db: &CardDb,
players: &[SquadPlayer],
) -> AppResult<serde_json::Value> {
let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect();
let mut cards: Vec<CardDefinition> = Vec::new();
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?",
)
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?;
if let Some(o) = owned {
if let Some(card) = card_db.get(&o.card_id) {
cards.push(card.clone());
}
}
}
// Per-player chemistry: club links (max 4) + league links (max 3) + nation links (max 3), capped at 10
let player_chems: Vec<i64> = cards
.iter()
.enumerate()
.map(|(i, c)| {
let same_club = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.club == c.club)
.count()
.min(4) as i64;
let same_league = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.league == c.league)
.count()
.min(3) as i64;
let same_nation = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.nation == c.nation)
.count()
.min(3) as i64;
(same_club + same_league + same_nation).min(10)
})
.collect();
let total: i64 = player_chems.iter().sum::<i64>().min(100);
Ok(serde_json::json!({
"total": total,
"max": 100,
"player_chemistries": player_chems,
}))
}
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
let now = chrono::Utc::now().to_rfc3339();
@@ -54,7 +164,7 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A
req.formation.as_deref().unwrap_or("4-4-2"),
);
sqlx::query(
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&squad.id)
.bind(&squad.club_id)
@@ -70,7 +180,7 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A
for player in &req.players {
let sp_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)"
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&sp_id)
.bind(&squad_id)
+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(())
}