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
+144
View File
@@ -0,0 +1,144 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::{
card::CardDefinition,
pack::{Pack, PackDefinition, PackOpenResult},
},
services::card_db::CardDb,
};
use anyhow::Context;
use rand::seq::SliceRandom;
use std::path::Path;
use uuid::Uuid;
pub fn load_pack_definitions(data_dir: &str) -> anyhow::Result<Vec<PackDefinition>> {
let dir = Path::new(data_dir).join("packs");
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<PackDefinition> =
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
defs.extend(batch);
}
}
Ok(defs)
}
pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppResult<Pack> {
let pack = Pack {
id: Uuid::new_v4().to_string(),
club_id: club_id.to_string(),
definition_id: definition_id.to_string(),
opened: false,
created_at: chrono::Utc::now().to_rfc3339(),
};
sqlx::query(
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(&pack.id)
.bind(&pack.club_id)
.bind(&pack.definition_id)
.bind(pack.opened)
.bind(&pack.created_at)
.execute(pool)
.await?;
Ok(pack)
}
pub async fn open_pack(
pool: &Pool,
card_db: &CardDb,
pack_defs: &[PackDefinition],
club_id: &str,
pack_id: &str,
) -> AppResult<PackOpenResult> {
let pack = sqlx::query_as::<_, Pack>(
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?"
)
.bind(pack_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
if pack.opened {
return Err(AppError::BadRequest("pack already opened".into()));
}
let def = pack_defs
.iter()
.find(|d| d.id == pack.definition_id)
.ok_or_else(|| {
AppError::NotFound(format!("pack definition {} not found", pack.definition_id))
})?;
let mut cards: Vec<CardDefinition> = Vec::new();
for slot in &def.slots {
let pool_cards: Vec<CardDefinition> = if let Some(rarities) = &slot.rarity_filter {
card_db
.all()
.into_iter()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
rarities.contains(&r)
})
.cloned()
.collect()
} else if let Some(min) = slot.min_overall {
card_db.by_min_overall(min).into_iter().cloned().collect()
} else {
card_db.all().into_iter().cloned().collect()
};
// Choose cards synchronously before any awaits so ThreadRng is not held across .await
let chosen: Vec<CardDefinition> = {
let mut rng = rand::thread_rng();
(0..slot.count)
.filter_map(|_| pool_cards.choose(&mut rng).cloned())
.collect()
};
for card in chosen {
let owned_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)"
)
.bind(&owned_id)
.bind(club_id)
.bind(&card.id)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await?;
cards.push(card);
}
}
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?")
.bind(pack_id)
.execute(pool)
.await?;
Ok(PackOpenResult {
pack_id: pack_id.to_string(),
cards,
})
}
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
let packs = sqlx::query_as::<_, Pack>(
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0"
)
.bind(club_id)
.fetch_all(pool)
.await?;
Ok(packs)
}