Files
OpenFUT-Core/src/services/match_service.rs
T
funman300 8fa125cfd6
CI / Build, lint & test (push) Failing after 1m46s
Phase 7 (Core): chemistry v2, expanded card pool & search filters
- Chemistry v2: FUT-style link scoring with per-player breakdown
  (club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3;
   player cap 10, team cap 100); chemistry response now includes
   per-player breakdown with club/league/nation link counts and pts
- Card pool: 34 new cards across Premier League, La Liga, Bundesliga
  with overlapping clubs/nations for meaningful chemistry testing
- Card search: extended GET /cards with nation, league, club,
  min_overall, max_overall, limit query params; results sorted by
  overall descending
- Match opponent: added "ultimate" difficulty band (85+ OVR);
  random formation selection from 6 tactical formations; falls back
  to lower OVR pool when not enough cards at the requested band
- Tests: 9 new integration tests (28 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:31:56 -07:00

180 lines
5.1 KiB
Rust

use crate::{
db::Pool,
error::AppResult,
models::{
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
objective::ObjectiveDefinition,
},
services::{card_db::CardDb, club, objective, profile, 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],
) -> AppResult<MatchRewardResult> {
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?;
profile::add_xp(pool, profile_id, xp).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);
Ok(MatchRewardResult {
match_record,
coins_awarded: coins,
xp_awarded: xp,
objectives_updated,
})
}