wip(core): preserve local broad Core refactor + inventory service

Divergent development line off origin/main (11a811d): a broad refactor across
routes/services/models/app + a large integration_test expansion (+1000), plus an
untracked game-independent inventory query service and Docker files. Preserved
verbatim before moving the canonical Core checkout to the committed migration
trunk (66c88fb). Reconciling this refactor with the migration trunk is a separate
user decision; nothing here is lost.
This commit is contained in:
OpenFUT Agent
2026-08-13 18:34:00 +00:00
parent eab522a1eb
commit f70cf4415c
34 changed files with 1633 additions and 416 deletions
+12
View File
@@ -0,0 +1,12 @@
target/
**/target/
*.db
*.db-shm
*.db-wal
.env
.env.local
Dockerfile
.dockerignore
.git
.gitignore
openfut.db
+1 -1
View File
@@ -90,4 +90,4 @@ migrations/ SQLite migration SQL files
- **No copyrighted assets.** All card data in `data/` must be original.
- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints.
- **Game-independent core.** `openfut-core` must stay game-agnostic;
FIFA-specific logic belongs in `openfut-bridge`.
FIFA-specific logic belongs in the emulation layer (`fifa17-recon/`).
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
authors = ["OpenFUT Contributors"]
description = "Offline Ultimate Team backend — game-independent core"
license = "MIT"
repository = "https://github.com/openfut/openfut-core"
repository = "https://git.aleshym.co/funman300/OpenFUT-Core.git"
[lib]
name = "openfut_core"
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# ---- OpenFUT Core: offline FUT backend (Axum + bundled SQLite) ----
# Multi-stage: build with the Rust toolchain, ship a slim Debian runtime.
# rustls + bundled SQLite mean no OpenSSL/system-sqlite at runtime.
FROM rust:1-bookworm AS builder
WORKDIR /build
# Cache dependency compilation: copy manifests first, build a stub, then the
# real sources. sqlx migrations are compiled in via sqlx::migrate!, so the
# migrations/ dir must be present at build time.
COPY Cargo.toml Cargo.lock* ./
RUN mkdir -p src \
&& echo 'fn main() {}' > src/main.rs \
&& echo '' > src/lib.rs \
&& cargo build --release --bin openfut-core 2>/dev/null || true
RUN rm -rf src
COPY src ./src
COPY migrations ./migrations
# Touch so cargo rebuilds against the real sources rather than the stub.
RUN touch src/main.rs src/lib.rs \
&& cargo build --release --bin openfut-core
# ---- Runtime ----
FROM debian:bookworm-slim AS runtime
# curl is used by the compose/Docker healthcheck to hit /health.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Run unprivileged.
RUN useradd --system --uid 10001 --create-home --home-dir /app openfut
WORKDIR /app
COPY --from=builder /build/target/release/openfut-core /usr/local/bin/openfut-core
# data/ is read at runtime from DATA_DIR (moddable JSON content) — bundle it.
COPY --chown=openfut:openfut data ./data
# Persist the SQLite database on a named volume.
RUN mkdir -p /app/db && chown openfut:openfut /app/db
USER openfut
ENV LISTEN_ADDR=0.0.0.0:8080 \
DATABASE_URL=sqlite:///app/db/openfut.db \
DATA_DIR=/app/data \
RUST_LOG=openfut_core=info,tower_http=info
EXPOSE 8080
VOLUME ["/app/db"]
HEALTHCHECK --interval=15s --timeout=4s --start-period=10s --retries=5 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["openfut-core"]
+5 -1
View File
@@ -2,7 +2,11 @@
**Offline Ultimate Team backend — game-independent.**
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend written in Rust. It is deliberately decoupled from any specific game, though it is designed to power a FIFA 23 offline experience.
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend
written in Rust. It is deliberately decoupled from any specific game; it is designed to power the
offline FUT economy behind the project's FIFA 17 emulation layer (and, eventually, a FIFA 23 port).
The emulation layer and Core are **not yet wired together** — see the OpenFUT Vault
(`../OpenFUT-Vault/`) for canonical project state.
---
+36 -9
View File
@@ -172,7 +172,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
.route("/objectives", get(routes::objectives::get_objectives))
.route("/objectives/:objective_id", get(routes::objectives::get_objective))
.route(
"/objectives/:objective_id",
get(routes::objectives::get_objective),
)
.route(
"/objectives/claim",
post(routes::objectives::post_claim_objective),
@@ -190,7 +193,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.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/trade-history", get(routes::market::get_trade_history))
.route(
"/market/trade-history",
get(routes::market::get_trade_history),
)
.route("/market/refresh", post(routes::market::post_market_refresh))
.route("/market/my-listings", get(routes::market::get_my_listings))
.route(
@@ -205,15 +211,36 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", get(routes::settings::get_settings))
.route("/settings", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division))
.route("/division/history", get(routes::division::get_division_history))
.route("/division/leaderboard", get(routes::division::get_division_leaderboard))
.route(
"/division/history",
get(routes::division::get_division_history),
)
.route(
"/division/leaderboard",
get(routes::division::get_division_leaderboard),
)
.route("/achievements", get(routes::achievements::get_achievements))
.route("/notifications", get(routes::notifications::get_notifications))
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
.route(
"/notifications",
get(routes::notifications::get_notifications),
)
.route(
"/notifications/read-all",
post(routes::notifications::mark_all_notifications_read),
)
.route(
"/notifications/:id/read",
patch(routes::notifications::mark_notification_read),
)
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
.route(
"/fut-champs/start",
post(routes::fut_champs::post_start_fut_champs),
)
.route(
"/fut-champs/history",
get(routes::fut_champs::get_champs_history),
)
.route(
"/fut-champs/:session_id/result",
post(routes::fut_champs::post_champs_result),
+27
View File
@@ -13,6 +13,33 @@ pub enum Rarity {
Icon,
}
/// Visual card quality tier (gold/silver/bronze).
///
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base
/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65,
/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Quality {
Bronze,
Silver,
Gold,
}
impl Quality {
/// Classify a base overall rating into its quality tier.
pub fn from_overall(overall: u8) -> Self {
if overall >= 75 {
Quality::Gold
} else if overall >= 65 {
Quality::Silver
} else {
Quality::Bronze
}
}
}
/// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition {
+3 -3
View File
@@ -1,18 +1,18 @@
pub mod achievement;
pub mod card;
pub mod chemistry_style;
pub mod notification;
pub mod club;
pub mod draft;
pub mod fut_champs;
pub mod event;
pub mod season;
pub mod fut_champs;
pub mod market;
pub mod match_result;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod reward;
pub mod sbc;
pub mod season;
pub mod squad;
pub mod statistics;
+6 -2
View File
@@ -10,9 +10,13 @@ use crate::{
pub async fn get_achievements(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 _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id)
.await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
let earned = achievements
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.count();
Ok(Json(json!({
"achievements": achievements,
"earned": earned,
+49 -20
View File
@@ -9,16 +9,26 @@ use crate::{
app::AppState,
error::{AppError, AppResult},
models::card::OwnedCard,
services::{club as club_svc, profile as profile_svc},
services::{
club as club_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
};
/// Quick-sell value for a card based on overall rating.
fn quick_sell_coins(overall: u8) -> i64 {
if overall >= 85 { 1500 }
else if overall >= 80 { 900 }
else if overall >= 75 { 600 }
else if overall >= 65 { 300 }
else { 150 }
if overall >= 85 {
1500
} else if overall >= 80 {
900
} else if overall >= 75 {
600
} else if overall >= 65 {
300
} else {
150
}
}
#[derive(Debug, Deserialize)]
@@ -90,10 +100,15 @@ pub async fn get_cards(
cards.truncate(limit);
}
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
Ok(Json(
json!({ "cards": cards, "total": total, "returned": cards.len() }),
))
}
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
pub async fn get_collection(
State(state): State<AppState>,
Query(query): Query<OwnedItemQuery>,
) -> 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?;
@@ -104,14 +119,13 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
.fetch_all(&state.pool)
.await?;
let with_defs: Vec<Value> = owned
let views: Vec<OwnedItemView> = owned
.iter()
.filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position =
o.position_override.as_deref().unwrap_or(&def.position);
json!({
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
let body = json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
@@ -122,14 +136,30 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def,
})
});
OwnedItemView {
owned_card_id: o.id.clone(),
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
}
})
})
.collect();
Ok(Json(
json!({ "collection": with_defs, "total": with_defs.len() }),
))
let page = inventory::apply_query(views, &query);
let returned = page.items.len();
Ok(Json(json!({
"collection": page.items,
"total": page.total,
"returned": returned,
"offset": page.offset,
"limit": page.limit,
})))
}
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
@@ -151,10 +181,9 @@ pub async fn delete_owned_card(
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
let card = state
.card_db
.get(&owned.card_id)
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
})?;
let coins = quick_sell_coins(card.overall);
+10 -13
View File
@@ -2,7 +2,9 @@ use crate::{
app::AppState,
error::AppResult,
models::club::Club,
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
services::{
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
},
};
use axum::{extract::State, Json};
use serde::Deserialize;
@@ -65,9 +67,8 @@ pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Val
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
let seasons_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
)
let seasons_completed: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
@@ -81,25 +82,21 @@ pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Val
.await
.unwrap_or(10);
let cards_owned: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let sbcs_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
)
let sbcs_completed: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
)
let total_checkins: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
+25 -7
View File
@@ -52,11 +52,26 @@ pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResul
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
const NPC_NAMES: &[&str] = &[
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
"Riverside FC",
"City Athletic",
"County United",
"Valley Rangers",
"Harbor Town FC",
"Mountside City",
"Lakewood Athletic",
"Eastbrook United",
"Westfield Rovers",
"Northgate FC",
"Southport Athletic",
"Ironbridge City",
"Milldale United",
"Hillcrest Rangers",
"Bayside FC",
"Thornfield Athletic",
"Greenhill United",
"Coldwater City",
"Redbury Rangers",
"Ashdown FC",
];
// Pick 9 NPC names without repetition using the seeded RNG
@@ -74,8 +89,11 @@ pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResul
// Quality bias: index 0-2 = stronger, 6-8 = weaker
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
let expected_win_rate = 0.2 + quality * 0.6; // 0.20.8
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let wins =
(npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64
* (1.0 - expected_win_rate)
* (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
json!({
+4 -2
View File
@@ -43,7 +43,8 @@ pub async fn post_draft_start(
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
let session =
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session))
}
@@ -53,7 +54,8 @@ pub async fn get_draft_session(
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
let session =
draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session))
}
+5 -7
View File
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc},
services::{
club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc,
},
};
/// GET /fut-champs — current active session, or null if none.
@@ -111,12 +113,8 @@ pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResul
// Ensure a season row exists
season_svc::get_or_create(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rivals_reward(
&state.pool,
&profile.id,
&club.id,
&state.pack_defs,
)
let result =
champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
.await?;
Ok(Json(result))
+5 -3
View File
@@ -19,7 +19,6 @@ pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
}
#[derive(Deserialize)]
pub struct MarketQuery {
pub min_overall: Option<u8>,
@@ -86,8 +85,11 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
pub async fn get_my_listings(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 listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
let listings =
market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(
json!({ "listings": listings, "total": listings.len() }),
))
}
/// Cancel a player-posted listing and return the card to the collection.
+8 -2
View File
@@ -68,8 +68,14 @@ pub async fn post_match_result(
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, &state.achievement_defs)
let result = match_service::process_match(
&state.pool,
&profile.id,
&club.id,
&req,
&state.obj_defs,
&state.achievement_defs,
)
.await?;
Ok(Json(result))
+1 -1
View File
@@ -4,8 +4,8 @@ pub mod cards;
pub mod club;
pub mod division;
pub mod draft;
pub mod fut_champs;
pub mod events;
pub mod fut_champs;
pub mod health;
pub mod market;
pub mod matches;
+8 -6
View File
@@ -7,7 +7,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
services::{
club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc,
},
};
/// GET /notifications
@@ -92,14 +94,16 @@ pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<
// Use "type" key for compatibility with dashboard and existing tests.
let all: Vec<Value> = persistent
.iter()
.map(|n| json!({
.map(|n| {
json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
}))
})
})
.chain(dynamic.iter().cloned())
.collect();
@@ -124,9 +128,7 @@ pub async fn mark_notification_read(
}
/// POST /notifications/read-all
pub async fn mark_all_notifications_read(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count = notif_svc::mark_all_read(&state.pool).await?;
Ok(Json(json!({ "marked_read": count })))
}
+6 -2
View File
@@ -140,8 +140,12 @@ pub async fn post_open_pack(
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?;
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
&state.pool,
&state.achievement_defs,
&profile.id,
&club.id,
)
.await;
Ok(Json(result))
}
+6 -2
View File
@@ -47,8 +47,12 @@ pub async fn post_sbc_submit(
if result.passed {
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
&state.pool,
&state.achievement_defs,
&profile.id,
&club.id,
)
.await;
}
Ok(Json(result))
+2 -7
View File
@@ -69,13 +69,8 @@ pub async fn post_change_position(
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::change_position(
&state.pool,
&club.id,
&owned_card_id,
&req.position,
)
.await?;
let updated =
upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
let card_def = state.card_db.get(&updated.card_id);
+41 -34
View File
@@ -29,58 +29,62 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
}
/// Query the current value for the given trigger metric.
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> {
let v: i64 = match trigger {
"matches_played" => sqlx::query_scalar(
"SELECT matches_played FROM statistics WHERE profile_id = ?",
)
async fn metric_value(
pool: &Pool,
profile_id: &str,
club_id: &str,
trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
.unwrap_or(0)
}
"matches_won" => sqlx::query_scalar(
"SELECT matches_won FROM statistics WHERE profile_id = ?",
)
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
.unwrap_or(0)
}
"goals_scored" => sqlx::query_scalar(
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
)
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
.unwrap_or(0)
}
"packs_opened" => sqlx::query_scalar(
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
)
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
.unwrap_or(0)
}
"sbcs_completed" => sqlx::query_scalar(
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
)
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
.unwrap_or(0)
}
"cards_owned" => sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(pool)
.await?,
.await?
}
"level" => sqlx::query_scalar(
"SELECT level FROM profiles WHERE id = ?",
)
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
@@ -165,11 +169,14 @@ pub async fn check_and_unlock(
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
}
let body = format!(
"{} Reward: {} coins.",
def.description, def.reward_coins
);
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
let _ = notification::create(
pool,
"achievement",
&format!("Achievement: {}", def.title),
&body,
)
.await;
newly_unlocked.push(def.clone());
}
+10 -7
View File
@@ -1,4 +1,8 @@
use crate::{db::Pool, error::AppResult, services::{club, pack}};
use crate::{
db::Pool,
error::AppResult,
services::{club, pack},
};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
}
}
pub async fn claim(
pool: &Pool,
profile_id: &str,
club_id: &str,
) -> AppResult<CheckinResult> {
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
@@ -82,7 +82,10 @@ pub async fn claim(
}
}
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
let last_streak = row
.as_ref()
.map(|(s, last_at)| compute_next_streak(*s, last_at))
.unwrap_or(1);
let idx = ((last_streak - 1) % 7) as usize;
let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
+9 -3
View File
@@ -179,7 +179,14 @@ pub async fn pick_card(
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
if all_filled {
let (coins, pack, avg) = compute_reward(card_db, &picks);
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339()))
(
None,
"completed".to_string(),
coins,
pack,
avg,
Some(chrono::Utc::now().to_rfc3339()),
)
} else {
let min_overall = difficulty_min_overall(&session.difficulty);
let next_pos = &pick_order[next_index];
@@ -284,8 +291,7 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe
}
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
let pick_order: Vec<String> =
serde_json::from_str(&session.pick_order).unwrap_or_default();
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
let candidates: Vec<String> = session
.current_candidates
+5 -1
View File
@@ -26,7 +26,11 @@ pub async fn get_active_session(
.map_err(Into::into)
}
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> {
pub async fn get_session(
pool: &Pool,
session_id: &str,
profile_id: &str,
) -> AppResult<FutChampsSession> {
sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
))
+291
View File
@@ -0,0 +1,291 @@
//! Game-independent owned-inventory query: semantic filtering, deterministic
//! ordering, and offset/limit pagination over a club's owned items.
//!
//! This layer is deliberately free of any game-specific concepts. It never sees
//! raw FIFA (or any other game's) numeric entity ids — a game adapter is
//! responsible for translating its wire query into the *semantic* values here
//! (quality tier, entity **names**, semantic offset/limit). The canonical
//! ordering is imposed by Core so pagination is correct and repeatable
//! regardless of what (if any) sort the client requests; see the module tests
//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN.
//!
//! Order of operations is load-bearing: **filter → order → paginate**. Paginating
//! before filtering is the production bug this replaces (a client that pages an
//! unfiltered/unsorted set re-reads page one forever and amplifies requests).
use serde::Deserialize;
use crate::models::card::Quality;
/// Semantic owned-inventory query. All values are game-independent: a quality
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
/// optional; combined filters are ANDed. Absent field = no constraint.
#[derive(Debug, Default, Deserialize)]
pub struct OwnedItemQuery {
/// Quality tier (gold/silver/bronze). Serialized lowercase.
#[serde(default)]
pub quality: Option<Quality>,
/// Playing position, e.g. "ST" (matched case-insensitively).
#[serde(default)]
pub position: Option<String>,
/// Nation name, e.g. "Argentina" (matched case-insensitively).
#[serde(default)]
pub nation: Option<String>,
/// League name, e.g. "Premier League" (matched case-insensitively).
#[serde(default)]
pub league: Option<String>,
/// Club name, e.g. "Chelsea" (matched case-insensitively).
#[serde(default)]
pub club: Option<String>,
/// Number of leading items to skip after filtering + ordering.
#[serde(default)]
pub offset: Option<i64>,
/// Maximum number of items to return in the page.
#[serde(default)]
pub limit: Option<i64>,
}
/// One owned item projected to the attributes needed for querying, plus the
/// response body to hand back verbatim once it survives the filter+page.
pub struct OwnedItemView {
pub owned_card_id: String,
/// Base card overall (drives quality tier).
pub base_overall: u8,
/// Effective overall (base + training bonus); drives ordering.
pub effective_overall: i64,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub body: serde_json::Value,
}
impl OwnedItemView {
fn quality(&self) -> Quality {
Quality::from_overall(self.base_overall)
}
}
/// Result of applying a query: the requested page plus the count of items that
/// matched the filter **before** pagination (what a client needs to page).
pub struct QueryPage {
pub items: Vec<serde_json::Value>,
pub total: usize,
pub offset: usize,
pub limit: Option<usize>,
}
/// Does an item satisfy every present filter (AND semantics)?
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
let pos_ok = q
.position
.as_ref()
.map(|p| item.position.eq_ignore_ascii_case(p))
.unwrap_or(true);
let nation_ok = q
.nation
.as_ref()
.map(|n| item.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = q
.league
.as_ref()
.map(|l| item.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = q
.club
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
///
/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so
/// pages never overlap or repeat. `offset`/`limit` are clamped to sane
/// non-negative values (the wire never sends negatives; clamping keeps a
/// malformed request from panicking).
pub fn apply_query(mut items: Vec<OwnedItemView>, q: &OwnedItemQuery) -> QueryPage {
// 1. filter
items.retain(|it| matches(it, q));
let total = items.len();
// 2. deterministic total order (independent of input/DB order)
items.sort_by(|a, b| {
b.effective_overall
.cmp(&a.effective_overall)
.then_with(|| a.owned_card_id.cmp(&b.owned_card_id))
});
// 3. paginate
let offset = q.offset.unwrap_or(0).max(0) as usize;
let limit = q.limit.map(|l| l.max(0) as usize);
let page: Vec<serde_json::Value> = items
.into_iter()
.skip(offset)
.take(limit.unwrap_or(usize::MAX))
.map(|it| it.body)
.collect();
QueryPage {
items: page,
total,
offset,
limit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn view(
id: &str,
overall: u8,
position: &str,
nation: &str,
league: &str,
club: &str,
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
nation: nation.to_string(),
league: league.to_string(),
club: club.to_string(),
body: json!({ "owned_card_id": id, "overall": overall }),
}
}
fn ids(page: &QueryPage) -> Vec<String> {
page.items
.iter()
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
.collect()
}
fn fixture() -> Vec<OwnedItemView> {
vec![
view("a", 84, "ST", "England", "Premier League", "Northgate"),
view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"),
view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"),
view("d", 60, "CM", "Italy", "Serie B", "Modena"),
view("e", 89, "LW", "Argentina", "Primera Division", "Boca"),
]
}
#[test]
fn no_filter_returns_all_in_overall_desc_order() {
let p = apply_query(fixture(), &OwnedItemQuery::default());
assert_eq!(p.total, 5);
assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60
}
#[test]
fn quality_gold_selects_overall_75_plus() {
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["e", "b", "a"]);
}
#[test]
fn filters_are_anded() {
let q = OwnedItemQuery {
league: Some("Premier League".into()),
position: Some("ST".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM
}
#[test]
fn case_insensitive_name_match() {
let q = OwnedItemQuery {
club: Some("chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn empty_when_nothing_matches() {
let q = OwnedItemQuery {
nation: Some("Argentina".into()),
club: Some("Chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 0);
assert!(p.items.is_empty());
}
#[test]
fn filter_runs_before_pagination() {
// Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b].
// If pagination ran first, offset/limit would slice the full 5-item set.
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
offset: Some(1),
limit: Some(1),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 3, "total is the filtered count, not the page size");
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn pages_do_not_overlap_and_advance() {
let page = |off| {
apply_query(
fixture(),
&OwnedItemQuery {
offset: Some(off),
limit: Some(2),
..Default::default()
},
)
};
let p0 = page(0);
let p1 = page(2);
assert_eq!(ids(&p0), ["e", "b"]);
assert_eq!(ids(&p1), ["a", "c"]);
// start advancing must NOT re-serve page one
assert_ne!(ids(&p0), ids(&p1));
assert_eq!(p0.total, 5);
assert_eq!(p1.total, 5);
}
#[test]
fn offset_past_end_is_empty_not_wrapped() {
let q = OwnedItemQuery {
offset: Some(100),
limit: Some(11),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert!(p.items.is_empty());
assert_eq!(p.total, 5);
}
#[test]
fn ordering_is_stable_on_overall_ties() {
let items = vec![
view("z", 80, "ST", "N", "L", "C"),
view("a", 80, "ST", "N", "L", "C"),
view("m", 80, "ST", "N", "L", "C"),
];
let p = apply_query(items, &OwnedItemQuery::default());
assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc
}
}
+7 -4
View File
@@ -285,9 +285,10 @@ pub async fn get_listings_by_seller(
let with_cards = listings
.into_iter()
.filter_map(|l| {
card_db
.get(&l.card_id)
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
listing: l,
card: card.clone(),
})
})
.collect();
Ok(with_cards)
@@ -303,7 +304,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?;
.ok_or_else(|| {
AppError::NotFound(format!("listing '{listing_id}' not found or already sold"))
})?;
sqlx::query("DELETE FROM market_listings WHERE id = ?")
.bind(listing_id)
+71 -18
View File
@@ -7,13 +7,14 @@ use crate::{
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
objective::ObjectiveDefinition,
},
services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
services::{
achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc,
statistics,
},
};
use rand::{seq::SliceRandom, Rng};
const FORMATIONS: &[&str] = &[
"4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2",
];
const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"];
/// Generate a random AI opponent squad for Squad Battles.
///
@@ -27,23 +28,53 @@ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Valu
let (min_overall, names): (u8, &[&str]) = match difficulty {
"professional" => (
70,
&["Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC"],
&[
"Athletic CF",
"City Wanderers",
"The Rovers",
"United Select",
"Blue Stars FC",
],
),
"world_class" => (
78,
&["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"],
&[
"Elite Stars FC",
"Champions Select",
"Premier XI",
"Galaxy United",
"Titan FC",
],
),
"legendary" => (
85,
&["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"],
&[
"Legends United",
"Ultimate XI",
"Gold Standard FC",
"The Icons",
"Heritage FC",
],
),
"ultimate" => (
90,
&["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
&[
"Apex XI",
"Pantheon FC",
"Gods of FUT",
"Invincibles Select",
"Eternal XI",
],
),
_ => (
55,
&["Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI"],
&[
"Amateur Town FC",
"Sunday League XI",
"Park FC",
"Village Stars",
"Reserve XI",
],
),
};
@@ -59,7 +90,11 @@ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Valu
let mut indices: Vec<usize> = (0..pool.len()).collect();
indices.shuffle(&mut rng);
let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect();
let cards: Vec<_> = indices
.into_iter()
.take(11)
.map(|i| pool[i].clone())
.collect();
let squad_rating = if cards.is_empty() {
0
@@ -141,11 +176,18 @@ pub async fn process_match(
for ev in &level_ups {
let body = if let Some(ref pack) = ev.pack_granted {
format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted)
format!(
"You reached level {}! Reward: {} coins + {pack}.",
ev.new_level, ev.coins_granted
)
} else {
format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted)
format!(
"You reached level {}! Reward: {} coins.",
ev.new_level, ev.coins_granted
)
};
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await;
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body)
.await;
}
statistics::record_match(
@@ -185,7 +227,10 @@ pub async fn process_match(
for obj_id in &objectives_updated {
let title = "Objective complete!";
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
let body = format!(
"\"{}\" is now complete. Claim your reward in Objectives.",
obj_id
);
let _ = notification::create(pool, "objective_complete", title, &body).await;
}
@@ -193,7 +238,8 @@ pub async fn process_match(
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
for owned_id in &expired_loans {
let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
let body =
format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
}
@@ -208,12 +254,19 @@ pub async fn process_match(
SeasonResult::Relegated => "Relegated",
SeasonResult::Maintained => "Maintained",
};
let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default());
let body = format!(
"{direction} — now in Division {}. Rewards: {} coins{}.",
se.new_division,
se.coins_awarded,
se.pack_awarded
.as_deref()
.map(|p| format!(" + {p}"))
.unwrap_or_default()
);
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
}
let achievements_unlocked =
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
let achievements_unlocked = achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
.await
.unwrap_or_default();
+3 -2
View File
@@ -2,17 +2,18 @@ pub mod achievement;
pub mod card_db;
pub mod checkin;
pub mod club;
pub mod notification;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod season;
pub mod inventory;
pub mod market;
pub mod match_service;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod sbc;
pub mod season;
pub mod settings;
pub mod squad;
pub mod squad_rules;
+2 -2
View File
@@ -124,8 +124,8 @@ pub async fn open_pack(
}
}
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
.unwrap_or_default();
let card_ids_json =
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
+5 -1
View File
@@ -86,7 +86,11 @@ pub async fn add_xp_with_levelup(
}
tracing::info!(profile_id, new_level = lvl, coins, "level up");
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
events.push(LevelUpEvent {
new_level: lvl,
coins_granted: coins,
pack_granted: pack,
});
}
Ok(events)
+3 -5
View File
@@ -16,9 +16,7 @@ pub const MAX_TRAINING_BONUS: i64 = 3;
pub const POSITION_CHANGE_COST: i64 = 500;
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
@@ -64,8 +62,8 @@ pub async fn change_position(
new_position: &str,
) -> AppResult<OwnedCard> {
let valid_positions = [
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
"CF", "ST",
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
"ST",
];
if !valid_positions.contains(&new_position) {
return Err(AppError::BadRequest(format!(
+786 -126
View File
File diff suppressed because it is too large Load Diff