Initial commit: OpenFUT Core
Offline Ultimate Team backend — game-independent REST API. - 19 API endpoints: auth, profiles, clubs, cards, packs, squads, objectives, SBCs, match rewards, NPC market, statistics - Axum + SQLite + SQLx with full migrations - Weighted pack generator, SBC validation engine - JSON-driven mod data (cards, packs, objectives, SBCs) - 5 integration tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::CardDefinition,
|
||||
objective::ObjectiveDefinition,
|
||||
sbc::{SbcDefinition, SbcResult, SubmitSbcRequest},
|
||||
},
|
||||
services::{card_db::CardDb, club, objective, statistics},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn load_sbc_definitions(data_dir: &str) -> anyhow::Result<Vec<SbcDefinition>> {
|
||||
let dir = Path::new(data_dir).join("sbcs");
|
||||
let mut defs = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(defs);
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
||||
let batch: Vec<SbcDefinition> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||
defs.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(defs)
|
||||
}
|
||||
|
||||
pub async fn submit_sbc(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
sbc_defs: &[SbcDefinition],
|
||||
obj_defs: &[ObjectiveDefinition],
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
req: &SubmitSbcRequest,
|
||||
) -> AppResult<SbcResult> {
|
||||
let def = sbc_defs
|
||||
.iter()
|
||||
.find(|d| d.id == req.sbc_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("SBC {} not found", req.sbc_id)))?;
|
||||
|
||||
// Resolve cards from DB
|
||||
let mut cards: Vec<CardDefinition> = Vec::new();
|
||||
for owned_id in &req.owned_card_ids {
|
||||
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?"
|
||||
)
|
||||
.bind(owned_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {owned_id} not found")))?;
|
||||
|
||||
let card = card_db.get(&row.card_id).ok_or_else(|| {
|
||||
AppError::NotFound(format!("card definition {} not found", row.card_id))
|
||||
})?;
|
||||
cards.push(card.clone());
|
||||
}
|
||||
|
||||
let (passed, failures) = validate_sbc(def, &cards);
|
||||
|
||||
if passed {
|
||||
// Consume cards
|
||||
for owned_id in &req.owned_card_ids {
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||
.bind(owned_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Record submission
|
||||
let sub_id = Uuid::new_v4().to_string();
|
||||
let card_ids_json = serde_json::to_string(&req.owned_card_ids)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO sbc_submissions (id, profile_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, 1, ?)"
|
||||
)
|
||||
.bind(&sub_id)
|
||||
.bind(profile_id)
|
||||
.bind(&req.sbc_id)
|
||||
.bind(&card_ids_json)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Grant reward
|
||||
if def.reward.coins > 0 {
|
||||
club::add_coins(pool, club_id, def.reward.coins).await?;
|
||||
}
|
||||
if let Some(pack_id) = &def.reward.pack_id {
|
||||
crate::services::pack::grant_pack(pool, club_id, pack_id).await?;
|
||||
}
|
||||
|
||||
statistics::increment_sbcs_completed(pool, profile_id).await?;
|
||||
objective::increment_metric(pool, profile_id, obj_defs, "sbcscompleted", 1).await?;
|
||||
|
||||
Ok(SbcResult {
|
||||
passed: true,
|
||||
failures: vec![],
|
||||
reward: Some(def.reward.clone()),
|
||||
})
|
||||
} else {
|
||||
Ok(SbcResult {
|
||||
passed: false,
|
||||
failures,
|
||||
reward: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec<String>) {
|
||||
let mut failures = Vec::new();
|
||||
let req = &def.requirements;
|
||||
|
||||
if cards.len() != req.squad_size as usize {
|
||||
failures.push(format!(
|
||||
"need exactly {} cards, got {}",
|
||||
req.squad_size,
|
||||
cards.len()
|
||||
));
|
||||
return (false, failures);
|
||||
}
|
||||
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
(failures.is_empty(), failures)
|
||||
}
|
||||
Reference in New Issue
Block a user