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
+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(),
}
}
}