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:
funman300
2026-06-25 14:52:06 -07:00
commit 1ffe0ffa9f
60 changed files with 6152 additions and 0 deletions
+166
View File
@@ -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)
}