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,63 @@
|
||||
use crate::models::card::CardDefinition;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// In-memory card registry loaded from data/cards/*.json
|
||||
pub struct CardDb {
|
||||
pub cards: HashMap<String, CardDefinition>,
|
||||
}
|
||||
|
||||
impl CardDb {
|
||||
pub fn load(data_dir: &str) -> Result<Self> {
|
||||
let cards_dir = Path::new(data_dir).join("cards");
|
||||
let mut cards = HashMap::new();
|
||||
|
||||
if !cards_dir.exists() {
|
||||
tracing::warn!("Card data directory not found: {:?}", cards_dir);
|
||||
return Ok(Self { cards });
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(&cards_dir)
|
||||
.with_context(|| format!("reading cards dir {:?}", cards_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<CardDefinition> = serde_json::from_str(&content)
|
||||
.with_context(|| format!("parsing {:?}", path))?;
|
||||
for card in batch {
|
||||
cards.insert(card.id.clone(), card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Loaded {} card definitions", cards.len());
|
||||
Ok(Self { cards })
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
||||
self.cards.get(id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
||||
self.cards
|
||||
.values()
|
||||
.filter(|c| {
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
r == rarity || rarity == "any"
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||
self.cards.values().collect()
|
||||
}
|
||||
|
||||
pub fn by_min_overall(&self, min: u8) -> Vec<&CardDefinition> {
|
||||
self.cards.values().filter(|c| c.overall >= min).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::club::Club,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
|
||||
sqlx::query_as::<_, Club>(
|
||||
"SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&club.id)
|
||||
.bind(&club.profile_id)
|
||||
.bind(&club.name)
|
||||
.bind(club.coins)
|
||||
.bind(club.level)
|
||||
.bind(club.created_at)
|
||||
.bind(club.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||
.bind(amount)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(new_balance)
|
||||
}
|
||||
|
||||
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if balance < amount {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"insufficient coins: have {balance}, need {amount}"
|
||||
)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
||||
.bind(amount)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(balance - amount)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::CardDefinition,
|
||||
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
|
||||
},
|
||||
services::{card_db::CardDb, club},
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_active_listings(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
) -> AppResult<Vec<MarketListingWithCard>> {
|
||||
let listings = sqlx::query_as::<_, MarketListing>(
|
||||
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') ORDER BY listed_at DESC LIMIT 50"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let with_cards: Vec<MarketListingWithCard> = listings
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
|
||||
listing: l,
|
||||
card: card.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(with_cards)
|
||||
}
|
||||
|
||||
/// Refresh NPC market with random listings from the card pool.
|
||||
pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<usize> {
|
||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Build all listings synchronously before any await — drops &CardDefinition refs before first .await
|
||||
let listings_to_insert: Vec<MarketListing> = {
|
||||
let all_cards: Vec<&CardDefinition> = card_db.all();
|
||||
if all_cards.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = 20usize.min(all_cards.len());
|
||||
let npc_names = [
|
||||
"FC Rovers NPC",
|
||||
"Market Bot",
|
||||
"Transfer AI",
|
||||
"Club Atletico Bot",
|
||||
"United NPC",
|
||||
"City AI Club",
|
||||
];
|
||||
let mut rng = rand::thread_rng();
|
||||
all_cards
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|card| {
|
||||
let base_price = price_for_card(card.overall);
|
||||
let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100);
|
||||
let seller = npc_names[rng.gen_range(0..npc_names.len())];
|
||||
MarketListing::new(&card.id, seller, price)
|
||||
})
|
||||
.collect()
|
||||
}; // all_cards refs and ThreadRng both dropped here
|
||||
|
||||
let mut inserted = 0;
|
||||
for listing in &listings_to_insert {
|
||||
sqlx::query(
|
||||
"INSERT INTO market_listings (id, card_id, seller_name, price, listed_at, expires_at, sold) VALUES (?, ?, ?, ?, ?, ?, 0)"
|
||||
)
|
||||
.bind(&listing.id)
|
||||
.bind(&listing.card_id)
|
||||
.bind(&listing.seller_name)
|
||||
.bind(listing.price)
|
||||
.bind(&listing.listed_at)
|
||||
.bind(&listing.expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub async fn buy_listing(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
club_id: &str,
|
||||
req: &BuyListingRequest,
|
||||
) -> AppResult<CardDefinition> {
|
||||
let listing = sqlx::query_as::<_, MarketListing>(
|
||||
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE id = ? AND sold = 0"
|
||||
)
|
||||
.bind(&req.listing_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
|
||||
|
||||
club::spend_coins(pool, club_id, listing.price).await?;
|
||||
|
||||
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?")
|
||||
.bind(&listing.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
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(&listing.card_id)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
card_db
|
||||
.get(&listing.card_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::NotFound("card definition not found".into()))
|
||||
}
|
||||
|
||||
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
|
||||
let _owned = 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(&req.owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
||||
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||
.bind(&req.owned_card_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Quick-sell price: 40% of requested price
|
||||
let coins = (req.price as f64 * 0.4) as i64;
|
||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
||||
Ok(new_balance)
|
||||
}
|
||||
|
||||
fn price_for_card(overall: u8) -> i64 {
|
||||
match overall {
|
||||
85..=u8::MAX => 50_000,
|
||||
80..=84 => 10_000,
|
||||
75..=79 => 3_000,
|
||||
70..=74 => 1_000,
|
||||
65..=69 => 500,
|
||||
_ => 200,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
models::{
|
||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||
objective::ObjectiveDefinition,
|
||||
},
|
||||
services::{club, objective, profile, statistics},
|
||||
};
|
||||
|
||||
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?;
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod card_db;
|
||||
pub mod club;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
pub mod objective;
|
||||
pub mod pack;
|
||||
pub mod profile;
|
||||
pub mod sbc;
|
||||
pub mod squad;
|
||||
pub mod statistics;
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
models::objective::{ObjectiveDefinition, ObjectiveProgress, ObjectiveWithProgress},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result<Vec<ObjectiveDefinition>> {
|
||||
let dir = Path::new(data_dir).join("objectives");
|
||||
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<ObjectiveDefinition> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||
defs.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(defs)
|
||||
}
|
||||
|
||||
pub async fn get_objectives_with_progress(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
defs: &[ObjectiveDefinition],
|
||||
) -> AppResult<Vec<ObjectiveWithProgress>> {
|
||||
let progress_rows = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let result = defs
|
||||
.iter()
|
||||
.map(|def| {
|
||||
let prog = progress_rows.iter().find(|p| p.objective_id == def.id);
|
||||
ObjectiveWithProgress {
|
||||
definition: def.clone(),
|
||||
current: prog.map(|p| p.current).unwrap_or(0),
|
||||
completed: prog.map(|p| p.completed).unwrap_or(false),
|
||||
claimed: prog.map(|p| p.claimed).unwrap_or(false),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Increment a metric for all objectives that track it.
|
||||
pub async fn increment_metric(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
defs: &[ObjectiveDefinition],
|
||||
metric: &str,
|
||||
amount: i64,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let mut completed_ids = Vec::new();
|
||||
|
||||
for def in defs
|
||||
.iter()
|
||||
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
|
||||
{
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
if let Some(prog) = existing {
|
||||
if prog.completed {
|
||||
continue;
|
||||
}
|
||||
let new_val = (prog.current + amount).min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
sqlx::query(
|
||||
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
|
||||
)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(&now)
|
||||
.bind(&prog.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
} else {
|
||||
let new_val = amount.min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(completed_ids)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::profile::Profile,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
||||
sqlx::query_as::<_, Profile>(
|
||||
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||
}
|
||||
|
||||
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(AppError::Conflict(
|
||||
"a profile already exists; OpenFUT is single-player only".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let profile = Profile::new(username);
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.bind(&profile.username)
|
||||
.bind(profile.level)
|
||||
.bind(profile.xp)
|
||||
.bind(profile.created_at)
|
||||
.bind(profile.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> {
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE profiles SET xp = xp + ?, updated_at = ? WHERE id = ?")
|
||||
.bind(xp)
|
||||
.bind(now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::squad::{SaveSquadRequest, Squad, SquadPlayer},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<SquadPlayer>)> {
|
||||
let squad = sqlx::query_as::<_, Squad>(
|
||||
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?;
|
||||
|
||||
let players = sqlx::query_as::<_, SquadPlayer>(
|
||||
"SELECT id, squad_id, owned_card_id, position_index, is_captain, is_on_bench FROM squad_players WHERE squad_id = ?"
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((squad, players))
|
||||
}
|
||||
|
||||
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let existing = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT id FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let squad_id = if let Some(id) = existing {
|
||||
sqlx::query("UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?")
|
||||
.bind(req.name.as_deref())
|
||||
.bind(req.formation.as_deref())
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
|
||||
.bind(&id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
id
|
||||
} else {
|
||||
let squad = Squad::new(
|
||||
club_id,
|
||||
req.name.as_deref().unwrap_or("My Squad"),
|
||||
req.formation.as_deref().unwrap_or("4-4-2"),
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.bind(&squad.club_id)
|
||||
.bind(&squad.name)
|
||||
.bind(&squad.formation)
|
||||
.bind(&squad.created_at)
|
||||
.bind(&squad.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
squad.id
|
||||
};
|
||||
|
||||
for player in &req.players {
|
||||
let sp_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&sp_id)
|
||||
.bind(&squad_id)
|
||||
.bind(&player.owned_card_id)
|
||||
.bind(player.position_index)
|
||||
.bind(player.is_captain)
|
||||
.bind(player.is_on_bench)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let squad = sqlx::query_as::<_, Squad>(
|
||||
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?",
|
||||
)
|
||||
.bind(&squad_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(squad)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
|
||||
|
||||
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
|
||||
let existing = sqlx::query_as::<_, Statistics>(
|
||||
"SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at FROM statistics WHERE profile_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(s) = existing {
|
||||
return Ok(s);
|
||||
}
|
||||
|
||||
let stats = Statistics::new(profile_id);
|
||||
sqlx::query(
|
||||
"INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(&stats.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub async fn record_match(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
outcome: &str,
|
||||
goals_for: i64,
|
||||
goals_against: i64,
|
||||
coins: i64,
|
||||
) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (w, d, l) = match outcome {
|
||||
"win" => (1i64, 0i64, 0i64),
|
||||
"draw" => (0, 1, 0),
|
||||
_ => (0, 0, 1),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE statistics SET
|
||||
matches_played = matches_played + 1,
|
||||
matches_won = matches_won + ?,
|
||||
matches_drawn = matches_drawn + ?,
|
||||
matches_lost = matches_lost + ?,
|
||||
goals_scored = goals_scored + ?,
|
||||
goals_conceded = goals_conceded + ?,
|
||||
total_coins_earned = total_coins_earned + ?,
|
||||
updated_at = ?
|
||||
WHERE profile_id = ?",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(d)
|
||||
.bind(l)
|
||||
.bind(goals_for)
|
||||
.bind(goals_against)
|
||||
.bind(coins)
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?")
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increment_sbcs_completed(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?")
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user