Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f70cf4415c | |||
| eab522a1eb |
@@ -0,0 +1,12 @@
|
|||||||
|
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 copyrighted assets.** All card data in `data/` must be original.
|
||||||
- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints.
|
- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints.
|
||||||
- **Game-independent core.** `openfut-core` must stay game-agnostic;
|
- **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
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
authors = ["OpenFUT Contributors"]
|
authors = ["OpenFUT Contributors"]
|
||||||
description = "Offline Ultimate Team backend — game-independent core"
|
description = "Offline Ultimate Team backend — game-independent core"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/openfut/openfut-core"
|
repository = "https://git.aleshym.co/funman300/OpenFUT-Core.git"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "openfut_core"
|
name = "openfut_core"
|
||||||
|
|||||||
+56
@@ -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"]
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
**Offline Ultimate Team backend — game-independent.**
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
-- Distinguish NPC-generated listings from player-posted ones so that the
|
|
||||||
-- periodic NPC refresh does not accidentally wipe player listings.
|
|
||||||
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Track when the player last claimed their rivals weekly reward to enforce a
|
|
||||||
-- 24-hour cooldown between claims.
|
|
||||||
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- Multi-game support. Every profile (and therefore all of its downstream state,
|
|
||||||
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
|
|
||||||
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
|
|
||||||
--
|
|
||||||
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
|
|
||||||
-- and the integration tests send no game header, so they keep operating on the same
|
|
||||||
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
|
|
||||||
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
|
|
||||||
--
|
|
||||||
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
|
|
||||||
-- all other tables reference a profile (directly via profile_id or via
|
|
||||||
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
|
|
||||||
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
|
|
||||||
+36
-9
@@ -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", get(routes::squad::get_squad_by_id))
|
||||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||||
.route("/objectives", get(routes::objectives::get_objectives))
|
.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(
|
.route(
|
||||||
"/objectives/claim",
|
"/objectives/claim",
|
||||||
post(routes::objectives::post_claim_objective),
|
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", get(routes::market::get_market))
|
||||||
.route("/market/buy", post(routes::market::post_market_buy))
|
.route("/market/buy", post(routes::market::post_market_buy))
|
||||||
.route("/market/sell", post(routes::market::post_market_sell))
|
.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/refresh", post(routes::market::post_market_refresh))
|
||||||
.route("/market/my-listings", get(routes::market::get_my_listings))
|
.route("/market/my-listings", get(routes::market::get_my_listings))
|
||||||
.route(
|
.route(
|
||||||
@@ -205,15 +211,36 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", get(routes::settings::get_settings))
|
.route("/settings", get(routes::settings::get_settings))
|
||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/division", get(routes::division::get_division))
|
.route("/division", get(routes::division::get_division))
|
||||||
.route("/division/history", get(routes::division::get_division_history))
|
.route(
|
||||||
.route("/division/leaderboard", get(routes::division::get_division_leaderboard))
|
"/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("/achievements", get(routes::achievements::get_achievements))
|
||||||
.route("/notifications", get(routes::notifications::get_notifications))
|
.route(
|
||||||
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
"/notifications",
|
||||||
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
|
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", get(routes::fut_champs::get_fut_champs))
|
||||||
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
|
.route(
|
||||||
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
|
"/fut-champs/start",
|
||||||
|
post(routes::fut_champs::post_start_fut_champs),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fut-champs/history",
|
||||||
|
get(routes::fut_champs::get_champs_history),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/fut-champs/:session_id/result",
|
"/fut-champs/:session_id/result",
|
||||||
post(routes::fut_champs::post_champs_result),
|
post(routes::fut_champs::post_champs_result),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub struct Config {
|
|||||||
pub listen_addr: String,
|
pub listen_addr: String,
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub max_connections: u32,
|
pub max_connections: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ use tracing::info;
|
|||||||
|
|
||||||
pub type Pool = SqlitePool;
|
pub type Pool = SqlitePool;
|
||||||
|
|
||||||
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
pub async fn init_pool(database_url: &str) -> Result<Pool> {
|
||||||
info!("Connecting to database: {}", database_url);
|
info!("Connecting to database: {}", database_url);
|
||||||
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(5)
|
||||||
.connect_with(opts)
|
.connect_with(opts)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
sqlx::query("PRAGMA journal_mode=WAL")
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
//! Request extractors shared across routes.
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{request::Parts, HeaderName},
|
|
||||||
};
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
|
|
||||||
///
|
|
||||||
/// Multi-game support: each game bridge tags its requests with its own id
|
|
||||||
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
|
|
||||||
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
|
|
||||||
/// when the header is absent, so the existing FIFA 23 bridge and the integration
|
|
||||||
/// tests (which send no header) keep operating on their game unchanged.
|
|
||||||
///
|
|
||||||
/// Extraction never fails: a missing or malformed header falls back to the default.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GameId(pub String);
|
|
||||||
|
|
||||||
/// The game assumed when no `X-OpenFUT-Game` header is present.
|
|
||||||
pub const DEFAULT_GAME: &str = "fifa23";
|
|
||||||
|
|
||||||
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
|
|
||||||
|
|
||||||
impl GameId {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for GameId
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = Infallible;
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let game = parts
|
|
||||||
.headers
|
|
||||||
.get(&HEADER)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or(DEFAULT_GAME)
|
|
||||||
.to_string();
|
|
||||||
Ok(GameId(game))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ pub mod app;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extractors;
|
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod modding;
|
pub mod modding;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ async fn main() -> Result<()> {
|
|||||||
let cfg = config::Config::from_env()?;
|
let cfg = config::Config::from_env()?;
|
||||||
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||||
|
|
||||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
let pool = db::init_pool(&cfg.database_url).await?;
|
||||||
db::run_migrations(&pool).await?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
seed::maybe_seed(&pool).await?;
|
seed::maybe_seed(&pool).await?;
|
||||||
|
|||||||
+24
-1
@@ -1 +1,24 @@
|
|||||||
// Reserved for future modding loader utilities.
|
use anyhow::{Context, Result};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
/// Generic loader for JSON arrays from a directory.
|
||||||
|
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
|
||||||
|
let batch: Vec<T> =
|
||||||
|
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
||||||
|
items.extend(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
//! Modding support: load JSON data files from the data/ directory.
|
//! Modding support: load JSON data files from the data/ directory.
|
||||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
||||||
|
|
||||||
|
pub mod loader;
|
||||||
|
|||||||
@@ -13,20 +13,6 @@ pub enum Rarity {
|
|||||||
Icon,
|
Icon,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rarity {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Rarity::Bronze => "bronze",
|
|
||||||
Rarity::Silver => "silver",
|
|
||||||
Rarity::Gold => "gold",
|
|
||||||
Rarity::RareGold => "raregold",
|
|
||||||
Rarity::Totw => "totw",
|
|
||||||
Rarity::Hero => "hero",
|
|
||||||
Rarity::Icon => "icon",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual card quality tier (gold/silver/bronze).
|
/// Visual card quality tier (gold/silver/bronze).
|
||||||
///
|
///
|
||||||
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
||||||
|
|||||||
+3
-3
@@ -1,18 +1,18 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod chemistry_style;
|
pub mod chemistry_style;
|
||||||
pub mod notification;
|
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod fut_champs;
|
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod season;
|
pub mod fut_champs;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_result;
|
pub mod match_result;
|
||||||
|
pub mod notification;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod reward;
|
pub mod reward;
|
||||||
pub mod sbc;
|
pub mod sbc;
|
||||||
|
pub mod season;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
|||||||
@@ -21,19 +21,6 @@ pub enum ObjectiveMetric {
|
|||||||
CoinsEarned,
|
CoinsEarned,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectiveMetric {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ObjectiveMetric::MatchesWon => "matcheswon",
|
|
||||||
ObjectiveMetric::MatchesPlayed => "matchesplayed",
|
|
||||||
ObjectiveMetric::GoalsScored => "goalsscored",
|
|
||||||
ObjectiveMetric::PacksOpened => "packsopened",
|
|
||||||
ObjectiveMetric::SbcsCompleted => "sbcscompleted",
|
|
||||||
ObjectiveMetric::CoinsEarned => "coinsearned",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Objective definition from data/objectives/*.json
|
/// Objective definition from data/objectives/*.json
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObjectiveDefinition {
|
pub struct ObjectiveDefinition {
|
||||||
|
|||||||
+12
-16
@@ -8,22 +8,18 @@ pub struct Profile {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub level: i64,
|
pub level: i64,
|
||||||
pub xp: i64,
|
pub xp: i64,
|
||||||
/// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of
|
|
||||||
/// this profile's downstream state so multiple games share one core + DB.
|
|
||||||
pub game_id: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
|
pub fn new(username: impl Into<String>) -> Self {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
game_id: game_id.into(),
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -38,16 +34,16 @@ pub struct CreateProfileRequest {
|
|||||||
/// XP required to reach each level (cumulative total from level 1).
|
/// XP required to reach each level (cumulative total from level 1).
|
||||||
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
|
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
|
||||||
pub const XP_THRESHOLDS: &[i64] = &[
|
pub const XP_THRESHOLDS: &[i64] = &[
|
||||||
0, // level 1
|
0, // level 1
|
||||||
500, // level 2
|
500, // level 2
|
||||||
1200, // level 3
|
1200, // level 3
|
||||||
2000, // level 4
|
2000, // level 4
|
||||||
3000, // level 5
|
3000, // level 5
|
||||||
4200, // level 6
|
4200, // level 6
|
||||||
5600, // level 7
|
5600, // level 7
|
||||||
7200, // level 8
|
7200, // level 8
|
||||||
9000, // level 9
|
9000, // level 9
|
||||||
11000, // level 10
|
11000, // level 10
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Compute the level for a given cumulative XP total.
|
/// Compute the level for a given cumulative XP total.
|
||||||
@@ -75,7 +71,7 @@ pub fn coins_for_level(new_level: i64) -> i64 {
|
|||||||
/// Pack granted at milestone levels (5, 10, 15, 20, …).
|
/// Pack granted at milestone levels (5, 10, 15, 20, …).
|
||||||
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
|
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
|
||||||
match new_level {
|
match new_level {
|
||||||
5 => Some("bronze_pack"),
|
5 => Some("bronze_pack"),
|
||||||
10 => Some("silver_pack"),
|
10 => Some("silver_pack"),
|
||||||
15 => Some("gold_pack"),
|
15 => Some("gold_pack"),
|
||||||
20 => Some("rare_gold_pack"),
|
20 => Some("rare_gold_pack"),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -8,12 +7,16 @@ use crate::{
|
|||||||
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).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 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!({
|
Ok(Json(json!({
|
||||||
"achievements": achievements,
|
"achievements": achievements,
|
||||||
"earned": earned,
|
"earned": earned,
|
||||||
|
|||||||
+28
-85
@@ -1,11 +1,9 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::AppResult,
|
||||||
extractors::GameId,
|
|
||||||
models::{club::Club, profile::CreateProfileRequest},
|
models::{club::Club, profile::CreateProfileRequest},
|
||||||
seed,
|
seed,
|
||||||
services::{club as club_svc, profile as profile_svc},
|
services::{club as club_svc, profile as profile_svc},
|
||||||
@@ -13,12 +11,11 @@ use crate::{
|
|||||||
|
|
||||||
pub async fn post_auth_local(
|
pub async fn post_auth_local(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<CreateProfileRequest>,
|
Json(req): Json<CreateProfileRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
||||||
|
|
||||||
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
|
let profile = profile_svc::create_profile(&state.pool, &username).await?;
|
||||||
|
|
||||||
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
||||||
club_svc::create_club(&state.pool, &club).await?;
|
club_svc::create_club(&state.pool, &club).await?;
|
||||||
@@ -34,105 +31,51 @@ pub async fn post_auth_local(
|
|||||||
|
|
||||||
/// GET /auth/status — lightweight check: does a profile exist?
|
/// GET /auth/status — lightweight check: does a profile exist?
|
||||||
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
||||||
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
|
||||||
.bind(game.as_str())
|
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!({ "has_profile": count > 0 })))
|
Ok(Json(json!({ "has_profile": count > 0 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ResetRequest {
|
|
||||||
pub confirm: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /auth/reset — wipe all game data and start fresh.
|
/// POST /auth/reset — wipe all game data and start fresh.
|
||||||
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
|
/// Deletes every user-data table in dependency order. The schema tables
|
||||||
/// accidental or unauthenticated calls. Deletes every user-data table in
|
/// (migrations) are left intact; calling POST /auth/local afterwards
|
||||||
/// dependency order; schema (migrations) is preserved.
|
/// creates a new profile.
|
||||||
pub async fn post_auth_reset(
|
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
// Delete in reverse-dependency order to satisfy FK constraints
|
||||||
game: GameId,
|
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
|
||||||
Json(req): Json<ResetRequest>,
|
let tables = [
|
||||||
) -> AppResult<Json<Value>> {
|
"player_achievements",
|
||||||
if req.confirm.as_deref() != Some("reset") {
|
"notifications",
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
|
|
||||||
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
|
|
||||||
// delete their dependent rows in reverse-dependency order.
|
|
||||||
let profile_id: Option<String> = sqlx::query_scalar(
|
|
||||||
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(game.as_str())
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(profile_id) = profile_id else {
|
|
||||||
return Ok(Json(json!({
|
|
||||||
"reset": true,
|
|
||||||
"message": "nothing to reset for this game"
|
|
||||||
})));
|
|
||||||
};
|
|
||||||
|
|
||||||
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
|
|
||||||
.bind(&profile_id)
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for club_id in &club_ids {
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
|
|
||||||
)
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
for table in ["squads", "owned_cards", "packs", "market_history"] {
|
|
||||||
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for table in [
|
|
||||||
"fut_champs_sessions",
|
"fut_champs_sessions",
|
||||||
"sbc_submissions",
|
"sbc_submissions",
|
||||||
"objective_progress",
|
"objective_progress",
|
||||||
"position_goals",
|
"position_goals",
|
||||||
"statistics",
|
"statistics",
|
||||||
"seasons",
|
"seasons",
|
||||||
"season_history",
|
|
||||||
"matches",
|
"matches",
|
||||||
|
"market_listings",
|
||||||
|
"squad_players",
|
||||||
|
"squads",
|
||||||
|
"owned_cards",
|
||||||
|
"packs",
|
||||||
|
"events",
|
||||||
"draft_sessions",
|
"draft_sessions",
|
||||||
"daily_checkins",
|
"settings",
|
||||||
] {
|
"clubs",
|
||||||
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
|
"profiles",
|
||||||
.bind(&profile_id)
|
];
|
||||||
|
|
||||||
|
for table in &tables {
|
||||||
|
sqlx::query(&format!("DELETE FROM {table}"))
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
|
tracing::info!("Full game reset performed");
|
||||||
.bind(&profile_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
|
||||||
.bind(&profile_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// NOTE (follow-up): settings (global key/value), events (shared content),
|
|
||||||
// market_listings (keyed by seller_name, includes the shared NPC market),
|
|
||||||
// notifications and player_achievements are not yet game-scoped and are left
|
|
||||||
// intact. They need a game/profile key before a per-game reset can cover them.
|
|
||||||
tracing::info!(game = %game.as_str(), "Per-game reset performed");
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"reset": true,
|
"reset": true,
|
||||||
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
|
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-18
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -19,11 +18,17 @@ use crate::{
|
|||||||
|
|
||||||
/// Quick-sell value for a card based on overall rating.
|
/// Quick-sell value for a card based on overall rating.
|
||||||
fn quick_sell_coins(overall: u8) -> i64 {
|
fn quick_sell_coins(overall: u8) -> i64 {
|
||||||
if overall >= 85 { 1500 }
|
if overall >= 85 {
|
||||||
else if overall >= 80 { 900 }
|
1500
|
||||||
else if overall >= 75 { 600 }
|
} else if overall >= 80 {
|
||||||
else if overall >= 65 { 300 }
|
900
|
||||||
else { 150 }
|
} else if overall >= 75 {
|
||||||
|
600
|
||||||
|
} else if overall >= 65 {
|
||||||
|
300
|
||||||
|
} else {
|
||||||
|
150
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -61,7 +66,7 @@ pub async fn get_cards(
|
|||||||
let rarity_ok = query
|
let rarity_ok = query
|
||||||
.rarity
|
.rarity
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
|
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let pos_ok = query
|
let pos_ok = query
|
||||||
.position
|
.position
|
||||||
@@ -95,15 +100,16 @@ pub async fn get_cards(
|
|||||||
cards.truncate(limit);
|
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(
|
pub async fn get_collection(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<OwnedItemQuery>,
|
Query(query): Query<OwnedItemQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
@@ -118,8 +124,7 @@ pub async fn get_collection(
|
|||||||
.filter_map(|o| {
|
.filter_map(|o| {
|
||||||
state.card_db.get(&o.card_id).map(|def| {
|
state.card_db.get(&o.card_id).map(|def| {
|
||||||
let effective_overall = def.overall as i64 + o.training_bonus;
|
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||||
let effective_position =
|
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
|
||||||
o.position_override.as_deref().unwrap_or(&def.position);
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"owned_card_id": o.id,
|
"owned_card_id": o.id,
|
||||||
"is_loan": o.is_loan,
|
"is_loan": o.is_loan,
|
||||||
@@ -160,10 +165,9 @@ pub async fn get_collection(
|
|||||||
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
||||||
pub async fn delete_owned_card(
|
pub async fn delete_owned_card(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
@@ -177,10 +181,9 @@ pub async fn delete_owned_card(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
|
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
|
||||||
|
|
||||||
let card = state
|
let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
|
||||||
.card_db
|
AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
|
||||||
.get(&owned.card_id)
|
})?;
|
||||||
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
|
|
||||||
|
|
||||||
let coins = quick_sell_coins(card.overall);
|
let coins = quick_sell_coins(card.overall);
|
||||||
|
|
||||||
|
|||||||
+35
-40
@@ -1,16 +1,17 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::club::Club,
|
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 axum::{extract::State, Json};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(club))
|
Ok(Json(club))
|
||||||
}
|
}
|
||||||
@@ -23,10 +24,9 @@ pub struct UpdateClubRequest {
|
|||||||
|
|
||||||
pub async fn put_club(
|
pub async fn put_club(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<UpdateClubRequest>,
|
Json(req): Json<UpdateClubRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let updated = club_svc::update_club(
|
let updated = club_svc::update_club(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -38,8 +38,8 @@ pub async fn put_club(
|
|||||||
Ok(Json(json!({ "club": updated })))
|
Ok(Json(json!({ "club": updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"available": status.available,
|
"available": status.available,
|
||||||
@@ -50,8 +50,8 @@ pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) ->
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -62,18 +62,17 @@ pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppRes
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
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 stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let seasons_completed: i64 = sqlx::query_scalar(
|
let seasons_completed: i64 =
|
||||||
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
|
||||||
)
|
.bind(&profile.id)
|
||||||
.bind(&profile.id)
|
.fetch_one(&state.pool)
|
||||||
.fetch_one(&state.pool)
|
.await
|
||||||
.await
|
.unwrap_or(0);
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let highest_division: i64 = sqlx::query_scalar(
|
let highest_division: i64 = sqlx::query_scalar(
|
||||||
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
|
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
|
||||||
@@ -83,29 +82,25 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(10);
|
.unwrap_or(10);
|
||||||
|
|
||||||
let cards_owned: i64 = sqlx::query_scalar(
|
let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
.bind(&club.id)
|
||||||
)
|
.fetch_one(&state.pool)
|
||||||
.bind(&club.id)
|
.await
|
||||||
.fetch_one(&state.pool)
|
.unwrap_or(0);
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let sbcs_completed: i64 = sqlx::query_scalar(
|
let sbcs_completed: i64 =
|
||||||
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
|
sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
|
||||||
)
|
.bind(&club.id)
|
||||||
.bind(&club.id)
|
.fetch_one(&state.pool)
|
||||||
.fetch_one(&state.pool)
|
.await
|
||||||
.await
|
.unwrap_or(0);
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let total_checkins: i64 = sqlx::query_scalar(
|
let total_checkins: i64 =
|
||||||
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
|
||||||
)
|
.bind(&profile.id)
|
||||||
.bind(&profile.id)
|
.fetch_one(&state.pool)
|
||||||
.fetch_one(&state.pool)
|
.await
|
||||||
.await
|
.unwrap_or(0);
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"total_wins": stats.matches_won,
|
"total_wins": stats.matches_won,
|
||||||
|
|||||||
+33
-16
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use rand::{Rng, SeedableRng};
|
use rand::{Rng, SeedableRng};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -10,8 +9,8 @@ use crate::{
|
|||||||
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -37,14 +36,14 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -53,11 +52,26 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
|
|||||||
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
|
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
|
||||||
|
|
||||||
const NPC_NAMES: &[&str] = &[
|
const NPC_NAMES: &[&str] = &[
|
||||||
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
|
"Riverside FC",
|
||||||
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
|
"City Athletic",
|
||||||
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
|
"County United",
|
||||||
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
|
"Valley Rangers",
|
||||||
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
|
"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
|
// Pick 9 NPC names without repetition using the seeded RNG
|
||||||
@@ -75,10 +89,13 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
|
|||||||
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
||||||
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
|
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 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 wins =
|
||||||
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
(npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||||
let draws = (npc_matches - wins - losses).max(0);
|
let losses = (npc_matches as f64
|
||||||
let pts = wins * 3 + draws;
|
* (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!({
|
json!({
|
||||||
"club_name": name,
|
"club_name": name,
|
||||||
"wins": wins,
|
"wins": wins,
|
||||||
|
|||||||
+8
-11
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -40,23 +39,23 @@ pub async fn get_draft_squad(
|
|||||||
/// until all 11 slots are filled.
|
/// until all 11 slots are filled.
|
||||||
pub async fn post_draft_start(
|
pub async fn post_draft_start(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<DraftQuery>,
|
Query(query): Query<DraftQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
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))
|
Ok(Json(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current state of a draft session.
|
/// Get the current state of a draft session.
|
||||||
pub async fn get_draft_session(
|
pub async fn get_draft_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
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))
|
Ok(Json(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +71,10 @@ pub struct PickRequest {
|
|||||||
/// and rewards (coins + optional pack) are granted automatically.
|
/// and rewards (coins + optional pack) are granted automatically.
|
||||||
pub async fn post_draft_pick(
|
pub async fn post_draft_pick(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
Json(req): Json<PickRequest>,
|
Json(req): Json<PickRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let session = draft_svc::pick_card(
|
let session = draft_svc::pick_card(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -93,10 +91,9 @@ pub async fn post_draft_pick(
|
|||||||
/// Abandon an active draft session. No rewards are granted.
|
/// Abandon an active draft session. No rewards are granted.
|
||||||
pub async fn post_draft_abandon(
|
pub async fn post_draft_abandon(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-21
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -9,12 +8,14 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
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.
|
/// GET /fut-champs — current active session, or null if none.
|
||||||
pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -24,8 +25,8 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /fut-champs/start — open a new FUT Champions week.
|
/// POST /fut-champs/start — open a new FUT Champions week.
|
||||||
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -43,11 +44,10 @@ pub struct ChampsMatchRequest {
|
|||||||
/// POST /fut-champs/:session_id/result — record a match in this session.
|
/// POST /fut-champs/:session_id/result — record a match in this session.
|
||||||
pub async fn post_champs_result(
|
pub async fn post_champs_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
Json(req): Json<ChampsMatchRequest>,
|
Json(req): Json<ChampsMatchRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
|
|
||||||
let session = champs_svc::record_match(
|
let session = champs_svc::record_match(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -77,10 +77,9 @@ pub async fn post_champs_result(
|
|||||||
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
||||||
pub async fn post_claim_champs_rewards(
|
pub async fn post_claim_champs_rewards(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result = champs_svc::claim_rewards(
|
let result = champs_svc::claim_rewards(
|
||||||
@@ -96,8 +95,8 @@ pub async fn post_claim_champs_rewards(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /fut-champs/history — past sessions, newest first.
|
/// GET /fut-champs/history — past sessions, newest first.
|
||||||
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -107,20 +106,16 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
||||||
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
// Ensure a season row exists
|
// Ensure a season row exists
|
||||||
season_svc::get_or_create(&state.pool, &profile.id).await?;
|
season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result = champs_svc::claim_rivals_reward(
|
let result =
|
||||||
&state.pool,
|
champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
|
||||||
&profile.id,
|
.await?;
|
||||||
&club.id,
|
|
||||||
&state.pack_defs,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-14
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -13,14 +12,13 @@ use crate::{
|
|||||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
||||||
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct MarketQuery {
|
pub struct MarketQuery {
|
||||||
pub min_overall: Option<u8>,
|
pub min_overall: Option<u8>,
|
||||||
@@ -53,10 +51,9 @@ pub async fn get_market(
|
|||||||
|
|
||||||
pub async fn post_market_buy(
|
pub async fn post_market_buy(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<BuyListingRequest>,
|
Json(req): Json<BuyListingRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -67,10 +64,9 @@ pub async fn post_market_buy(
|
|||||||
|
|
||||||
pub async fn post_market_sell(
|
pub async fn post_market_sell(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SellCardRequest>,
|
Json(req): Json<SellCardRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -86,20 +82,22 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return all active market listings posted by the current player's club.
|
/// Return all active market listings posted by the current player's club.
|
||||||
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).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?;
|
let listings =
|
||||||
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
|
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.
|
/// Cancel a player-posted listing and return the card to the collection.
|
||||||
pub async fn delete_market_listing(
|
pub async fn delete_market_listing(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(listing_id): Path<String>,
|
Path(listing_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
||||||
Ok(Json(json!({ "cancelled": listing_id })))
|
Ok(Json(json!({ "cancelled": listing_id })))
|
||||||
|
|||||||
+11
-8
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -21,10 +20,9 @@ pub struct MatchHistoryQuery {
|
|||||||
|
|
||||||
pub async fn get_matches(
|
pub async fn get_matches(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<MatchHistoryQuery>,
|
Query(query): Query<MatchHistoryQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
let matches = if let Some(mode) = &query.mode {
|
let matches = if let Some(mode) = &query.mode {
|
||||||
@@ -65,15 +63,20 @@ pub async fn get_opponent(
|
|||||||
|
|
||||||
pub async fn post_match_result(
|
pub async fn post_match_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SubmitMatchRequest>,
|
Json(req): Json<SubmitMatchRequest>,
|
||||||
) -> AppResult<Json<MatchRewardResult>> {
|
) -> AppResult<Json<MatchRewardResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result =
|
let result = match_service::process_match(
|
||||||
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
|
&state.pool,
|
||||||
.await?;
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&req,
|
||||||
|
&state.obj_defs,
|
||||||
|
&state.achievement_defs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,8 +4,8 @@ pub mod cards;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod fut_champs;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod fut_champs;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
|
|||||||
+16
-15
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -8,7 +7,9 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
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
|
/// GET /notifications
|
||||||
@@ -17,8 +18,8 @@ use crate::{
|
|||||||
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||||
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||||
/// have `id: null` and are always considered unread.
|
/// have `id: null` and are always considered unread.
|
||||||
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
// ── Persistent notifications ─────────────────────────────────────────────
|
// ── Persistent notifications ─────────────────────────────────────────────
|
||||||
@@ -93,14 +94,16 @@ pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> A
|
|||||||
// Use "type" key for compatibility with dashboard and existing tests.
|
// Use "type" key for compatibility with dashboard and existing tests.
|
||||||
let all: Vec<Value> = persistent
|
let all: Vec<Value> = persistent
|
||||||
.iter()
|
.iter()
|
||||||
.map(|n| json!({
|
.map(|n| {
|
||||||
"id": n.id,
|
json!({
|
||||||
"type": n.kind,
|
"id": n.id,
|
||||||
"title": n.title,
|
"type": n.kind,
|
||||||
"body": n.body,
|
"title": n.title,
|
||||||
"is_read": n.is_read,
|
"body": n.body,
|
||||||
"created_at": n.created_at,
|
"is_read": n.is_read,
|
||||||
}))
|
"created_at": n.created_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
.chain(dynamic.iter().cloned())
|
.chain(dynamic.iter().cloned())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -125,9 +128,7 @@ pub async fn mark_notification_read(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /notifications/read-all
|
/// POST /notifications/read-all
|
||||||
pub async fn mark_all_notifications_read(
|
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let count = notif_svc::mark_all_read(&state.pool).await?;
|
let count = notif_svc::mark_all_read(&state.pool).await?;
|
||||||
Ok(Json(json!({ "marked_read": count })))
|
Ok(Json(json!({ "marked_read": count })))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -12,8 +11,8 @@ use crate::{
|
|||||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let objectives =
|
let objectives =
|
||||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
Ok(Json(json!({ "objectives": objectives })))
|
Ok(Json(json!({ "objectives": objectives })))
|
||||||
@@ -21,10 +20,9 @@ pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
|
|
||||||
pub async fn get_objective(
|
pub async fn get_objective(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(objective_id): Path<String>,
|
Path(objective_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let all =
|
let all =
|
||||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
let obj = all
|
let obj = all
|
||||||
@@ -36,10 +34,9 @@ pub async fn get_objective(
|
|||||||
|
|
||||||
pub async fn post_claim_objective_by_id(
|
pub async fn post_claim_objective_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(objective_id): Path<String>,
|
Path(objective_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let reward = obj_svc::claim_objective(
|
let reward = obj_svc::claim_objective(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -59,10 +56,9 @@ pub struct ClaimRequest {
|
|||||||
|
|
||||||
pub async fn post_claim_objective(
|
pub async fn post_claim_objective(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<ClaimRequest>,
|
Json(req): Json<ClaimRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let reward = obj_svc::claim_objective(
|
let reward = obj_svc::claim_objective(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
|
|||||||
+12
-11
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -20,10 +19,9 @@ pub struct BuyPackRequest {
|
|||||||
|
|
||||||
pub async fn post_buy_pack(
|
pub async fn post_buy_pack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<BuyPackRequest>,
|
Json(req): Json<BuyPackRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let pack = pack_svc::buy_pack(
|
let pack = pack_svc::buy_pack(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -56,8 +54,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
|
|||||||
Ok(Json(json!({ "packs": store })))
|
Ok(Json(json!({ "packs": store })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
||||||
|
|
||||||
@@ -79,8 +77,8 @@ pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return recently opened packs with the card IDs they contained.
|
/// Return recently opened packs with the card IDs they contained.
|
||||||
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
||||||
@@ -124,10 +122,9 @@ pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> Ap
|
|||||||
|
|
||||||
pub async fn post_open_pack(
|
pub async fn post_open_pack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(pack_id): Path<String>,
|
Path(pack_id): Path<String>,
|
||||||
) -> AppResult<Json<PackOpenResult>> {
|
) -> AppResult<Json<PackOpenResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result = pack_svc::open_pack(
|
let result = pack_svc::open_pack(
|
||||||
@@ -143,8 +140,12 @@ pub async fn post_open_pack(
|
|||||||
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = crate::services::achievement::check_and_unlock(
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
&state.pool,
|
||||||
).await;
|
&state.achievement_defs,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
@@ -8,8 +7,8 @@ use crate::{
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let computed_level = level_for_xp(profile.xp);
|
let computed_level = level_for_xp(profile.xp);
|
||||||
|
|
||||||
let next_level = computed_level + 1;
|
let next_level = computed_level + 1;
|
||||||
|
|||||||
+7
-5
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -30,10 +29,9 @@ pub async fn get_sbc(
|
|||||||
|
|
||||||
pub async fn post_sbc_submit(
|
pub async fn post_sbc_submit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SubmitSbcRequest>,
|
Json(req): Json<SubmitSbcRequest>,
|
||||||
) -> AppResult<Json<SbcResult>> {
|
) -> AppResult<Json<SbcResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result = sbc_svc::submit_sbc(
|
let result = sbc_svc::submit_sbc(
|
||||||
@@ -49,8 +47,12 @@ pub async fn post_sbc_submit(
|
|||||||
|
|
||||||
if result.passed {
|
if result.passed {
|
||||||
let _ = crate::services::achievement::check_and_unlock(
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
&state.pool,
|
||||||
).await;
|
&state.achievement_defs,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
|
|||||||
+8
-12
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -12,8 +11,8 @@ use crate::{
|
|||||||
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||||
@@ -22,8 +21,8 @@ pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult
|
|||||||
Ok(Json(squad_response(&squad, &players, chemistry)))
|
Ok(Json(squad_response(&squad, &players, chemistry)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
||||||
Ok(Json(json!({ "squads": squads })))
|
Ok(Json(json!({ "squads": squads })))
|
||||||
@@ -31,10 +30,9 @@ pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResul
|
|||||||
|
|
||||||
pub async fn get_squad_by_id(
|
pub async fn get_squad_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(squad_id): Path<String>,
|
Path(squad_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
||||||
@@ -45,14 +43,13 @@ pub async fn get_squad_by_id(
|
|||||||
|
|
||||||
pub async fn post_squad(
|
pub async fn post_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SaveSquadRequest>,
|
Json(req): Json<SaveSquadRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
if !req.players.is_empty() {
|
if !req.players.is_empty() {
|
||||||
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
|
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -61,10 +58,9 @@ pub async fn post_squad(
|
|||||||
|
|
||||||
pub async fn delete_squad(
|
pub async fn delete_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(squad_id): Path<String>,
|
Path(squad_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
||||||
Ok(Json(json!({ "deleted": squad_id })))
|
Ok(Json(json!({ "deleted": squad_id })))
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -13,8 +12,8 @@ use crate::{
|
|||||||
services::{profile as profile_svc, statistics as stats_svc},
|
services::{profile as profile_svc, statistics as stats_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -37,10 +36,9 @@ pub struct HistoryQuery {
|
|||||||
|
|
||||||
pub async fn get_statistics_history(
|
pub async fn get_statistics_history(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<HistoryQuery>,
|
Query(query): Query<HistoryQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
let matches = sqlx::query_as::<_, Match>(
|
let matches = sqlx::query_as::<_, Match>(
|
||||||
|
|||||||
+5
-14
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -28,11 +27,10 @@ pub struct ApplyChemStyleRequest {
|
|||||||
/// POST /collection/:owned_card_id/chemistry-style
|
/// POST /collection/:owned_card_id/chemistry-style
|
||||||
pub async fn post_apply_chemistry_style(
|
pub async fn post_apply_chemistry_style(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
Json(req): Json<ApplyChemStyleRequest>,
|
Json(req): Json<ApplyChemStyleRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let updated = upgrade_svc::apply_chemistry_style(
|
let updated = upgrade_svc::apply_chemistry_style(
|
||||||
@@ -65,20 +63,14 @@ pub struct ChangePositionRequest {
|
|||||||
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
||||||
pub async fn post_change_position(
|
pub async fn post_change_position(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
Json(req): Json<ChangePositionRequest>,
|
Json(req): Json<ChangePositionRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let updated = upgrade_svc::change_position(
|
let updated =
|
||||||
&state.pool,
|
upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
|
||||||
&club.id,
|
|
||||||
&owned_card_id,
|
|
||||||
&req.position,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let card_def = state.card_db.get(&updated.card_id);
|
let card_def = state.card_db.get(&updated.card_id);
|
||||||
|
|
||||||
@@ -100,11 +92,10 @@ pub struct ApplyTrainingRequest {
|
|||||||
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
||||||
pub async fn post_apply_training(
|
pub async fn post_apply_training(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
Json(req): Json<ApplyTrainingRequest>,
|
Json(req): Json<ApplyTrainingRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let updated =
|
let updated =
|
||||||
|
|||||||
+76
-69
@@ -29,79 +29,83 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Query the current value for the given trigger metric.
|
/// 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> {
|
async fn metric_value(
|
||||||
let v: i64 = match trigger {
|
pool: &Pool,
|
||||||
"matches_played" => sqlx::query_scalar(
|
profile_id: &str,
|
||||||
"SELECT matches_played FROM statistics WHERE profile_id = ?",
|
club_id: &str,
|
||||||
)
|
trigger: &str,
|
||||||
.bind(profile_id)
|
) -> AppResult<i64> {
|
||||||
.fetch_optional(pool)
|
let v: i64 =
|
||||||
.await?
|
match trigger {
|
||||||
.unwrap_or(0),
|
"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(
|
"matches_won" => {
|
||||||
"SELECT matches_won FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
|
||||||
)
|
.bind(profile_id)
|
||||||
.bind(profile_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await?
|
||||||
.await?
|
.unwrap_or(0)
|
||||||
.unwrap_or(0),
|
}
|
||||||
|
|
||||||
"goals_scored" => sqlx::query_scalar(
|
"goals_scored" => {
|
||||||
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
|
||||||
)
|
.bind(profile_id)
|
||||||
.bind(profile_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await?
|
||||||
.await?
|
.unwrap_or(0)
|
||||||
.unwrap_or(0),
|
}
|
||||||
|
|
||||||
"packs_opened" => sqlx::query_scalar(
|
"packs_opened" => {
|
||||||
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
|
||||||
)
|
.bind(profile_id)
|
||||||
.bind(profile_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await?
|
||||||
.await?
|
.unwrap_or(0)
|
||||||
.unwrap_or(0),
|
}
|
||||||
|
|
||||||
"sbcs_completed" => sqlx::query_scalar(
|
"sbcs_completed" => {
|
||||||
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
|
||||||
)
|
.bind(profile_id)
|
||||||
.bind(profile_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await?
|
||||||
.await?
|
.unwrap_or(0)
|
||||||
.unwrap_or(0),
|
}
|
||||||
|
|
||||||
"cards_owned" => sqlx::query_scalar(
|
"cards_owned" => {
|
||||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||||
)
|
.bind(club_id)
|
||||||
.bind(club_id)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?
|
||||||
.await?,
|
}
|
||||||
|
|
||||||
"level" => sqlx::query_scalar(
|
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
|
||||||
"SELECT level FROM profiles WHERE id = ?",
|
.bind(profile_id)
|
||||||
)
|
.fetch_optional(pool)
|
||||||
.bind(profile_id)
|
.await?
|
||||||
.fetch_optional(pool)
|
.unwrap_or(1),
|
||||||
.await?
|
|
||||||
.unwrap_or(1),
|
|
||||||
|
|
||||||
"objectives_completed" => sqlx::query_scalar(
|
"objectives_completed" => sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
||||||
)
|
)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?,
|
.await?,
|
||||||
|
|
||||||
"drafts_completed" => sqlx::query_scalar(
|
"drafts_completed" => sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
|
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
|
||||||
)
|
)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?,
|
.await?,
|
||||||
|
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
Ok(v)
|
Ok(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,11 +169,14 @@ pub async fn check_and_unlock(
|
|||||||
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
|
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = format!(
|
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
|
||||||
"{} Reward: {} coins.",
|
let _ = notification::create(
|
||||||
def.description, def.reward_coins
|
pool,
|
||||||
);
|
"achievement",
|
||||||
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
|
&format!("Achievement: {}", def.title),
|
||||||
|
&body,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
newly_unlocked.push(def.clone());
|
newly_unlocked.push(def.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ impl CardDb {
|
|||||||
self.cards.get(id)
|
self.cards.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
||||||
|
self.cards
|
||||||
|
.values()
|
||||||
|
.filter(|c| {
|
||||||
|
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||||
|
r == rarity || rarity == "any"
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||||
self.cards.values().collect()
|
self.cards.values().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -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;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
|
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
|
||||||
@@ -8,7 +12,7 @@ const STREAK_7_PACK: &str = "silver_pack";
|
|||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct CheckinStatus {
|
pub struct CheckinStatus {
|
||||||
pub available: bool,
|
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_coins: i64,
|
||||||
pub next_reward_pack: Option<&'static str>,
|
pub next_reward_pack: Option<&'static str>,
|
||||||
pub last_checked_in: Option<String>,
|
pub last_checked_in: Option<String>,
|
||||||
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn claim(
|
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
|
||||||
pool: &Pool,
|
|
||||||
profile_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
) -> AppResult<CheckinResult> {
|
|
||||||
let row: Option<(i64, String)> = sqlx::query_as(
|
let row: Option<(i64, String)> = sqlx::query_as(
|
||||||
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
||||||
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
|
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 idx = ((last_streak - 1) % 7) as usize;
|
||||||
let coins = STREAK_COINS[idx];
|
let coins = STREAK_COINS[idx];
|
||||||
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
||||||
|
|||||||
+13
-17
@@ -88,18 +88,13 @@ pub async fn start_draft(
|
|||||||
let first_position = &pick_order[0];
|
let first_position = &pick_order[0];
|
||||||
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
||||||
|
|
||||||
let pick_order_json = serde_json::to_string(&pick_order)
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
let candidates_json = serde_json::to_string(&candidates)
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
|
|
||||||
let session = DraftSession {
|
let session = DraftSession {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
profile_id: profile_id.to_string(),
|
profile_id: profile_id.to_string(),
|
||||||
difficulty: difficulty.to_string(),
|
difficulty: difficulty.to_string(),
|
||||||
pick_order: pick_order_json,
|
pick_order: serde_json::to_string(&pick_order).unwrap(),
|
||||||
picks: "[]".to_string(),
|
picks: "[]".to_string(),
|
||||||
current_candidates: Some(candidates_json),
|
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
reward_coins: 0,
|
reward_coins: 0,
|
||||||
reward_pack_id: None,
|
reward_pack_id: None,
|
||||||
@@ -184,28 +179,30 @@ pub async fn pick_card(
|
|||||||
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
|
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
|
||||||
if all_filled {
|
if all_filled {
|
||||||
let (coins, pack, avg) = compute_reward(card_db, &picks);
|
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 {
|
} else {
|
||||||
let min_overall = difficulty_min_overall(&session.difficulty);
|
let min_overall = difficulty_min_overall(&session.difficulty);
|
||||||
let next_pos = &pick_order[next_index];
|
let next_pos = &pick_order[next_index];
|
||||||
let next_candidates =
|
let next_candidates =
|
||||||
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
||||||
{
|
|
||||||
let candidates_json = serde_json::to_string(&next_candidates)
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
(
|
(
|
||||||
Some(candidates_json),
|
Some(serde_json::to_string(&next_candidates).unwrap()),
|
||||||
"active".to_string(),
|
"active".to_string(),
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let picks_json = serde_json::to_string(&picks)
|
let picks_json = serde_json::to_string(&picks).unwrap();
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
||||||
@@ -294,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 {
|
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
|
||||||
let pick_order: Vec<String> =
|
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
|
||||||
serde_json::from_str(&session.pick_order).unwrap_or_default();
|
|
||||||
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
|
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
|
||||||
let candidates: Vec<String> = session
|
let candidates: Vec<String> = session
|
||||||
.current_candidates
|
.current_candidates
|
||||||
|
|||||||
+10
-22
@@ -26,7 +26,11 @@ pub async fn get_active_session(
|
|||||||
.map_err(Into::into)
|
.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!(
|
sqlx::query_as::<_, FutChampsSession>(&format!(
|
||||||
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
|
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
|
||||||
))
|
))
|
||||||
@@ -239,30 +243,16 @@ pub async fn claim_rivals_reward(
|
|||||||
pack_defs: &[PackDefinition],
|
pack_defs: &[PackDefinition],
|
||||||
) -> AppResult<serde_json::Value> {
|
) -> AppResult<serde_json::Value> {
|
||||||
// Fetch current season row (must exist)
|
// Fetch current season row (must exist)
|
||||||
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
|
let row: Option<(i64, i64, i64)> = sqlx::query_as(
|
||||||
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
|
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
|
||||||
FROM seasons WHERE profile_id = ?",
|
|
||||||
)
|
)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (division, week_claimed, total_pts, last_claimed_at) =
|
let (division, week_claimed, total_pts) =
|
||||||
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
||||||
|
|
||||||
// Enforce 24-hour cooldown between weekly reward claims
|
|
||||||
if let Some(ref last_claimed) = last_claimed_at {
|
|
||||||
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
|
|
||||||
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
|
||||||
if elapsed < chrono::Duration::hours(24) {
|
|
||||||
let hours_remaining = 24 - elapsed.num_hours();
|
|
||||||
return Err(AppError::Conflict(format!(
|
|
||||||
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_week = week_claimed + 1;
|
let next_week = week_claimed + 1;
|
||||||
let coins = rivals_weekly_coins(division);
|
let coins = rivals_weekly_coins(division);
|
||||||
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
||||||
@@ -285,13 +275,11 @@ pub async fn claim_rivals_reward(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
|
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
|
||||||
rivals_last_claimed_at = ? WHERE profile_id = ?",
|
WHERE profile_id = ?",
|
||||||
)
|
)
|
||||||
.bind(next_week)
|
.bind(next_week)
|
||||||
.bind(&now)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
Executable → Regular
+11
-13
@@ -43,12 +43,11 @@ pub async fn refresh_npc_listings(
|
|||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
event_defs: &[EventDefinition],
|
event_defs: &[EventDefinition],
|
||||||
) -> AppResult<usize> {
|
) -> AppResult<usize> {
|
||||||
// Clean up expired listings and previous NPC listings.
|
// Clean up expired and unsold listings
|
||||||
// Player-posted listings (is_npc = 0) are intentionally preserved.
|
|
||||||
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
|
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -108,8 +107,8 @@ pub async fn refresh_npc_listings(
|
|||||||
for listing in &listings_to_insert {
|
for listing in &listings_to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO market_listings \
|
"INSERT INTO market_listings \
|
||||||
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
|
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
|
VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||||
)
|
)
|
||||||
.bind(&listing.id)
|
.bind(&listing.id)
|
||||||
.bind(&listing.card_id)
|
.bind(&listing.card_id)
|
||||||
@@ -191,10 +190,6 @@ pub async fn sell_card(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
req: &SellCardRequest,
|
req: &SellCardRequest,
|
||||||
) -> AppResult<i64> {
|
) -> AppResult<i64> {
|
||||||
if req.price < 0 {
|
|
||||||
return Err(AppError::BadRequest("price must be non-negative".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
chemistry_style, position_override, training_bonus \
|
chemistry_style, position_override, training_bonus \
|
||||||
@@ -290,9 +285,10 @@ pub async fn get_listings_by_seller(
|
|||||||
let with_cards = listings
|
let with_cards = listings
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|l| {
|
.filter_map(|l| {
|
||||||
card_db
|
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
|
||||||
.get(&l.card_id)
|
listing: l,
|
||||||
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
|
card: card.clone(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(with_cards)
|
Ok(with_cards)
|
||||||
@@ -308,7 +304,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
|
|||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.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 = ?")
|
sqlx::query("DELETE FROM market_listings WHERE id = ?")
|
||||||
.bind(listing_id)
|
.bind(listing_id)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use crate::{
|
|||||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
objective::ObjectiveDefinition,
|
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};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
|
|
||||||
const FORMATIONS: &[&str] = &[
|
const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"];
|
||||||
"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.
|
/// 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 {
|
let (min_overall, names): (u8, &[&str]) = match difficulty {
|
||||||
"professional" => (
|
"professional" => (
|
||||||
70,
|
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" => (
|
"world_class" => (
|
||||||
78,
|
78,
|
||||||
&["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"],
|
&[
|
||||||
|
"Elite Stars FC",
|
||||||
|
"Champions Select",
|
||||||
|
"Premier XI",
|
||||||
|
"Galaxy United",
|
||||||
|
"Titan FC",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
"legendary" => (
|
"legendary" => (
|
||||||
85,
|
85,
|
||||||
&["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"],
|
&[
|
||||||
|
"Legends United",
|
||||||
|
"Ultimate XI",
|
||||||
|
"Gold Standard FC",
|
||||||
|
"The Icons",
|
||||||
|
"Heritage FC",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
"ultimate" => (
|
"ultimate" => (
|
||||||
90,
|
90,
|
||||||
&["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
|
&[
|
||||||
|
"Apex XI",
|
||||||
|
"Pantheon FC",
|
||||||
|
"Gods of FUT",
|
||||||
|
"Invincibles Select",
|
||||||
|
"Eternal XI",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_ => (
|
_ => (
|
||||||
55,
|
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();
|
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
||||||
indices.shuffle(&mut rng);
|
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() {
|
let squad_rating = if cards.is_empty() {
|
||||||
0
|
0
|
||||||
@@ -94,12 +129,6 @@ pub async fn process_match(
|
|||||||
obj_defs: &[ObjectiveDefinition],
|
obj_defs: &[ObjectiveDefinition],
|
||||||
ach_defs: &[AchievementDefinition],
|
ach_defs: &[AchievementDefinition],
|
||||||
) -> AppResult<MatchRewardResult> {
|
) -> AppResult<MatchRewardResult> {
|
||||||
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
|
||||||
return Err(crate::error::AppError::BadRequest(
|
|
||||||
"goals_for and goals_against must each be between 0 and 99".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let outcome = if req.goals_for > req.goals_against {
|
let outcome = if req.goals_for > req.goals_against {
|
||||||
"win"
|
"win"
|
||||||
} else if req.goals_for == req.goals_against {
|
} else if req.goals_for == req.goals_against {
|
||||||
@@ -147,11 +176,18 @@ pub async fn process_match(
|
|||||||
|
|
||||||
for ev in &level_ups {
|
for ev in &level_ups {
|
||||||
let body = if let Some(ref pack) = ev.pack_granted {
|
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 {
|
} 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(
|
statistics::record_match(
|
||||||
@@ -190,20 +226,20 @@ pub async fn process_match(
|
|||||||
objectives_updated.append(&mut c);
|
objectives_updated.append(&mut c);
|
||||||
|
|
||||||
for obj_id in &objectives_updated {
|
for obj_id in &objectives_updated {
|
||||||
let display_name = obj_defs
|
let title = "Objective complete!";
|
||||||
.iter()
|
let body = format!(
|
||||||
.find(|d| &d.id == obj_id)
|
"\"{}\" is now complete. Claim your reward in Objectives.",
|
||||||
.map(|d| d.title.as_str())
|
obj_id
|
||||||
.unwrap_or(obj_id.as_str());
|
);
|
||||||
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
|
let _ = notification::create(pool, "objective_complete", title, &body).await;
|
||||||
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
||||||
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
||||||
|
|
||||||
for owned_id in &expired_loans {
|
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;
|
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,14 +254,21 @@ pub async fn process_match(
|
|||||||
SeasonResult::Relegated => "Relegated",
|
SeasonResult::Relegated => "Relegated",
|
||||||
SeasonResult::Maintained => "Maintained",
|
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 _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let achievements_unlocked =
|
let achievements_unlocked = achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
|
||||||
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
|
.await
|
||||||
.await
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(MatchRewardResult {
|
Ok(MatchRewardResult {
|
||||||
match_record,
|
match_record,
|
||||||
|
|||||||
+2
-2
@@ -2,18 +2,18 @@ pub mod achievement;
|
|||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
pub mod checkin;
|
pub mod checkin;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod notification;
|
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod season;
|
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
|
pub mod notification;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sbc;
|
pub mod sbc;
|
||||||
|
pub mod season;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod squad_rules;
|
pub mod squad_rules;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub async fn increment_metric(
|
|||||||
|
|
||||||
for def in defs
|
for def in defs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.metric.as_str() == metric)
|
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
|
||||||
{
|
{
|
||||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ pub async fn open_pack(
|
|||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
let r = c.rarity.as_str();
|
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||||
rarities.contains(&r.to_string())
|
rarities.contains(&r)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -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<_>>())
|
let card_ids_json =
|
||||||
.unwrap_or_default();
|
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||||
|
|||||||
+13
-23
@@ -5,41 +5,33 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
|
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
||||||
/// one profile row per `game_id`, so we take the earliest for that game.
|
|
||||||
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
|
|
||||||
sqlx::query_as::<_, Profile>(
|
sqlx::query_as::<_, Profile>(
|
||||||
"SELECT id, username, level, xp, game_id, created_at, updated_at \
|
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
||||||
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
||||||
)
|
)
|
||||||
.bind(game_id)
|
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
|
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
||||||
// Single-player per game: one profile per game_id, not one globally.
|
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
||||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
|
||||||
.bind(game_id)
|
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if existing > 0 {
|
if existing > 0 {
|
||||||
return Err(AppError::Conflict(
|
return Err(AppError::Conflict(
|
||||||
"a profile already exists for this game; OpenFUT is single-player per game".into(),
|
"a profile already exists; OpenFUT is single-player only".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = Profile::new(username, game_id);
|
let profile = Profile::new(username);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
)
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
.bind(&profile.username)
|
.bind(&profile.username)
|
||||||
.bind(profile.level)
|
.bind(profile.level)
|
||||||
.bind(profile.xp)
|
.bind(profile.xp)
|
||||||
.bind(&profile.game_id)
|
|
||||||
.bind(profile.created_at)
|
.bind(profile.created_at)
|
||||||
.bind(profile.updated_at)
|
.bind(profile.updated_at)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -67,13 +59,7 @@ pub async fn add_xp_with_levelup(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
xp_to_add: i64,
|
xp_to_add: i64,
|
||||||
) -> AppResult<Vec<LevelUpEvent>> {
|
) -> AppResult<Vec<LevelUpEvent>> {
|
||||||
let profile = sqlx::query_as::<_, Profile>(
|
let profile = get_active_profile(pool).await?;
|
||||||
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
|
||||||
let old_level = level_for_xp(profile.xp);
|
let old_level = level_for_xp(profile.xp);
|
||||||
let new_total_xp = profile.xp + xp_to_add;
|
let new_total_xp = profile.xp + xp_to_add;
|
||||||
let new_level = level_for_xp(new_total_xp);
|
let new_level = level_for_xp(new_total_xp);
|
||||||
@@ -100,7 +86,11 @@ pub async fn add_xp_with_levelup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(profile_id, new_level = lvl, coins, "level up");
|
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)
|
Ok(events)
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ pub async fn record_match(
|
|||||||
// Grant rewards
|
// Grant rewards
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
if let Some(pack_def) = pack_id {
|
if let Some(pack_def) = pack_id {
|
||||||
|
let dummy_pack_id = Uuid::new_v4().to_string();
|
||||||
|
// Grant via pack system so it shows in inventory
|
||||||
|
let _ = dummy_pack_id; // will use grant_pack instead
|
||||||
pack::grant_pack(pool, club_id, pack_def).await?;
|
pack::grant_pack(pool, club_id, pack_def).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
|
|||||||
pub async fn validate_formation(
|
pub async fn validate_formation(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
club_id: &str,
|
|
||||||
players: &[SquadPlayerInput],
|
players: &[SquadPlayerInput],
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||||
@@ -87,13 +86,12 @@ pub async fn validate_formation(
|
|||||||
let mut gk_count = 0usize;
|
let mut gk_count = 0usize;
|
||||||
for sp in &starters {
|
for sp in &starters {
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?",
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&sp.owned_card_id)
|
.bind(&sp.owned_card_id)
|
||||||
.bind(club_id)
|
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
|
||||||
|
|
||||||
if let Some(card) = card_db.get(&owned.card_id) {
|
if let Some(card) = card_db.get(&owned.card_id) {
|
||||||
if card.position == "GK" {
|
if card.position == "GK" {
|
||||||
|
|||||||
@@ -16,14 +16,12 @@ pub const MAX_TRAINING_BONUS: i64 = 3;
|
|||||||
pub const POSITION_CHANGE_COST: i64 = 500;
|
pub const POSITION_CHANGE_COST: i64 = 500;
|
||||||
|
|
||||||
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
||||||
sqlx::query_as::<_, OwnedCard>(&format!(
|
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
|
||||||
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
.bind(owned_card_id)
|
||||||
))
|
.bind(club_id)
|
||||||
.bind(owned_card_id)
|
.fetch_optional(pool)
|
||||||
.bind(club_id)
|
.await?
|
||||||
.fetch_optional(pool)
|
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a chemistry style to an owned card.
|
/// Apply a chemistry style to an owned card.
|
||||||
@@ -64,8 +62,8 @@ pub async fn change_position(
|
|||||||
new_position: &str,
|
new_position: &str,
|
||||||
) -> AppResult<OwnedCard> {
|
) -> AppResult<OwnedCard> {
|
||||||
let valid_positions = [
|
let valid_positions = [
|
||||||
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
|
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
|
||||||
"CF", "ST",
|
"ST",
|
||||||
];
|
];
|
||||||
if !valid_positions.contains(&new_position) {
|
if !valid_positions.contains(&new_position) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
|
|||||||
+456
-178
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user