Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b5ac1908 |
@@ -1,12 +0,0 @@
|
||||
target/
|
||||
**/target/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
.env.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
openfut.db
|
||||
+1
-1
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.2–0.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
@@ -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,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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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 (1–7 cycle, 0 if never checked in)
|
||||
pub streak_day: i64, // current streak (1–7 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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user