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