Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0afce0dd59 |
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"id": "weekly_win_3",
|
||||
"title": "Weekly Warrior",
|
||||
"description": "Win 3 matches this week.",
|
||||
"objective_type": "weekly",
|
||||
"metric": "matcheswon",
|
||||
"target": 3,
|
||||
"reward_coins": 2000,
|
||||
"reward_pack_id": "silver_pack",
|
||||
"reward_xp": 500
|
||||
},
|
||||
{
|
||||
"id": "weekly_goals_10",
|
||||
"title": "Goal Machine",
|
||||
"description": "Score 10 goals this week.",
|
||||
"objective_type": "weekly",
|
||||
"metric": "goalsscored",
|
||||
"target": 10,
|
||||
"reward_coins": 1500,
|
||||
"reward_pack_id": null,
|
||||
"reward_xp": 400
|
||||
},
|
||||
{
|
||||
"id": "weekly_play_5",
|
||||
"title": "Dedicated Player",
|
||||
"description": "Play 5 matches this week.",
|
||||
"objective_type": "weekly",
|
||||
"metric": "matchesplayed",
|
||||
"target": 5,
|
||||
"reward_coins": 1000,
|
||||
"reward_pack_id": null,
|
||||
"reward_xp": 300
|
||||
},
|
||||
{
|
||||
"id": "weekly_sbc_2",
|
||||
"title": "SBC Specialist",
|
||||
"description": "Complete 2 SBCs this week.",
|
||||
"objective_type": "weekly",
|
||||
"metric": "sbcscompleted",
|
||||
"target": 2,
|
||||
"reward_coins": 2500,
|
||||
"reward_pack_id": "gold_pack",
|
||||
"reward_xp": 600
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add win streak tracking to statistics
|
||||
ALTER TABLE statistics ADD COLUMN win_streak INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE statistics ADD COLUMN best_win_streak INTEGER NOT NULL DEFAULT 0;
|
||||
+68
-8
@@ -1,11 +1,13 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
db::Pool,
|
||||
models::{objective::ObjectiveDefinition, pack::PackDefinition, sbc::SbcDefinition},
|
||||
models::objective::{ObjectiveDefinition, ObjectiveType},
|
||||
models::pack::PackDefinition,
|
||||
models::sbc::SbcDefinition,
|
||||
routes,
|
||||
services::{
|
||||
card_db::CardDb, objective::load_objective_definitions, pack::load_pack_definitions,
|
||||
sbc::load_sbc_definitions,
|
||||
card_db::CardDb, market, objective::load_objective_definitions,
|
||||
pack::load_pack_definitions, sbc::load_sbc_definitions,
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -32,20 +34,73 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||
|
||||
tracing::info!(
|
||||
"Loaded {} packs, {} objectives, {} SBCs",
|
||||
"Loaded {} cards, {} packs, {} objectives, {} SBCs",
|
||||
card_db.cards.len(),
|
||||
pack_defs.len(),
|
||||
obj_defs.len(),
|
||||
sbc_defs.len()
|
||||
sbc_defs.len(),
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
card_db,
|
||||
pool: pool.clone(),
|
||||
card_db: card_db.clone(),
|
||||
pack_defs,
|
||||
obj_defs,
|
||||
obj_defs: obj_defs.clone(),
|
||||
sbc_defs,
|
||||
};
|
||||
|
||||
// Background task: refresh NPC market at startup and every 24 hours
|
||||
{
|
||||
let pool = pool.clone();
|
||||
let card_db = card_db.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await {
|
||||
tracing::warn!("initial market refresh failed: {e}");
|
||||
}
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await;
|
||||
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await {
|
||||
tracing::warn!("market refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: reset daily objective progress at midnight UTC
|
||||
{
|
||||
let pool = pool.clone();
|
||||
let daily_ids: Vec<String> = obj_defs
|
||||
.iter()
|
||||
.filter(|d| matches!(d.objective_type, ObjectiveType::Daily))
|
||||
.map(|d| d.id.clone())
|
||||
.collect();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let now = chrono::Utc::now();
|
||||
let tomorrow = now.date_naive() + chrono::Days::new(1);
|
||||
let midnight = tomorrow
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|dt| dt.and_utc())
|
||||
.unwrap_or_else(|| now + chrono::Duration::hours(24));
|
||||
let secs = (midnight - now).num_seconds().max(1) as u64;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await;
|
||||
|
||||
for id in &daily_ids {
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE objective_progress SET current = 0, completed = 0, claimed = 0 WHERE objective_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("daily reset failed for {id}: {e}");
|
||||
}
|
||||
}
|
||||
tracing::info!("Daily objectives reset ({} objectives)", daily_ids.len());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let router = Router::new()
|
||||
.route("/health", get(routes::health::get_health))
|
||||
.route("/auth/local", post(routes::auth::post_auth_local))
|
||||
@@ -65,6 +120,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
post(routes::objectives::post_claim_objective),
|
||||
)
|
||||
.route("/matches", get(routes::matches::get_matches))
|
||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||
.route("/matches/result", post(routes::matches::post_match_result))
|
||||
.route("/sbc", get(routes::sbc::get_sbcs))
|
||||
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
||||
@@ -74,6 +130,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/market/sell", post(routes::market::post_market_sell))
|
||||
.route("/market/refresh", post(routes::market::post_market_refresh))
|
||||
.route("/statistics", get(routes::statistics::get_statistics))
|
||||
.route(
|
||||
"/statistics/history",
|
||||
get(routes::statistics::get_statistics_history),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
@@ -12,6 +12,8 @@ pub struct Statistics {
|
||||
pub packs_opened: i64,
|
||||
pub sbcs_completed: i64,
|
||||
pub total_coins_earned: i64,
|
||||
pub win_streak: i64,
|
||||
pub best_win_streak: i64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -28,6 +30,8 @@ impl Statistics {
|
||||
packs_opened: 0,
|
||||
sbcs_completed: 0,
|
||||
total_coins_earned: 0,
|
||||
win_streak: 0,
|
||||
best_win_streak: 0,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -1,4 +1,8 @@
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
@@ -8,8 +12,31 @@ use crate::{
|
||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_market(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let listings = market_svc::get_active_listings(&state.pool, &state.card_db).await?;
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarketQuery {
|
||||
pub min_overall: Option<u8>,
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_market(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<MarketQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let all = market_svc::get_active_listings(&state.pool, &state.card_db).await?;
|
||||
let listings: Vec<_> = all
|
||||
.into_iter()
|
||||
.filter(|l| {
|
||||
query
|
||||
.min_overall
|
||||
.map(|min| l.card.overall >= min)
|
||||
.unwrap_or(true)
|
||||
&& query
|
||||
.position
|
||||
.as_ref()
|
||||
.map(|p| l.card.position.eq_ignore_ascii_case(p))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(
|
||||
json!({ "listings": listings, "total": listings.len() }),
|
||||
))
|
||||
|
||||
@@ -47,6 +47,20 @@ pub async fn get_matches(
|
||||
Ok(Json(json!({ "matches": matches, "total": matches.len() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OpponentQuery {
|
||||
pub difficulty: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_opponent(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<OpponentQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let difficulty = query.difficulty.as_deref().unwrap_or("beginner");
|
||||
let opponent = match_service::generate_opponent(&state.card_db, difficulty);
|
||||
Ok(Json(opponent))
|
||||
}
|
||||
|
||||
pub async fn post_match_result(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SubmitMatchRequest>,
|
||||
|
||||
@@ -13,6 +13,7 @@ pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>>
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||
let chemistry = squad_svc::calculate_chemistry(&state.pool, &state.card_db, &players).await?;
|
||||
|
||||
let enriched: Vec<Value> = players
|
||||
.iter()
|
||||
@@ -34,6 +35,7 @@ pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>>
|
||||
"formation": squad.formation,
|
||||
},
|
||||
"players": enriched,
|
||||
"chemistry": chemistry,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -44,6 +46,10 @@ pub async fn post_squad(
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
if !req.players.is_empty() {
|
||||
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?;
|
||||
}
|
||||
|
||||
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
|
||||
Ok(Json(json!({ "squad": squad })))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::statistics::Statistics,
|
||||
models::{match_result::Match, statistics::Statistics},
|
||||
services::{profile as profile_svc, statistics as stats_svc},
|
||||
};
|
||||
|
||||
@@ -12,3 +17,55 @@ pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Sta
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HistoryQuery {
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn get_statistics_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let matches = sqlx::query_as::<_, Match>(
|
||||
"SELECT id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at FROM matches WHERE profile_id = ? ORDER BY played_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.bind(limit)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let total = matches.len();
|
||||
let wins = matches.iter().filter(|m| m.outcome == "win").count();
|
||||
let draws = matches.iter().filter(|m| m.outcome == "draw").count();
|
||||
let losses = matches.iter().filter(|m| m.outcome == "loss").count();
|
||||
let goals_for: i64 = matches.iter().map(|m| m.goals_for).sum();
|
||||
let goals_against: i64 = matches.iter().map(|m| m.goals_against).sum();
|
||||
let win_rate = if total > 0 {
|
||||
wins as f64 / total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let avg_goals = if total > 0 {
|
||||
goals_for as f64 / total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"matches": matches,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"wins": wins,
|
||||
"draws": draws,
|
||||
"losses": losses,
|
||||
"win_rate": (win_rate * 100.0).round() / 100.0,
|
||||
"total_goals_for": goals_for,
|
||||
"total_goals_against": goals_against,
|
||||
"avg_goals_per_game": (avg_goals * 100.0).round() / 100.0,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user