2 Commits

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

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

Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
2026-08-11 21:22:57 +00:00
funman300 eab522a1eb squad: transactional replace_squad + a game-rules boundary for evaluation
Driven by a retail FIFA 17 capture: the client sends the WHOLE squad on
every save (~2KB, every slot/item/kit number), and a user swapping two
players produced nine changed slots across two saves. Slot deltas
therefore do not describe intent, so the only honest semantic operation
is "this is the squad now".

replace_squad, and why save_squad no longer has its own write path
------------------------------------------------------------------
save_squad UPDATEd the squad, DELETEd every squad_players row, then
INSERTed the new ones one at a time -- all outside a transaction. A
failure part-way through left a squad with some old players deleted and
only some new ones written: a state nobody asked for and no client can
detect. It also never checked that the cards being placed belonged to the
club, and accepted the same card in two slots.

replace_squad validates BEFORE any write (so a rejection leaves the
stored squad untouched) and performs every write in one transaction:

  - card must exist AND belong to this club
  - a card may occupy at most one slot
  - a slot may hold at most one card
  - slot indices must not be negative
  - the squad being replaced must belong to this club

save_squad is now a thin wrapper over it. That deliberately tightens the
existing Core REST route -- it now validates ownership and rejects
duplicates. Those were bugs, and two write paths with different
guarantees is how the stricter one gets bypassed.

A cross-club card is reported as NotFound, not Forbidden: whether a card
exists in someone else's club is not the caller's business.

Game-rules boundary
-------------------
Core contained calculate_chemistry -- a full FUT-style link-scoring
formula. Chemistry is game-specific and changed between FIFA
generations, so a formula compiled into generic Core quietly makes Core a
FIFA-something server.

It now sits behind SquadRules, with the existing implementation preserved
byte-for-byte in behaviour as DefaultSquadRules ("openfut-default-v2").
Rules take a resolved SquadSnapshot of pure data rather than a pool and a
card database, so they are synchronous, testable without fixtures, and
cannot reach Core's storage. Fifa17SquadRules is deliberately NOT
written: the algorithm is unproven and inventing one is worse than having
none.

Client-reported values
----------------------
FIFA sends its own chemistry/rating/starRating. ClientReportedEvaluation
is a DIFFERENT TYPE from SquadEvaluation, so assigning one where the
other belongs does not compile. Disagreement is reported through
EvaluationComparison and never reconciled in either direction -- the
server's value stands and the mismatch is surfaced for investigation
against the exact squad that produced it.

Evidence
--------
17 unit tests, 113 in the crate, 7/7 mutations killed including
"ownership check removed", "replacement becomes a merge" and
"client-reported chemistry becomes the server value".

Scope note: `cargo fmt` without -p reformatted ~27 unrelated files; those
were reverted so this commit touches only the squad path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:56:33 +00:00
41 changed files with 179 additions and 405 deletions
-5
View File
@@ -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);
-3
View File
@@ -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;
-14
View File
@@ -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);
+1
View File
@@ -5,6 +5,7 @@ pub struct Config {
pub listen_addr: String,
pub database_url: String,
pub data_dir: String,
#[allow(dead_code)]
pub max_connections: u32,
}
+2 -2
View File
@@ -8,11 +8,11 @@ use tracing::info;
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);
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.max_connections(5)
.connect_with(opts)
.await?;
sqlx::query("PRAGMA journal_mode=WAL")
-51
View File
@@ -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))
}
}
-1
View File
@@ -2,7 +2,6 @@ pub mod app;
pub mod config;
pub mod db;
pub mod error;
pub mod extractors;
pub mod middleware;
pub mod modding;
pub mod models;
+1 -1
View File
@@ -18,7 +18,7 @@ async fn main() -> Result<()> {
let cfg = config::Config::from_env()?;
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?;
seed::maybe_seed(&pool).await?;
+24 -1
View File
@@ -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)
}
+2
View File
@@ -1,2 +1,4 @@
//! Modding support: load JSON data files from the data/ directory.
//! All game content (cards, packs, objectives, SBCs) is data-driven.
pub mod loader;
-14
View File
@@ -13,20 +13,6 @@ pub enum Rarity {
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).
///
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
-13
View File
@@ -21,19 +21,6 @@ pub enum ObjectiveMetric {
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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectiveDefinition {
+1 -5
View File
@@ -8,22 +8,18 @@ pub struct Profile {
pub username: String,
pub level: 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 updated_at: DateTime<Utc>,
}
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();
Self {
id: Uuid::new_v4().to_string(),
username: username.into(),
level: 1,
xp: 0,
game_id: game_id.into(),
created_at: now,
updated_at: now,
}
+2 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{extract::State, Json};
use serde_json::{json, Value};
@@ -8,8 +7,8 @@ use crate::{
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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
+28 -85
View File
@@ -1,11 +1,9 @@
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
extractors::GameId,
error::AppResult,
models::{club::Club, profile::CreateProfileRequest},
seed,
services::{club as club_svc, profile as profile_svc},
@@ -13,12 +11,11 @@ use crate::{
pub async fn post_auth_local(
State(state): State<AppState>,
game: GameId,
Json(req): Json<CreateProfileRequest>,
) -> AppResult<Json<Value>> {
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);
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?
/// Returns 200 `{ "has_profile": true/false }` without erroring.
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game.as_str())
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
.fetch_one(&state.pool)
.await?;
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.
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
/// accidental or unauthenticated calls. Deletes every user-data table in
/// dependency order; schema (migrations) is preserved.
pub async fn post_auth_reset(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ResetRequest>,
) -> AppResult<Json<Value>> {
if req.confirm.as_deref() != Some("reset") {
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 [
/// Deletes every user-data table in dependency order. The schema tables
/// (migrations) are left intact; calling POST /auth/local afterwards
/// creates a new profile.
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Delete in reverse-dependency order to satisfy FK constraints
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
let tables = [
"player_achievements",
"notifications",
"fut_champs_sessions",
"sbc_submissions",
"objective_progress",
"position_goals",
"statistics",
"seasons",
"season_history",
"matches",
"market_listings",
"squad_players",
"squads",
"owned_cards",
"packs",
"events",
"draft_sessions",
"daily_checkins",
] {
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
.bind(&profile_id)
"settings",
"clubs",
"profiles",
];
for table in &tables {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&state.pool)
.await?;
}
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
.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");
tracing::info!("Full game reset performed");
Ok(Json(json!({
"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."
})))
}
+3 -6
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -61,7 +60,7 @@ pub async fn get_cards(
let rarity_ok = query
.rarity
.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);
let pos_ok = query
.position
@@ -100,10 +99,9 @@ pub async fn get_cards(
pub async fn get_collection(
State(state): State<AppState>,
game: GameId,
Query(query): Query<OwnedItemQuery>,
) -> 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 owned = sqlx::query_as::<_, OwnedCard>(
@@ -160,10 +158,9 @@ pub async fn get_collection(
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
pub async fn delete_owned_card(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
) -> 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 owned = sqlx::query_as::<_, OwnedCard>(
+9 -11
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
@@ -9,8 +8,8 @@ use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club))
}
@@ -23,10 +22,9 @@ pub struct UpdateClubRequest {
pub async fn put_club(
State(state): State<AppState>,
game: GameId,
Json(req): Json<UpdateClubRequest>,
) -> 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 updated = club_svc::update_club(
&state.pool,
@@ -38,8 +36,8 @@ pub async fn put_club(
Ok(Json(json!({ "club": updated })))
}
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({
"available": status.available,
@@ -50,8 +48,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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
Ok(Json(json!({
@@ -62,8 +60,8 @@ 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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
+6 -7
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{extract::State, Json};
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
@@ -10,8 +9,8 @@ use crate::{
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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let 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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() })))
}
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
+4 -9
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -40,10 +39,9 @@ pub async fn get_draft_squad(
/// until all 11 slots are filled.
pub async fn post_draft_start(
State(state): State<AppState>,
game: GameId,
Query(query): Query<DraftQuery>,
) -> 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 session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session))
@@ -52,10 +50,9 @@ pub async fn post_draft_start(
/// Get the current state of a draft session.
pub async fn get_draft_session(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
) -> 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?;
Ok(Json(session))
}
@@ -72,11 +69,10 @@ pub struct PickRequest {
/// and rewards (coins + optional pack) are granted automatically.
pub async fn post_draft_pick(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
Json(req): Json<PickRequest>,
) -> 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 session = draft_svc::pick_card(
&state.pool,
@@ -93,10 +89,9 @@ pub async fn post_draft_pick(
/// Abandon an active draft session. No rewards are granted.
pub async fn post_draft_abandon(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
) -> 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?;
Ok(Json(result))
}
+10 -13
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -13,8 +12,8 @@ use crate::{
};
/// 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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -24,8 +23,8 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
}
/// 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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -43,11 +42,10 @@ pub struct ChampsMatchRequest {
/// POST /fut-champs/:session_id/result — record a match in this session.
pub async fn post_champs_result(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
Json(req): Json<ChampsMatchRequest>,
) -> 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(
&state.pool,
@@ -77,10 +75,9 @@ pub async fn post_champs_result(
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
pub async fn post_claim_champs_rewards(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
) -> 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 result = champs_svc::claim_rewards(
@@ -96,8 +93,8 @@ pub async fn post_claim_champs_rewards(
}
/// GET /fut-champs/history — past sessions, newest first.
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -107,8 +104,8 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
}
/// 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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Ensure a season row exists
+7 -11
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -13,8 +12,8 @@ use crate::{
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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
@@ -53,10 +52,9 @@ pub async fn get_market(
pub async fn post_market_buy(
State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyListingRequest>,
) -> 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 card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -67,10 +65,9 @@ pub async fn post_market_buy(
pub async fn post_market_sell(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SellCardRequest>,
) -> 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 new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -86,8 +83,8 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
}
/// 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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
@@ -96,10 +93,9 @@ pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> App
/// Cancel a player-posted listing and return the card to the collection.
pub async fn delete_market_listing(
State(state): State<AppState>,
game: GameId,
Path(listing_id): Path<String>,
) -> 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?;
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
Ok(Json(json!({ "cancelled": listing_id })))
+2 -5
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Query, State},
Json,
@@ -21,10 +20,9 @@ pub struct MatchHistoryQuery {
pub async fn get_matches(
State(state): State<AppState>,
game: GameId,
Query(query): Query<MatchHistoryQuery>,
) -> 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 matches = if let Some(mode) = &query.mode {
@@ -65,10 +63,9 @@ pub async fn get_opponent(
pub async fn post_match_result(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitMatchRequest>,
) -> 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 result =
+2 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -17,8 +16,8 @@ use crate::{
/// notifications (unclaimed objectives, expiring loans, season ending soon).
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
/// have `id: null` and are always considered unread.
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// ── Persistent notifications ─────────────────────────────────────────────
+5 -9
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -12,8 +11,8 @@ use crate::{
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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
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(
State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>,
) -> 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 =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let obj = all
@@ -36,10 +34,9 @@ pub async fn get_objective(
pub async fn post_claim_objective_by_id(
State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>,
) -> 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 reward = obj_svc::claim_objective(
&state.pool,
@@ -59,10 +56,9 @@ pub struct ClaimRequest {
pub async fn post_claim_objective(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ClaimRequest>,
) -> 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 reward = obj_svc::claim_objective(
&state.pool,
+6 -9
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -20,10 +19,9 @@ pub struct BuyPackRequest {
pub async fn post_buy_pack(
State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyPackRequest>,
) -> 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 pack = pack_svc::buy_pack(
&state.pool,
@@ -56,8 +54,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
Ok(Json(json!({ "packs": store })))
}
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let 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.
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let 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(
State(state): State<AppState>,
game: GameId,
Path(pack_id): Path<String>,
) -> 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 result = pack_svc::open_pack(
+2 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
@@ -8,8 +7,8 @@ use crate::{
use axum::{extract::State, Json};
use serde_json::{json, Value};
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let computed_level = level_for_xp(profile.xp);
let next_level = computed_level + 1;
+1 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -30,10 +29,9 @@ pub async fn get_sbc(
pub async fn post_sbc_submit(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitSbcRequest>,
) -> 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 result = sbc_svc::submit_sbc(
+8 -12
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -12,8 +11,8 @@ use crate::{
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>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (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)))
}
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
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(
State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>,
) -> 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 (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(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SaveSquadRequest>,
) -> 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?;
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?;
@@ -61,10 +58,9 @@ pub async fn post_squad(
pub async fn delete_squad(
State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>,
) -> 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?;
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
Ok(Json(json!({ "deleted": squad_id })))
+3 -5
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Query, State},
Json,
@@ -13,8 +12,8 @@ use crate::{
services::{profile as profile_svc, statistics as stats_svc},
};
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).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?;
@@ -37,10 +36,9 @@ pub struct HistoryQuery {
pub async fn get_statistics_history(
State(state): State<AppState>,
game: GameId,
Query(query): Query<HistoryQuery>,
) -> 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 matches = sqlx::query_as::<_, Match>(
+3 -7
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -28,11 +27,10 @@ pub struct ApplyChemStyleRequest {
/// POST /collection/:owned_card_id/chemistry-style
pub async fn post_apply_chemistry_style(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
Json(req): Json<ApplyChemStyleRequest>,
) -> 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 updated = upgrade_svc::apply_chemistry_style(
@@ -65,11 +63,10 @@ pub struct ChangePositionRequest {
/// POST /collection/:owned_card_id/position — costs 500 coins.
pub async fn post_change_position(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
Json(req): Json<ChangePositionRequest>,
) -> 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 updated = upgrade_svc::change_position(
@@ -100,11 +97,10 @@ pub struct ApplyTrainingRequest {
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
pub async fn post_apply_training(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
Json(req): Json<ApplyTrainingRequest>,
) -> 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 updated =
+11
View File
@@ -42,6 +42,17 @@ impl CardDb {
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> {
self.cards.values().collect()
}
+4 -14
View File
@@ -88,18 +88,13 @@ pub async fn start_draft(
let first_position = &pick_order[0];
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 {
id: Uuid::new_v4().to_string(),
profile_id: profile_id.to_string(),
difficulty: difficulty.to_string(),
pick_order: pick_order_json,
pick_order: serde_json::to_string(&pick_order).unwrap(),
picks: "[]".to_string(),
current_candidates: Some(candidates_json),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
status: "active".to_string(),
reward_coins: 0,
reward_pack_id: None,
@@ -190,22 +185,17 @@ pub async fn pick_card(
let next_pos = &pick_order[next_index];
let next_candidates =
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(),
0,
None,
0,
None,
)
}
};
let picks_json = serde_json::to_string(&picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
let picks_json = serde_json::to_string(&picks).unwrap();
sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
+5 -21
View File
@@ -239,30 +239,16 @@ pub async fn claim_rivals_reward(
pack_defs: &[PackDefinition],
) -> AppResult<serde_json::Value> {
// Fetch current season row (must exist)
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
FROM seasons WHERE profile_id = ?",
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.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()))?;
// 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 coins = rivals_weekly_coins(division);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
@@ -285,13 +271,11 @@ pub async fn claim_rivals_reward(
None
};
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
rivals_last_claimed_at = ? WHERE profile_id = ?",
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
WHERE profile_id = ?",
)
.bind(next_week)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
+4 -9
View File
@@ -43,12 +43,11 @@ pub async fn refresh_npc_listings(
card_db: &CardDb,
event_defs: &[EventDefinition],
) -> AppResult<usize> {
// Clean up expired listings and previous NPC listings.
// Player-posted listings (is_npc = 0) are intentionally preserved.
// Clean up expired and unsold listings
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool)
.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)
.await?;
@@ -108,8 +107,8 @@ pub async fn refresh_npc_listings(
for listing in &listings_to_insert {
sqlx::query(
"INSERT INTO market_listings \
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
VALUES (?, ?, ?, ?, ?, ?, 0)",
)
.bind(&listing.id)
.bind(&listing.card_id)
@@ -191,10 +190,6 @@ pub async fn sell_card(
club_id: &str,
req: &SellCardRequest,
) -> 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>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
+3 -13
View File
@@ -94,12 +94,6 @@ pub async fn process_match(
obj_defs: &[ObjectiveDefinition],
ach_defs: &[AchievementDefinition],
) -> 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 {
"win"
} else if req.goals_for == req.goals_against {
@@ -190,13 +184,9 @@ pub async fn process_match(
objectives_updated.append(&mut c);
for obj_id in &objectives_updated {
let display_name = obj_defs
.iter()
.find(|d| &d.id == obj_id)
.map(|d| d.title.as_str())
.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", "Objective complete!", &body).await;
let title = "Objective complete!";
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
let _ = notification::create(pool, "objective_complete", title, &body).await;
}
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn increment_metric(
for def in defs
.iter()
.filter(|d| d.metric.as_str() == metric)
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
{
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 = ?"
+2 -2
View File
@@ -90,8 +90,8 @@ pub async fn open_pack(
.all()
.into_iter()
.filter(|c| {
let r = c.rarity.as_str();
rarities.contains(&r.to_string())
let r = format!("{:?}", c.rarity).to_lowercase();
rarities.contains(&r)
})
.cloned()
.collect()
+8 -22
View File
@@ -5,41 +5,33 @@ use crate::{
};
use chrono::Utc;
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
/// 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> {
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, game_id, created_at, updated_at \
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
)
.bind(game_id)
.fetch_optional(pool)
.await?
.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> {
// Single-player per game: one profile per game_id, not one globally.
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game_id)
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
.fetch_one(pool)
.await?;
if existing > 0 {
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(
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(&profile.id)
.bind(&profile.username)
.bind(profile.level)
.bind(profile.xp)
.bind(&profile.game_id)
.bind(profile.created_at)
.bind(profile.updated_at)
.execute(pool)
@@ -67,13 +59,7 @@ pub async fn add_xp_with_levelup(
club_id: &str,
xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> {
let profile = sqlx::query_as::<_, Profile>(
"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 profile = get_active_profile(pool).await?;
let old_level = level_for_xp(profile.xp);
let new_total_xp = profile.xp + xp_to_add;
let new_level = level_for_xp(new_total_xp);
+3
View File
@@ -101,6 +101,9 @@ pub async fn record_match(
// Grant rewards
club::add_coins(pool, club_id, coins).await?;
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?;
}
+2 -4
View File
@@ -72,7 +72,6 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
pub async fn validate_formation(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
players: &[SquadPlayerInput],
) -> AppResult<()> {
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;
for sp in &starters {
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(club_id)
.fetch_optional(pool)
.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 card.position == "GK" {
+4 -8
View File
@@ -1404,14 +1404,10 @@ async fn test_rivals_reward_increments_week_counter() {
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
})).await;
// First claim succeeds
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["week_number"], 1, "first claim should be week 1");
// Immediate re-claim is blocked by the 24-hour cooldown
let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
assert_eq!(s, StatusCode::CONFLICT, "re-claim within 24h should be rejected");
assert_eq!(json["week_number"], 2, "second claim should be week 2");
}
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
@@ -1715,7 +1711,7 @@ async fn test_auth_reset_clears_profile() {
})).await;
// Reset
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["reset"], true);
@@ -1733,7 +1729,7 @@ async fn test_auth_reset_allows_new_profile() {
let app = build_test_app().await;
auth(&app, "FirstProfile").await;
json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
json_post(&app, "/auth/reset", serde_json::json!({})).await;
// Should be able to create a new profile after reset
let (status, json) = json_post(&app, "/auth/local",