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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user