1 Commits

Author SHA1 Message Date
funman300 33b5ac1908 feat(club): semantic owned-item query + FIFA17 filter/pagination fix
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.

Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.

Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
2026-08-11 21:22:57 +00:00
33 changed files with 408 additions and 905 deletions
-12
View File
@@ -1,12 +0,0 @@
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 the emulation layer (`fifa17-recon/`).
FIFA-specific logic belongs in `openfut-bridge`.
+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://git.aleshym.co/funman300/OpenFUT-Core.git"
repository = "https://github.com/openfut/openfut-core"
[lib]
name = "openfut_core"
-56
View File
@@ -1,56 +0,0 @@
# 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"]
+1 -5
View File
@@ -2,11 +2,7 @@
**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; 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.
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.
---
+9 -36
View File
@@ -172,10 +172,7 @@ 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),
@@ -193,10 +190,7 @@ 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(
@@ -211,36 +205,15 @@ 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),
+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 event;
pub mod fut_champs;
pub mod event;
pub mod season;
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;
+11 -11
View File
@@ -34,16 +34,16 @@ pub struct CreateProfileRequest {
/// XP required to reach each level (cumulative total from level 1).
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
pub const XP_THRESHOLDS: &[i64] = &[
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
];
/// Compute the level for a given cumulative XP total.
@@ -71,7 +71,7 @@ pub fn coins_for_level(new_level: i64) -> i64 {
/// Pack granted at milestone levels (5, 10, 15, 20, …).
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
match new_level {
5 => Some("bronze_pack"),
5 => Some("bronze_pack"),
10 => Some("silver_pack"),
15 => Some("gold_pack"),
20 => Some("rare_gold_pack"),
+2 -6
View File
@@ -10,13 +10,9 @@ 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,
+12 -18
View File
@@ -18,17 +18,11 @@ use crate::{
/// 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)]
@@ -100,9 +94,7 @@ 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(
@@ -124,7 +116,8 @@ pub async fn get_collection(
.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);
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,
@@ -181,9 +174,10 @@ 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);
+29 -26
View File
@@ -2,9 +2,7 @@ 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;
@@ -67,12 +65,13 @@ 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 = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let seasons_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let highest_division: i64 = sqlx::query_scalar(
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
@@ -82,25 +81,29 @@ 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 = ?")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
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")
.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",
)
.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 = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(Json(json!({
"total_wins": stats.matches_won,
+9 -27
View File
@@ -52,26 +52,11 @@ 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
@@ -89,13 +74,10 @@ 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 draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
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!({
"club_name": name,
"wins": wins,
+2 -4
View File
@@ -43,8 +43,7 @@ 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))
}
@@ -54,8 +53,7 @@ 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))
}
+8 -6
View File
@@ -8,9 +8,7 @@ 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.
@@ -113,9 +111,13 @@ 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)
.await?;
let result = champs_svc::claim_rivals_reward(
&state.pool,
&profile.id,
&club.id,
&state.pack_defs,
)
.await?;
Ok(Json(result))
}
+3 -5
View File
@@ -19,6 +19,7 @@ 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>,
@@ -85,11 +86,8 @@ 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.
+3 -9
View File
@@ -68,15 +68,9 @@ 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,
)
.await?;
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 events;
pub mod fut_champs;
pub mod events;
pub mod health;
pub mod market;
pub mod matches;
+12 -14
View File
@@ -7,9 +7,7 @@ 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
@@ -94,16 +92,14 @@ 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!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
})
})
.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();
@@ -128,7 +124,9 @@ 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 })))
}
+2 -6
View File
@@ -140,12 +140,8 @@ 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))
}
+2 -6
View File
@@ -47,12 +47,8 @@ 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))
+7 -2
View File
@@ -69,8 +69,13 @@ 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);
+69 -76
View File
@@ -29,83 +29,79 @@ 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 = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
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),
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"matches_won" => sqlx::query_scalar(
"SELECT matches_won FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"goals_scored" => sqlx::query_scalar(
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"packs_opened" => sqlx::query_scalar(
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"sbcs_completed" => sqlx::query_scalar(
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(pool)
.await?
}
"cards_owned" => sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(club_id)
.fetch_one(pool)
.await?,
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"level" => sqlx::query_scalar(
"SELECT level FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
_ => 0,
};
_ => 0,
};
Ok(v)
}
@@ -169,14 +165,11 @@ 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());
}
+8 -11
View File
@@ -1,8 +1,4 @@
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];
@@ -12,7 +8,7 @@ const STREAK_7_PACK: &str = "silver_pack";
#[derive(Debug, serde::Serialize)]
pub struct CheckinStatus {
pub available: bool,
pub streak_day: i64, // current streak (17 cycle, 0 if never checked in)
pub streak_day: i64, // current streak (17 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
@@ -60,7 +56,11 @@ 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,10 +82,7 @@ pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<Ch
}
}
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 };
+3 -9
View File
@@ -179,14 +179,7 @@ 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];
@@ -291,7 +284,8 @@ 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
+1 -5
View File
@@ -26,11 +26,7 @@ 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 = ?"
))
Regular → Executable
View File
+4 -7
View File
@@ -285,10 +285,9 @@ 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)
@@ -304,9 +303,7 @@ 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)
+20 -73
View File
@@ -7,14 +7,13 @@ 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.
///
@@ -28,53 +27,23 @@ 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"],
),
};
@@ -90,11 +59,7 @@ 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
@@ -176,18 +141,11 @@ 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(
@@ -227,10 +185,7 @@ 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;
}
@@ -238,8 +193,7 @@ 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;
}
@@ -254,21 +208,14 @@ 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)
.await
.unwrap_or_default();
let achievements_unlocked =
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
.await
.unwrap_or_default();
Ok(MatchRewardResult {
match_record,
+2 -2
View File
@@ -2,18 +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 inventory;
pub mod season;
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 = ?")
+1 -5
View File
@@ -86,11 +86,7 @@ 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)
+10 -8
View File
@@ -16,12 +16,14 @@ 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 = ?"))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
}
/// Apply a chemistry style to an owned card.
@@ -62,8 +64,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!(
+170 -452
View File
File diff suppressed because it is too large Load Diff