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
+74
View File
@@ -0,0 +1,74 @@
use crate::{
config::Config,
db::Pool,
models::{objective::ObjectiveDefinition, pack::PackDefinition, sbc::SbcDefinition},
routes,
services::{
card_db::CardDb, objective::load_objective_definitions, pack::load_pack_definitions,
sbc::load_sbc_definitions,
},
};
use anyhow::Result;
use axum::{
routing::{get, post},
Router,
};
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
#[derive(Clone)]
pub struct AppState {
pub pool: Pool,
pub card_db: Arc<CardDb>,
pub pack_defs: Arc<Vec<PackDefinition>>,
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
pub sbc_defs: Arc<Vec<SbcDefinition>>,
}
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
tracing::info!(
"Loaded {} packs, {} objectives, {} SBCs",
pack_defs.len(),
obj_defs.len(),
sbc_defs.len()
);
let state = AppState {
pool,
card_db,
pack_defs,
obj_defs,
sbc_defs,
};
let router = Router::new()
.route("/health", get(routes::health::get_health))
.route("/auth/local", post(routes::auth::post_auth_local))
.route("/profile", get(routes::profile::get_profile))
.route("/club", get(routes::club::get_club))
.route("/cards", get(routes::cards::get_cards))
.route("/collection", get(routes::cards::get_collection))
.route("/packs", get(routes::packs::get_packs))
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
.route("/squad", get(routes::squad::get_squad))
.route("/squad", post(routes::squad::post_squad))
.route("/objectives", get(routes::objectives::get_objectives))
.route("/matches/result", post(routes::matches::post_match_result))
.route("/sbc", get(routes::sbc::get_sbcs))
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
.route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell))
.route("/market/refresh", post(routes::market::post_market_refresh))
.route("/statistics", get(routes::statistics::get_statistics))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state);
Ok(router)
}
+25
View File
@@ -0,0 +1,25 @@
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct Config {
pub listen_addr: String,
pub database_url: String,
pub data_dir: String,
#[allow(dead_code)]
pub max_connections: u32,
}
impl Config {
pub fn from_env() -> Result<Self> {
Ok(Self {
listen_addr: std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into()),
database_url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite://openfut.db".into()),
data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into()),
max_connections: std::env::var("DB_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
})
}
}
+20
View File
@@ -0,0 +1,20 @@
use anyhow::Result;
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
use tracing::info;
pub type Pool = SqlitePool;
pub async fn init_pool(database_url: &str) -> Result<Pool> {
info!("Connecting to database: {}", database_url);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn run_migrations(pool: &Pool) -> Result<()> {
info!("Running database migrations");
sqlx::migrate!("./migrations").run(pool).await?;
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(#[from] anyhow::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
AppError::Database(e) => {
tracing::error!("Database error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
}
AppError::Internal(e) => {
tracing::error!("Internal error: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".into(),
)
}
AppError::Io(e) => {
tracing::error!("IO error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "io error".into())
}
AppError::Json(e) => (StatusCode::BAD_REQUEST, format!("json parse error: {e}")),
};
let body = Json(json!({ "error": message }));
(status, body).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+24
View File
@@ -0,0 +1,24 @@
pub mod app;
pub mod config;
pub mod db;
pub mod error;
pub mod modding;
pub mod models;
pub mod routes;
pub mod seed;
pub mod services;
use anyhow::Result;
use axum::Router;
/// Build the full Axum application with a provided pool and data directory.
/// Used by tests to create in-process app instances.
pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
let cfg = config::Config {
listen_addr: "127.0.0.1:0".into(),
database_url: "sqlite::memory:".into(),
data_dir: data_dir.to_string(),
max_connections: 1,
};
app::build(pool, cfg).await
}
+33
View File
@@ -0,0 +1,33 @@
use anyhow::Result;
use openfut_core::{config, db, seed};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let cfg = config::Config::from_env()?;
info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url).await?;
db::run_migrations(&pool).await?;
seed::maybe_seed(&pool).await?;
let app = openfut_core::app::build(pool, cfg.clone()).await?;
let listener = tokio::net::TcpListener::bind(&cfg.listen_addr).await?;
info!("Listening on http://{}", cfg.listen_addr);
axum::serve(listener, app).await?;
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use std::path::Path;
#[allow(dead_code)]
/// Generic loader for JSON arrays from a directory.
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
let mut items = Vec::new();
if !dir.exists() {
return Ok(items);
}
for entry in std::fs::read_dir(dir).with_context(|| format!("reading 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<T> =
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
items.extend(batch);
}
}
Ok(items)
}
+4
View File
@@ -0,0 +1,4 @@
//! Modding support: load JSON data files from the data/ directory.
//! All game content (cards, packs, objectives, SBCs) is data-driven.
pub mod loader;
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rarity {
#[default]
Bronze,
Silver,
Gold,
RareGold,
Totw,
Hero,
Icon,
}
/// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition {
pub id: String,
pub name: String,
pub overall: u8,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub pace: u8,
pub shooting: u8,
pub passing: u8,
pub dribbling: u8,
pub defending: u8,
pub physical: u8,
pub rarity: Rarity,
pub image_path: Option<String>,
}
/// A card instance owned by a club (stored in DB).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct OwnedCard {
pub id: String,
pub club_id: String,
pub card_id: String,
pub is_loan: bool,
pub loan_matches_remaining: Option<i64>,
pub acquired_at: String,
}
+29
View File
@@ -0,0 +1,29 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Club {
pub id: String,
pub profile_id: String,
pub name: String,
pub coins: i64,
pub level: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Club {
pub fn new(profile_id: impl Into<String>, name: impl Into<String>, coins: i64) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
profile_id: profile_id.into(),
name: name.into(),
coins,
level: 1,
created_at: now,
updated_at: now,
}
}
}
+48
View File
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// NPC transfer market listing
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MarketListing {
pub id: String,
pub card_id: String,
pub seller_name: String,
pub price: i64,
pub listed_at: String,
pub expires_at: String,
pub sold: bool,
}
impl MarketListing {
pub fn new(card_id: impl Into<String>, seller_name: impl Into<String>, price: i64) -> Self {
let now = chrono::Utc::now();
let expires = now + chrono::Duration::hours(24);
Self {
id: Uuid::new_v4().to_string(),
card_id: card_id.into(),
seller_name: seller_name.into(),
price,
listed_at: now.to_rfc3339(),
expires_at: expires.to_rfc3339(),
sold: false,
}
}
}
#[derive(Debug, Deserialize)]
pub struct BuyListingRequest {
pub listing_id: String,
}
#[derive(Debug, Deserialize)]
pub struct SellCardRequest {
pub owned_card_id: String,
pub price: i64,
}
#[derive(Debug, Serialize)]
pub struct MarketListingWithCard {
#[serde(flatten)]
pub listing: MarketListing,
pub card: crate::models::card::CardDefinition,
}
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MatchOutcome {
Win,
Draw,
Loss,
}
#[derive(Debug, Deserialize)]
pub struct SubmitMatchRequest {
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Match {
pub id: String,
pub profile_id: String,
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub outcome: String,
pub coins_awarded: i64,
pub xp_awarded: i64,
pub mode: String,
pub played_at: String,
}
impl Match {
#[allow(clippy::too_many_arguments)]
pub fn new(
profile_id: &str,
squad_id: &str,
opponent_name: &str,
goals_for: i64,
goals_against: i64,
mode: &str,
coins_awarded: i64,
xp_awarded: i64,
) -> Self {
let outcome = if goals_for > goals_against {
"win"
} else if goals_for == goals_against {
"draw"
} else {
"loss"
};
Self {
id: Uuid::new_v4().to_string(),
profile_id: profile_id.to_string(),
squad_id: squad_id.to_string(),
opponent_name: opponent_name.to_string(),
goals_for,
goals_against,
outcome: outcome.to_string(),
coins_awarded,
xp_awarded,
mode: mode.to_string(),
played_at: chrono::Utc::now().to_rfc3339(),
}
}
}
#[derive(Debug, Serialize)]
pub struct MatchRewardResult {
pub match_record: Match,
pub coins_awarded: i64,
pub xp_awarded: i64,
pub objectives_updated: Vec<String>,
}
+11
View File
@@ -0,0 +1,11 @@
pub mod card;
pub mod club;
pub mod market;
pub mod match_result;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod reward;
pub mod sbc;
pub mod squad;
pub mod statistics;
+57
View File
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObjectiveType {
Daily,
Weekly,
Lifetime,
Milestone,
Season,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObjectiveMetric {
MatchesWon,
MatchesPlayed,
GoalsScored,
PacksOpened,
SbcsCompleted,
CoinsEarned,
}
/// Objective definition from data/objectives/*.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectiveDefinition {
pub id: String,
pub title: String,
pub description: String,
pub objective_type: ObjectiveType,
pub metric: ObjectiveMetric,
pub target: i64,
pub reward_coins: i64,
pub reward_pack_id: Option<String>,
pub reward_xp: i64,
}
/// Progress row in DB
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ObjectiveProgress {
pub id: String,
pub profile_id: String,
pub objective_id: String,
pub current: i64,
pub completed: bool,
pub claimed: bool,
pub updated_at: String,
}
#[derive(Debug, Serialize)]
pub struct ObjectiveWithProgress {
#[serde(flatten)]
pub definition: ObjectiveDefinition,
pub current: i64,
pub completed: bool,
pub claimed: bool,
}
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
/// Pack definition loaded from data/packs/*.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackDefinition {
pub id: String,
pub name: String,
pub description: String,
pub cost_coins: i64,
pub slots: Vec<PackSlot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackSlot {
pub count: u8,
/// Minimum overall rating filter
pub min_overall: Option<u8>,
/// Rarity filter: "bronze", "silver", "gold", "rare_gold", "totw", "hero", "icon"
pub rarity_filter: Option<Vec<String>>,
/// Whether this slot guarantees rare
pub guaranteed_rare: bool,
}
/// A pack instance stored in the DB (assigned but unopened).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Pack {
pub id: String,
pub club_id: String,
pub definition_id: String,
pub opened: bool,
pub created_at: String,
}
/// The result of opening a pack.
#[derive(Debug, Serialize)]
pub struct PackOpenResult {
pub pack_id: String,
pub cards: Vec<crate::models::card::CardDefinition>,
}
+32
View File
@@ -0,0 +1,32 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Profile {
pub id: String,
pub username: String,
pub level: i64,
pub xp: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Profile {
pub fn new(username: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
username: username.into(),
level: 1,
xp: 0,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateProfileRequest {
pub username: Option<String>,
}
+24
View File
@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reward {
pub coins: i64,
pub xp: i64,
pub pack_id: Option<String>,
}
#[allow(dead_code)]
impl Reward {
pub fn coins_only(coins: i64) -> Self {
Self {
coins,
xp: 0,
pack_id: None,
}
}
pub fn full(coins: i64, xp: i64, pack_id: Option<String>) -> Self {
Self { coins, xp, pack_id }
}
}
+59
View File
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// SBC definition from data/sbcs/*.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbcDefinition {
pub id: String,
pub name: String,
pub description: String,
pub requirements: SbcRequirements,
pub reward: SbcReward,
pub expires_at: Option<String>,
pub repeatable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbcRequirements {
pub squad_size: u8,
pub min_overall: Option<u8>,
pub max_overall: Option<u8>,
pub min_chemistry: Option<u8>,
pub required_leagues: Vec<String>,
pub required_nations: Vec<String>,
pub required_clubs: Vec<String>,
pub min_players_from_same_league: Option<u8>,
pub min_players_from_same_nation: Option<u8>,
pub min_players_from_same_club: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbcReward {
pub coins: i64,
pub xp: i64,
pub pack_id: Option<String>,
}
/// DB record of a completed submission
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SbcSubmission {
pub id: String,
pub profile_id: String,
pub sbc_id: String,
pub submitted_card_ids: String,
pub passed: bool,
pub submitted_at: String,
}
#[derive(Debug, Deserialize)]
pub struct SubmitSbcRequest {
pub sbc_id: String,
pub owned_card_ids: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct SbcResult {
pub passed: bool,
pub failures: Vec<String>,
pub reward: Option<SbcReward>,
}
+55
View File
@@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Squad {
pub id: String,
pub club_id: String,
pub name: String,
pub formation: String,
pub created_at: String,
pub updated_at: String,
}
impl Squad {
pub fn new(
club_id: impl Into<String>,
name: impl Into<String>,
formation: impl Into<String>,
) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id: Uuid::new_v4().to_string(),
club_id: club_id.into(),
name: name.into(),
formation: formation.into(),
created_at: now.clone(),
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SquadPlayer {
pub id: String,
pub squad_id: String,
pub owned_card_id: String,
pub position_index: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
#[derive(Debug, Deserialize)]
pub struct SaveSquadRequest {
pub name: Option<String>,
pub formation: Option<String>,
pub players: Vec<SquadPlayerInput>,
}
#[derive(Debug, Deserialize)]
pub struct SquadPlayerInput {
pub owned_card_id: String,
pub position_index: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
+34
View File
@@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Statistics {
pub profile_id: String,
pub matches_played: i64,
pub matches_won: i64,
pub matches_drawn: i64,
pub matches_lost: i64,
pub goals_scored: i64,
pub goals_conceded: i64,
pub packs_opened: i64,
pub sbcs_completed: i64,
pub total_coins_earned: i64,
pub updated_at: String,
}
impl Statistics {
pub fn new(profile_id: impl Into<String>) -> Self {
Self {
profile_id: profile_id.into(),
matches_played: 0,
matches_won: 0,
matches_drawn: 0,
matches_lost: 0,
goals_scored: 0,
goals_conceded: 0,
packs_opened: 0,
sbcs_completed: 0,
total_coins_earned: 0,
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
}
+30
View File
@@ -0,0 +1,30 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::{club::Club, profile::CreateProfileRequest},
seed,
services::{club as club_svc, profile as profile_svc},
};
pub async fn post_auth_local(
State(state): State<AppState>,
Json(req): Json<CreateProfileRequest>,
) -> AppResult<Json<Value>> {
let username = req.username.unwrap_or_else(|| "Player 1".into());
let profile = profile_svc::create_profile(&state.pool, &username).await?;
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
club_svc::create_club(&state.pool, &club).await?;
seed::grant_starter_pack(&state.pool, &club.id, &state.pack_defs).await?;
Ok(Json(json!({
"profile": profile,
"club": club,
"message": "Welcome to OpenFUT FC! Your club has been created."
})))
}
+75
View File
@@ -0,0 +1,75 @@
use axum::{
extract::{Query, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::card::OwnedCard,
services::{club as club_svc, profile as profile_svc},
};
#[derive(Debug, Deserialize)]
pub struct CardQuery {
pub rarity: Option<String>,
pub position: Option<String>,
}
pub async fn get_cards(
State(state): State<AppState>,
Query(query): Query<CardQuery>,
) -> AppResult<Json<Value>> {
let cards = state
.card_db
.all()
.into_iter()
.filter(|c| {
query
.rarity
.as_ref()
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
.unwrap_or(true)
&& query
.position
.as_ref()
.map(|p| c.position.to_lowercase() == p.to_lowercase())
.unwrap_or(true)
})
.collect::<Vec<_>>();
Ok(Json(json!({ "cards": cards, "total": cards.len() })))
}
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE club_id = ?"
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
let with_defs: Vec<Value> = owned
.iter()
.filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| {
json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
"acquired_at": o.acquired_at,
"card": def,
})
})
})
.collect();
Ok(Json(
json!({ "collection": with_defs, "total": with_defs.len() }),
))
}
+13
View File
@@ -0,0 +1,13 @@
use crate::{
app::AppState,
error::AppResult,
models::club::Club,
services::{club as club_svc, profile as profile_svc},
};
use axum::{extract::State, Json};
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club))
}
+13
View File
@@ -0,0 +1,13 @@
use axum::{http::StatusCode, Json};
use serde_json::{json, Value};
pub async fn get_health() -> (StatusCode, Json<Value>) {
(
StatusCode::OK,
Json(json!({
"status": "ok",
"service": "openfut-core",
"version": env!("CARGO_PKG_VERSION")
})),
)
}
+47
View File
@@ -0,0 +1,47 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::market::{BuyListingRequest, SellCardRequest},
services::{club as club_svc, market as market_svc, profile as profile_svc},
};
pub async fn get_market(State(state): State<AppState>) -> AppResult<Json<Value>> {
let listings = market_svc::get_active_listings(&state.pool, &state.card_db).await?;
Ok(Json(
json!({ "listings": listings, "total": listings.len() }),
))
}
pub async fn post_market_buy(
State(state): State<AppState>,
Json(req): Json<BuyListingRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
Ok(Json(
json!({ "purchased_card": card, "message": "Card purchased successfully" }),
))
}
pub async fn post_market_sell(
State(state): State<AppState>,
Json(req): Json<SellCardRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let new_balance = market_svc::sell_card(&state.pool, &club.id, &req).await?;
Ok(Json(
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
))
}
pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count = market_svc::refresh_npc_listings(&state.pool, &state.card_db).await?;
Ok(Json(json!({ "listings_generated": count })))
}
+22
View File
@@ -0,0 +1,22 @@
use axum::{extract::State, Json};
use crate::{
app::AppState,
error::AppResult,
models::match_result::{MatchRewardResult, SubmitMatchRequest},
services::{club as club_svc, match_service, profile as profile_svc},
};
pub async fn post_match_result(
State(state): State<AppState>,
Json(req): Json<SubmitMatchRequest>,
) -> AppResult<Json<MatchRewardResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result =
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs)
.await?;
Ok(Json(result))
}
+12
View File
@@ -0,0 +1,12 @@
pub mod auth;
pub mod cards;
pub mod club;
pub mod health;
pub mod market;
pub mod matches;
pub mod objectives;
pub mod packs;
pub mod profile;
pub mod sbc;
pub mod squad;
pub mod statistics;
+15
View File
@@ -0,0 +1,15 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{objective as obj_svc, profile as profile_svc},
};
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
Ok(Json(json!({ "objectives": objectives })))
}
+57
View File
@@ -0,0 +1,57 @@
use axum::{
extract::{Path, State},
Json,
};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::pack::PackOpenResult,
services::{club as club_svc, objective, pack as pack_svc, profile as profile_svc, statistics},
};
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
let with_defs: Vec<Value> = packs
.iter()
.map(|p| {
let def = state.pack_defs.iter().find(|d| d.id == p.definition_id);
json!({
"pack_id": p.id,
"definition_id": p.definition_id,
"name": def.map(|d| &d.name),
"description": def.map(|d| &d.description),
"created_at": p.created_at,
})
})
.collect();
Ok(Json(json!({ "packs": with_defs })))
}
pub async fn post_open_pack(
State(state): State<AppState>,
Path(pack_id): Path<String>,
) -> AppResult<Json<PackOpenResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = pack_svc::open_pack(
&state.pool,
&state.card_db,
&state.pack_defs,
&club.id,
&pack_id,
)
.await?;
statistics::increment_packs_opened(&state.pool, &profile.id).await?;
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?;
Ok(Json(result))
}
+9
View File
@@ -0,0 +1,9 @@
use crate::{
app::AppState, error::AppResult, models::profile::Profile, services::profile as profile_svc,
};
use axum::{extract::State, Json};
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Profile>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
Ok(Json(profile))
}
+34
View File
@@ -0,0 +1,34 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::sbc::{SbcResult, SubmitSbcRequest},
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
};
pub async fn get_sbcs(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!({ "sbcs": state.sbc_defs })))
}
pub async fn post_sbc_submit(
State(state): State<AppState>,
Json(req): Json<SubmitSbcRequest>,
) -> AppResult<Json<SbcResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = sbc_svc::submit_sbc(
&state.pool,
&state.card_db,
&state.sbc_defs,
&state.obj_defs,
&profile.id,
&club.id,
&req,
)
.await?;
Ok(Json(result))
}
+49
View File
@@ -0,0 +1,49 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::squad::SaveSquadRequest,
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
};
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
let enriched: Vec<Value> = players
.iter()
.map(|sp| {
json!({
"squad_player_id": sp.id,
"owned_card_id": sp.owned_card_id,
"position_index": sp.position_index,
"is_captain": sp.is_captain,
"is_on_bench": sp.is_on_bench,
})
})
.collect();
Ok(Json(json!({
"squad": {
"id": squad.id,
"name": squad.name,
"formation": squad.formation,
},
"players": enriched,
})))
}
pub async fn post_squad(
State(state): State<AppState>,
Json(req): Json<SaveSquadRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
Ok(Json(json!({ "squad": squad })))
}
+14
View File
@@ -0,0 +1,14 @@
use axum::{extract::State, Json};
use crate::{
app::AppState,
error::AppResult,
models::statistics::Statistics,
services::{profile as profile_svc, statistics as stats_svc},
};
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Statistics>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
Ok(Json(stats))
}
+31
View File
@@ -0,0 +1,31 @@
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc};
use tracing::info;
/// Seeds the market with NPC listings if empty.
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
// Any one-time startup seeds go here.
// Currently we just ensure the market gets listings on first run.
Ok(())
}
/// Grants the starter pack to a newly created club.
pub async fn grant_starter_pack(
pool: &Pool,
club_id: &str,
pack_defs: &[PackDefinition],
) -> AppResult<()> {
// Look for the gold starter pack first; fall back to the first available definition.
let starter_def = pack_defs
.iter()
.find(|p| p.id == "gold_pack")
.or_else(|| pack_defs.first());
if let Some(def) = starter_def {
info!("Granting starter pack '{}' to club {}", def.id, club_id);
pack_svc::grant_pack(pool, club_id, &def.id).await?;
} else {
tracing::warn!("No pack definitions loaded; skipping starter pack grant");
}
Ok(())
}
+63
View File
@@ -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()
}
}
+71
View File
@@ -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)
}
+158
View File
@@ -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,
}
}
+106
View File
@@ -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,
})
}
+10
View File
@@ -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;
+122
View File
@@ -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)
}
+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)
}
+52
View File
@@ -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(())
}
+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)
}
+93
View File
@@ -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)
}
+90
View File
@@ -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(())
}