288 lines
9.5 KiB
Rust
288 lines
9.5 KiB
Rust
use crate::{
|
|
db::Pool,
|
|
error::AppResult,
|
|
models::{
|
|
achievement::AchievementDefinition,
|
|
card::OwnedCard,
|
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
|
objective::ObjectiveDefinition,
|
|
},
|
|
services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
|
|
};
|
|
use rand::{seq::SliceRandom, Rng};
|
|
|
|
const FORMATIONS: &[&str] = &[
|
|
"4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2",
|
|
];
|
|
|
|
/// Generate a random AI opponent squad for Squad Battles.
|
|
///
|
|
/// Difficulty bands:
|
|
/// beginner — overall 55+ (same as "any")
|
|
/// professional — overall 70+
|
|
/// world_class — overall 78+
|
|
/// legendary — overall 85+
|
|
/// ultimate — overall 90+
|
|
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"],
|
|
),
|
|
"ultimate" => (
|
|
90,
|
|
&["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
|
|
),
|
|
_ => (
|
|
55,
|
|
&["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);
|
|
|
|
// Fall back to lower overall band if not enough cards at this difficulty
|
|
let pool: Vec<_> = if all.len() >= 11 {
|
|
all
|
|
} else {
|
|
card_db.by_min_overall(55)
|
|
};
|
|
|
|
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
|
indices.shuffle(&mut rng);
|
|
let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[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())];
|
|
let formation = FORMATIONS[rng.gen_range(0..FORMATIONS.len())];
|
|
|
|
serde_json::json!({
|
|
"opponent_name": name,
|
|
"difficulty": difficulty,
|
|
"squad_rating": squad_rating,
|
|
"formation": formation,
|
|
"cards": cards,
|
|
})
|
|
}
|
|
|
|
const COINS_WIN: i64 = 400;
|
|
const COINS_DRAW: i64 = 150;
|
|
const COINS_LOSS: i64 = 75;
|
|
const XP_WIN: i64 = 200;
|
|
const XP_DRAW: i64 = 75;
|
|
const XP_LOSS: i64 = 30;
|
|
|
|
pub async fn process_match(
|
|
pool: &Pool,
|
|
profile_id: &str,
|
|
club_id: &str,
|
|
req: &SubmitMatchRequest,
|
|
obj_defs: &[ObjectiveDefinition],
|
|
ach_defs: &[AchievementDefinition],
|
|
) -> AppResult<MatchRewardResult> {
|
|
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
|
return Err(crate::error::AppError::BadRequest(
|
|
"goals_for and goals_against must each be between 0 and 99".into(),
|
|
));
|
|
}
|
|
|
|
let outcome = if req.goals_for > req.goals_against {
|
|
"win"
|
|
} else if req.goals_for == req.goals_against {
|
|
"draw"
|
|
} else {
|
|
"loss"
|
|
};
|
|
|
|
let (coins, xp) = match outcome {
|
|
"win" => (COINS_WIN, XP_WIN),
|
|
"draw" => (COINS_DRAW, XP_DRAW),
|
|
_ => (COINS_LOSS, XP_LOSS),
|
|
};
|
|
|
|
let match_record = Match::new(
|
|
profile_id,
|
|
&req.squad_id,
|
|
&req.opponent_name,
|
|
req.goals_for,
|
|
req.goals_against,
|
|
&req.mode,
|
|
coins,
|
|
xp,
|
|
);
|
|
|
|
sqlx::query(
|
|
"INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.bind(&match_record.id)
|
|
.bind(&match_record.profile_id)
|
|
.bind(&match_record.squad_id)
|
|
.bind(&match_record.opponent_name)
|
|
.bind(match_record.goals_for)
|
|
.bind(match_record.goals_against)
|
|
.bind(&match_record.outcome)
|
|
.bind(match_record.coins_awarded)
|
|
.bind(match_record.xp_awarded)
|
|
.bind(&match_record.mode)
|
|
.bind(&match_record.played_at)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
club::add_coins(pool, club_id, coins).await?;
|
|
let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?;
|
|
|
|
for ev in &level_ups {
|
|
let body = if let Some(ref pack) = ev.pack_granted {
|
|
format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted)
|
|
} else {
|
|
format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted)
|
|
};
|
|
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await;
|
|
}
|
|
|
|
statistics::record_match(
|
|
pool,
|
|
profile_id,
|
|
outcome,
|
|
req.goals_for,
|
|
req.goals_against,
|
|
coins,
|
|
)
|
|
.await?;
|
|
|
|
if let Some(positions) = &req.goal_positions {
|
|
statistics::record_position_goals(pool, profile_id, positions).await?;
|
|
}
|
|
|
|
let mut objectives_updated = Vec::new();
|
|
|
|
let mut completed =
|
|
objective::increment_metric(pool, profile_id, obj_defs, "matchesplayed", 1).await?;
|
|
objectives_updated.append(&mut completed);
|
|
|
|
if outcome == "win" {
|
|
let mut c =
|
|
objective::increment_metric(pool, profile_id, obj_defs, "matcheswon", 1).await?;
|
|
objectives_updated.append(&mut c);
|
|
}
|
|
|
|
let mut c =
|
|
objective::increment_metric(pool, profile_id, obj_defs, "goalsscored", req.goals_for)
|
|
.await?;
|
|
objectives_updated.append(&mut c);
|
|
|
|
let mut c =
|
|
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
|
objectives_updated.append(&mut c);
|
|
|
|
for obj_id in &objectives_updated {
|
|
let display_name = obj_defs
|
|
.iter()
|
|
.find(|d| &d.id == obj_id)
|
|
.map(|d| d.title.as_str())
|
|
.unwrap_or(obj_id.as_str());
|
|
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
|
|
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
|
}
|
|
|
|
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
|
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
|
|
|
for owned_id in &expired_loans {
|
|
let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
|
|
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
|
|
}
|
|
|
|
// Update season progress (creates the season row if it doesn't exist yet).
|
|
season_svc::get_or_create(pool, profile_id).await?;
|
|
let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?;
|
|
|
|
if let Some(ref se) = season_end {
|
|
use crate::models::season::SeasonResult;
|
|
let direction = match se.result {
|
|
SeasonResult::Promoted => "Promoted",
|
|
SeasonResult::Relegated => "Relegated",
|
|
SeasonResult::Maintained => "Maintained",
|
|
};
|
|
let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default());
|
|
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
|
}
|
|
|
|
let achievements_unlocked =
|
|
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
Ok(MatchRewardResult {
|
|
match_record,
|
|
coins_awarded: coins,
|
|
xp_awarded: xp,
|
|
objectives_updated,
|
|
expired_loans,
|
|
season_end,
|
|
level_ups,
|
|
achievements_unlocked,
|
|
})
|
|
}
|
|
|
|
/// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI.
|
|
/// Removes cards whose remaining count hits 0 and returns their owned_card_ids.
|
|
async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<Vec<String>> {
|
|
// Get starters (is_on_bench = 0) for this squad
|
|
let starters: Vec<(String, String)> = sqlx::query_as(
|
|
"SELECT sp.id, sp.owned_card_id FROM squad_players sp \
|
|
JOIN squads s ON s.id = sp.squad_id \
|
|
WHERE sp.squad_id = ? AND sp.is_on_bench = 0 AND s.club_id = ?",
|
|
)
|
|
.bind(squad_id)
|
|
.bind(club_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
let mut expired = Vec::new();
|
|
|
|
for (_sp_id, owned_id) in starters {
|
|
let card = sqlx::query_as::<_, OwnedCard>(
|
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
|
FROM owned_cards WHERE id = ? AND is_loan = 1",
|
|
)
|
|
.bind(&owned_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
|
|
if let Some(c) = card {
|
|
let remaining = c.loan_matches_remaining.unwrap_or(0);
|
|
if remaining <= 1 {
|
|
// Loan expired — remove from collection
|
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
|
.bind(&owned_id)
|
|
.execute(pool)
|
|
.await?;
|
|
expired.push(owned_id);
|
|
} else {
|
|
sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?")
|
|
.bind(remaining - 1)
|
|
.bind(&owned_id)
|
|
.execute(pool)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(expired)
|
|
}
|