6 Commits

Author SHA1 Message Date
funman300 2fb835200f style(core): cargo fmt match/club agent additions
CI / Build, lint & test (push) Successful in 4m1s
2026-08-20 17:59:02 +00:00
funman300 5f9f556af8 feat(app): register GET/PUT /club/manager routes 2026-08-20 16:43:51 +00:00
funman300 9036f5f411 feat(club): ownership-backed squad manager assignment
Add a generic, durable squad->manager assignment (migration 0023
squad_managers) so a manager persists across squad save, reload, and
server restart, backed by authoritative Core owned_cards.

- squad_managers(squad_id PK, owned_card_id, updated_at) with ON DELETE
  CASCADE on both FKs: quick-selling the manager auto-clears the
  assignment (no resurrection); one manager per squad (no duplicates).
- club::{get,set,clear}_squad_manager validate club ownership of both the
  squad and the card, and re-check ownership on read (defends against a
  stale row left by a market transfer).
- GET/PUT /club/manager routes expose the assignment; FIFA wire meaning
  stays in the adapter.

Tests: persistence across reload+restart (headline), reassignment
replace/no-duplicate, clear/no-resurrection, cascade on quick-sell,
foreign-card rejection.
2026-08-20 16:43:28 +00:00
funman300 b0306a9b1d feat(match): atomic exactly-once match-completion transaction
Add complete_match: one BEGIN/COMMIT that validates identity + result,
enforces a durable (profile_id, match_identity) uniqueness guard
(migration 0022 match_completions), persists match history, and grants
coins + XP/level-ups + W/D/L/DNF statistics + objectives + achievements
exactly once. Any failure rolls the whole match back (no compensating
cleanup). Handles sequential/restart/concurrent replay, conflicting
re-report (first result canonical), DNF (loss economics, own stat
bucket) and no-contest (zero economic effect).

Adds tx-scoped variants: statistics::record_match_tx/
record_position_goals_tx, objective::increment_metric_tx,
achievement::check_and_unlock_tx. New MatchResultKind/CompleteMatchRequest/
MatchCompletionResult models + POST /matches/complete route.
2026-08-20 16:38:16 +00:00
funman300 a034e74c16 style(core): apply cargo fmt across routes, services, models, tests
CI / Build, lint & test (push) Successful in 2m19s
Pure rustfmt reflow (import grouping, array/match-arm/call-arg wrapping,
alphabetized module decls, comment realignment). No semantic change:
full-diff and `git diff -w` both confirm logic byte-identical to 271c363;
workspace builds and all 74 core tests pass. Retained pre-existing WIP
brought forward after verification.
2026-08-20 16:05:41 +00:00
funman300 271c3639ed feat(sbc): make submissions atomic and durable
CI / Build, lint & test (push) Failing after 52s
2026-08-18 18:26:23 +00:00
40 changed files with 3322 additions and 364 deletions
+1
View File
@@ -37,3 +37,4 @@ axum-macros = "0.4"
axum-test = "14"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tempfile = "3"
@@ -0,0 +1,14 @@
-- Durable replay and non-repeatable-completion guards for atomic SBC submissions.
-- Existing successful rows are treated as non-repeatable; if historical data already
-- violates that invariant the migration fails rather than silently discarding history.
ALTER TABLE sbc_submissions ADD COLUMN repeatable INTEGER NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX idx_sbc_nonrepeatable_completion
ON sbc_submissions(profile_id, sbc_id)
WHERE passed = 1 AND repeatable = 0;
-- submitted_card_ids is stored in canonical sorted order by the writer. This rejects
-- stale retries of the same card set even for explicitly repeatable challenges.
CREATE UNIQUE INDEX idx_sbc_submission_replay
ON sbc_submissions(profile_id, sbc_id, submitted_card_ids)
WHERE passed = 1;
+9
View File
@@ -0,0 +1,9 @@
-- Core owns durable per-profile working squads for SBC challenges. The FIFA17
-- adapter maps its numeric challenge id to the opaque generic sbc_id.
CREATE TABLE sbc_challenge_squads (
profile_id TEXT NOT NULL REFERENCES profiles(id),
sbc_id TEXT NOT NULL,
owned_card_ids TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, sbc_id)
);
+29
View File
@@ -0,0 +1,29 @@
-- Durable economic idempotency for match completion.
--
-- One economic effect per (profile_id, match_identity), independent of any HTTP
-- receipt idempotency the game host/adapter layers on top. A sequential replay,
-- a restart replay, a concurrent duplicate, or a conflicting re-report of the
-- same match all collide on this UNIQUE and are refused BEFORE any coins, XP,
-- statistics, objectives, or achievements are applied — the first completion is
-- the one canonical result, the rest are idempotent no-ops.
--
-- `match_identity` is opaque to Core: the game adapter/host derives a stable
-- per-match token (e.g. the FIFA17 match-create id). Core never parses it.
CREATE TABLE match_completions (
id TEXT PRIMARY KEY NOT NULL,
profile_id TEXT NOT NULL REFERENCES profiles(id),
match_identity TEXT NOT NULL,
result TEXT NOT NULL, -- canonical: win | draw | loss | dnf | no_contest
coins_awarded INTEGER NOT NULL DEFAULT 0,
xp_awarded INTEGER NOT NULL DEFAULT 0,
match_id TEXT NOT NULL REFERENCES matches(id),
completed_at TEXT NOT NULL,
UNIQUE(profile_id, match_identity)
);
CREATE INDEX idx_match_completions_profile ON match_completions(profile_id);
-- W/D/L already live on `statistics`; add the DNF (abandon/quit) bucket so the
-- four match outcomes are mutually-exclusive counters. A did-not-finish is
-- economically a loss but is tallied here, not in `matches_lost`.
ALTER TABLE statistics ADD COLUMN matches_dnf INTEGER NOT NULL DEFAULT 0;
+25
View File
@@ -0,0 +1,25 @@
-- Squad manager assignment: an owned item assigned as a squad's manager.
--
-- Generic, game-neutral canonical state. Core does not know what a "manager"
-- means to any game; it only records that one owned item (`owned_card_id`) is
-- assigned to a squad in the manager role. The FIFA 17 adapter owns the wire
-- meaning (itemType "manager", contract, chemistry) exactly as it owns player
-- item shaping — Core just persists the ownership-backed assignment durably and
-- atomically, so a manager survives squad save / reload / server restart.
--
-- One manager per squad: `squad_id` is the primary key, so a re-assignment
-- REPLACEs rather than accumulating (no duplicate-manager rows).
--
-- `owned_card_id` references `owned_cards(id)` with ON DELETE CASCADE: quick
-- selling / discarding the manager card (a DELETE on owned_cards) removes the
-- assignment automatically, so a sold manager is never resurrected on the next
-- squad read. Reads additionally re-check the manager still belongs to the club
-- (see `club::get_squad_manager`), defending against a stale row left by a
-- market transfer (which UPDATEs owner rather than deleting).
CREATE TABLE IF NOT EXISTS squad_managers (
squad_id TEXT PRIMARY KEY NOT NULL REFERENCES squads(id) ON DELETE CASCADE,
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_squad_managers_owned ON squad_managers(owned_card_id);
+12
View File
@@ -169,6 +169,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/club/checkin", get(routes::club::get_checkin_status))
.route("/club/checkin", post(routes::club::post_checkin))
.route("/club/milestones", get(routes::club::get_milestones))
// ClubB: squad manager assignment (append-only; own lines).
.route("/club/manager", get(routes::club::get_squad_manager))
.route("/club/manager", put(routes::club::put_squad_manager))
.route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection))
@@ -250,9 +253,18 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/matches", get(routes::matches::get_matches))
.route("/matches/opponent", get(routes::matches::get_opponent))
.route("/matches/result", post(routes::matches::post_match_result))
.route(
"/matches/complete",
post(routes::matches::post_match_complete),
)
.route("/sbc", get(routes::sbc::get_sbcs))
.route("/sbc/status", get(routes::sbc::get_sbc_status))
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
.route(
"/sbc/:sbc_id/squad",
get(routes::sbc::get_sbc_squad).put(routes::sbc::put_sbc_squad),
)
.route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell))
+80
View File
@@ -11,6 +11,45 @@ pub enum MatchOutcome {
Loss,
}
/// Canonical, game-independent economic result of a completed match. The game
/// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
/// never sees a game-specific reason string.
///
/// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
/// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
/// its own statistics bucket and never in `matches_lost`.
/// * `NoContest` — a voided match. Zero economic effect: no coins, XP, or
/// W/D/L/DNF change; recorded only for history + idempotency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchResultKind {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResultKind {
/// The canonical lowercase token persisted in `matches.outcome` and
/// `match_completions.result`.
pub fn as_str(self) -> &'static str {
match self {
MatchResultKind::Win => "win",
MatchResultKind::Draw => "draw",
MatchResultKind::Loss => "loss",
MatchResultKind::Dnf => "dnf",
MatchResultKind::NoContest => "no_contest",
}
}
/// Whether this result applies any economic effect (coins / XP / statistics /
/// objectives / achievements). `NoContest` is the only non-economic result.
pub fn is_economic(self) -> bool {
!matches!(self, MatchResultKind::NoContest)
}
}
#[derive(Debug, Deserialize)]
pub struct SubmitMatchRequest {
pub squad_id: String,
@@ -87,3 +126,44 @@ pub struct MatchRewardResult {
/// Achievements unlocked as a result of this match.
pub achievements_unlocked: Vec<AchievementDefinition>,
}
/// Request to atomically complete a match exactly once. `match_identity` is the
/// opaque, host-supplied per-match token that keys durable economic idempotency
/// (persona/profile + match_identity). `result` is the canonical outcome the
/// game adapter derived from its wire; `goals_for`/`goals_against` are recorded
/// for history and statistics (0-0 is normal for a DNF/no-contest).
#[derive(Debug, Clone, Deserialize)]
pub struct CompleteMatchRequest {
pub match_identity: String,
pub result: MatchResultKind,
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
#[serde(default)]
pub goal_positions: Option<Vec<String>>,
}
/// Outcome of [`crate::services::match_service::complete_match`].
#[derive(Debug, Serialize)]
pub struct MatchCompletionResult {
/// `true` when THIS call applied the economic effect; `false` on an
/// idempotent replay of an already-completed match (the persisted canonical
/// result is echoed unchanged).
pub applied: bool,
pub match_identity: String,
pub result: MatchResultKind,
pub coins_awarded: i64,
pub xp_awarded: i64,
/// Club balance after completion — echoed so the host can render the wire
/// reward body without a second round-trip.
pub coins_balance: i64,
/// Objectives completed by this match (empty on a replay).
pub objectives_updated: Vec<String>,
/// Level-ups gained from this match's XP (empty on a replay).
pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked by this match (empty on a replay).
pub achievements_unlocked: Vec<AchievementDefinition>,
pub match_record: Match,
}
+3 -3
View File
@@ -1,19 +1,19 @@
pub mod achievement;
pub mod card;
pub mod chemistry_style;
pub mod notification;
pub mod club;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod game_ext;
pub mod event;
pub mod season;
pub mod market;
pub mod match_result;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod reward;
pub mod sbc;
pub mod season;
pub mod squad;
pub mod statistics;
+11 -11
View File
@@ -38,16 +38,16 @@ pub struct CreateProfileRequest {
/// XP required to reach each level (cumulative total from level 1).
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
pub const XP_THRESHOLDS: &[i64] = &[
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
];
/// Compute the level for a given cumulative XP total.
@@ -75,7 +75,7 @@ pub fn coins_for_level(new_level: i64) -> i64 {
/// Pack granted at milestone levels (5, 10, 15, 20, …).
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
match new_level {
5 => Some("bronze_pack"),
5 => Some("bronze_pack"),
10 => Some("silver_pack"),
15 => Some("gold_pack"),
20 => Some("rare_gold_pack"),
+14 -2
View File
@@ -33,16 +33,17 @@ pub struct SbcReward {
pub pack_id: Option<String>,
}
/// DB record of a completed submission
#[allow(dead_code)]
/// DB record of a completed submission.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SbcSubmission {
pub id: String,
pub profile_id: String,
pub club_id: Option<String>,
pub sbc_id: String,
pub submitted_card_ids: String,
pub passed: bool,
pub submitted_at: String,
pub repeatable: bool,
}
#[derive(Debug, Deserialize)]
@@ -51,6 +52,17 @@ pub struct SubmitSbcRequest {
pub owned_card_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveSbcSquadRequest {
pub owned_card_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbcSquadState {
pub sbc_id: String,
pub owned_card_ids: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct SbcResult {
pub passed: bool,
+2
View File
@@ -7,6 +7,7 @@ pub struct Statistics {
pub matches_won: i64,
pub matches_drawn: i64,
pub matches_lost: i64,
pub matches_dnf: i64,
pub goals_scored: i64,
pub goals_conceded: i64,
pub packs_opened: i64,
@@ -25,6 +26,7 @@ impl Statistics {
matches_won: 0,
matches_drawn: 0,
matches_lost: 0,
matches_dnf: 0,
goals_scored: 0,
goals_conceded: 0,
packs_opened: 0,
+10 -3
View File
@@ -8,12 +8,19 @@ 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>> {
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?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id)
.await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
let earned = achievements
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.count();
Ok(Json(json!({
"achievements": achievements,
"earned": earned,
+4 -1
View File
@@ -34,7 +34,10 @@ 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>> {
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())
.fetch_one(&state.pool)
+19 -14
View File
@@ -11,8 +11,7 @@ use crate::{
error::{AppError, AppResult},
models::card::OwnedCard,
services::{
club as club_svc,
economy as economy_svc,
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
@@ -20,11 +19,17 @@ use crate::{
/// Quick-sell value for a card based on overall rating.
fn quick_sell_coins(overall: u8) -> i64 {
if overall >= 85 { 1500 }
else if overall >= 80 { 900 }
else if overall >= 75 { 600 }
else if overall >= 65 { 300 }
else { 150 }
if overall >= 85 {
1500
} else if overall >= 80 {
900
} else if overall >= 75 {
600
} else if overall >= 65 {
300
} else {
150
}
}
#[derive(Debug, Deserialize)]
@@ -96,7 +101,9 @@ pub async fn get_cards(
cards.truncate(limit);
}
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
Ok(Json(
json!({ "cards": cards, "total": total, "returned": cards.len() }),
))
}
pub async fn get_collection(
@@ -119,8 +126,7 @@ pub async fn get_collection(
.filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position =
o.position_override.as_deref().unwrap_or(&def.position);
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
let body = json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
@@ -178,10 +184,9 @@ pub async fn delete_owned_card(
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
let card = state
.card_db
.get(&owned.card_id)
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
})?;
let coins = quick_sell_coins(card.overall);
+69 -30
View File
@@ -3,7 +3,9 @@ use crate::{
app::AppState,
error::AppResult,
models::club::Club,
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
services::{
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
},
};
use axum::{extract::State, Json};
use serde::Deserialize;
@@ -38,7 +40,10 @@ 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>> {
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?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -67,13 +72,12 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
let seasons_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let seasons_completed: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let highest_division: i64 = sqlx::query_scalar(
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
@@ -83,29 +87,25 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
.await
.unwrap_or(10);
let cards_owned: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let sbcs_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
)
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let sbcs_completed: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(Json(json!({
"total_wins": stats.matches_won,
@@ -124,3 +124,42 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
"club_level": club.level,
})))
}
/// The owned card assigned as the active squad's manager, or `null`. Generic:
/// Core returns the ownership-backed assignment; the FIFA 17 adapter shapes the
/// manager wire item from it (itemType/contract/chemistry are adapter concerns).
pub async fn get_squad_manager(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
}
#[derive(Deserialize)]
pub struct SetManagerRequest {
/// The owned card to assign as manager, or `null`/absent to clear it.
pub owned_card_id: Option<String>,
}
/// Assign (or, with a null/absent `owned_card_id`, clear) the active squad's
/// manager. Fail-closed: the card must be owned by this club and the club must
/// have a squad. Returns the resulting assignment.
pub async fn put_squad_manager(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SetManagerRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
match req.owned_card_id {
Some(owned_card_id) => {
club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await?
}
None => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
}
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
}
+35 -11
View File
@@ -37,13 +37,19 @@ 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>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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>> {
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?;
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?;
@@ -53,11 +59,26 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
const NPC_NAMES: &[&str] = &[
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
"Riverside FC",
"City Athletic",
"County United",
"Valley Rangers",
"Harbor Town FC",
"Mountside City",
"Lakewood Athletic",
"Eastbrook United",
"Westfield Rovers",
"Northgate FC",
"Southport Athletic",
"Ironbridge City",
"Milldale United",
"Hillcrest Rangers",
"Bayside FC",
"Thornfield Athletic",
"Greenhill United",
"Coldwater City",
"Redbury Rangers",
"Ashdown FC",
];
// Pick 9 NPC names without repetition using the seeded RNG
@@ -75,10 +96,13 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
// Quality bias: index 0-2 = stronger, 6-8 = weaker
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
let expected_win_rate = 0.2 + quality * 0.6; // 0.2–0.8
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
let wins =
(npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64
* (1.0 - expected_win_rate)
* (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
json!({
"club_name": name,
"wins": wins,
+4 -2
View File
@@ -45,7 +45,8 @@ pub async fn post_draft_start(
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
let session =
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session))
}
@@ -56,7 +57,8 @@ pub async fn get_draft_session(
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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))
}
+18 -11
View File
@@ -9,7 +9,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc},
services::{
club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc,
},
};
/// GET /fut-champs — current active session, or null if none.
@@ -24,7 +26,10 @@ 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>> {
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?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
@@ -96,7 +101,10 @@ 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>> {
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?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
@@ -107,20 +115,19 @@ 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>> {
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?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Ensure a season row exists
season_svc::get_or_create(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rivals_reward(
&state.pool,
&profile.id,
&club.id,
&state.pack_defs,
)
.await?;
let result =
champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
.await?;
Ok(Json(result))
}
+13 -5
View File
@@ -13,14 +13,16 @@ 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>> {
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?;
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() })))
}
#[derive(Deserialize)]
pub struct MarketQuery {
pub min_overall: Option<u8>,
@@ -86,11 +88,17 @@ 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>> {
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?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
let listings =
market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(
json!({ "listings": listings, "total": listings.len() }),
))
}
/// Cancel a player-posted listing and return the card to the collection.
+37 -4
View File
@@ -9,7 +9,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest},
models::match_result::{
CompleteMatchRequest, Match, MatchCompletionResult, MatchRewardResult, SubmitMatchRequest,
},
services::{club as club_svc, match_service, profile as profile_svc},
};
@@ -71,9 +73,40 @@ pub async fn post_match_result(
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result =
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
.await?;
let result = match_service::process_match(
&state.pool,
&profile.id,
&club.id,
&req,
&state.obj_defs,
&state.achievement_defs,
)
.await?;
Ok(Json(result))
}
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
/// economy entry point (the game host routes a finished match here). Idempotent
/// on `(profile, match_identity)`: a replay returns the persisted canonical
/// result with `applied = false` and grants nothing twice.
pub async fn post_match_complete(
State(state): State<AppState>,
game: GameId,
Json(req): Json<CompleteMatchRequest>,
) -> AppResult<Json<MatchCompletionResult>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = match_service::complete_match(
&state.pool,
&profile.id,
&club.id,
&req,
&state.obj_defs,
&state.achievement_defs,
)
.await?;
Ok(Json(result))
}
+1 -1
View File
@@ -5,8 +5,8 @@ pub mod club;
pub mod division;
pub mod draft;
pub mod economy;
pub mod fut_champs;
pub mod events;
pub mod fut_champs;
pub mod health;
pub mod market;
pub mod matches;
+18 -13
View File
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
services::{
club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc,
},
};
/// GET /notifications
@@ -17,7 +19,10 @@ 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>> {
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?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
@@ -93,14 +98,16 @@ pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> A
// Use "type" key for compatibility with dashboard and existing tests.
let all: Vec<Value> = persistent
.iter()
.map(|n| json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
}))
.map(|n| {
json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
})
})
.chain(dynamic.iter().cloned())
.collect();
@@ -125,9 +132,7 @@ pub async fn mark_notification_read(
}
/// POST /notifications/read-all
pub async fn mark_all_notifications_read(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count = notif_svc::mark_all_read(&state.pool).await?;
Ok(Json(json!({ "marked_read": count })))
}
+10 -3
View File
@@ -79,7 +79,10 @@ 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>> {
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?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
@@ -143,8 +146,12 @@ pub async fn post_open_pack(
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?;
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
&state.pool,
&state.achievement_defs,
&profile.id,
&club.id,
)
.await;
Ok(Json(result))
}
+52 -12
View File
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
models::sbc::{SbcResult, SubmitSbcRequest},
models::sbc::{SaveSbcSquadRequest, SbcResult, SubmitSbcRequest},
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
};
@@ -28,6 +28,49 @@ pub async fn get_sbc(
Ok(Json(json!({ "sbc": sbc })))
}
pub async fn get_sbc_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let completions = sbc_svc::completion_counts(&state.pool, &profile.id).await?;
Ok(Json(json!({ "completions": completions })))
}
pub async fn get_sbc_squad(
State(state): State<AppState>,
game: GameId,
Path(sbc_id): Path<String>,
) -> AppResult<Json<Value>> {
if !state
.sbc_defs
.iter()
.any(|definition| definition.id == sbc_id)
{
return Err(AppError::NotFound(format!("SBC '{sbc_id}' not found")));
}
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let squad = sbc_svc::load_sbc_squad(&state.pool, &profile.id, &sbc_id).await?;
Ok(Json(json!({ "squad": squad })))
}
pub async fn put_sbc_squad(
State(state): State<AppState>,
game: GameId,
Path(sbc_id): Path<String>,
Json(req): Json<SaveSbcSquadRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squad = sbc_svc::save_sbc_squad(
&state.pool,
&state.sbc_defs,
&profile.id,
&club.id,
&sbc_id,
&req.owned_card_ids,
)
.await?;
Ok(Json(json!({ "squad": squad })))
}
pub async fn post_sbc_submit(
State(state): State<AppState>,
game: GameId,
@@ -38,20 +81,17 @@ pub async fn post_sbc_submit(
let result = sbc_svc::submit_sbc(
&state.pool,
&state.card_db,
&state.sbc_defs,
&state.obj_defs,
&profile.id,
&club.id,
sbc_svc::SbcSubmissionContext {
card_db: &state.card_db,
sbc_defs: &state.sbc_defs,
objective_defs: &state.obj_defs,
achievement_defs: &state.achievement_defs,
profile_id: &profile.id,
club_id: &club.id,
},
&req,
)
.await?;
if result.passed {
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
}
Ok(Json(result))
}
+2 -7
View File
@@ -72,13 +72,8 @@ pub async fn post_change_position(
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::change_position(
&state.pool,
&club.id,
&owned_card_id,
&req.position,
)
.await?;
let updated =
upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
let card_def = state.card_db.get(&updated.card_id);
+245 -70
View File
@@ -1,10 +1,12 @@
use crate::{
db::Pool,
error::AppResult,
error::{AppError, AppResult},
models::achievement::{AchievementDefinition, PlayerAchievement},
services::{club as club_svc, notification},
};
use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use uuid::Uuid;
@@ -29,79 +31,83 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
}
/// Query the current value for the given trigger metric.
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> {
let v: i64 = match trigger {
"matches_played" => sqlx::query_scalar(
"SELECT matches_played FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
async fn metric_value(
pool: &Pool,
profile_id: &str,
club_id: &str,
trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"matches_won" => sqlx::query_scalar(
"SELECT matches_won FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"goals_scored" => sqlx::query_scalar(
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"packs_opened" => sqlx::query_scalar(
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"sbcs_completed" => sqlx::query_scalar(
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"cards_owned" => sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(club_id)
.fetch_one(pool)
.await?,
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(pool)
.await?
}
"level" => sqlx::query_scalar(
"SELECT level FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
_ => 0,
};
_ => 0,
};
Ok(v)
}
@@ -165,11 +171,180 @@ pub async fn check_and_unlock(
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
}
let body = format!(
"{} Reward: {} coins.",
def.description, def.reward_coins
);
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
let _ = notification::create(
pool,
"achievement",
&format!("Achievement: {}", def.title),
&body,
)
.await;
newly_unlocked.push(def.clone());
}
}
Ok(newly_unlocked)
}
/// Transaction-scoped [`metric_value`] — identical reads, run inside the
/// caller's transaction so achievement checks see the same uncommitted state the
/// rest of the match-completion transaction just wrote.
async fn metric_value_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
club_id: &str,
trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(&mut **tx)
.await?
}
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(&mut **tx)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(&mut **tx)
.await?,
_ => 0,
};
Ok(v)
}
/// Transaction-scoped [`check_and_unlock`] for the atomic match-completion path.
/// Unlocks are inserted, coins credited, and notifications written inside the
/// caller's transaction (mirroring the inline economy writes elsewhere), so a
/// later failure rolls back the whole match — no half-granted achievement.
pub async fn check_and_unlock_tx(
tx: &mut Transaction<'_, Sqlite>,
defs: &[AchievementDefinition],
profile_id: &str,
club_id: &str,
now: &str,
) -> AppResult<Vec<AchievementDefinition>> {
if defs.is_empty() {
return Ok(vec![]);
}
let unlocked_ids: Vec<String> =
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
.fetch_all(&mut **tx)
.await?;
let unlocked_set: HashSet<&str> = unlocked_ids.iter().map(|s| s.as_str()).collect();
let candidates: Vec<&AchievementDefinition> = defs
.iter()
.filter(|d| !unlocked_set.contains(d.id.as_str()))
.collect();
if candidates.is_empty() {
return Ok(vec![]);
}
let mut trigger_cache: HashMap<String, i64> = Default::default();
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
for def in candidates {
let value = match trigger_cache.get(&def.trigger) {
Some(&v) => v,
None => {
let v = metric_value_tx(tx, profile_id, club_id, &def.trigger).await?;
trigger_cache.insert(def.trigger.clone(), v);
v
}
};
if value >= def.threshold {
if def.reward_coins < 0 {
return Err(AppError::Internal(anyhow::anyhow!(
"achievement {} has a negative reward",
def.id
)));
}
let inserted = sqlx::query(
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&def.id)
.bind(now)
.execute(&mut **tx)
.await?;
if inserted.rows_affected() == 0 {
continue;
}
if def.reward_coins > 0 {
let credited =
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
.bind(def.reward_coins)
.bind(now)
.bind(club_id)
.execute(&mut **tx)
.await?;
if credited.rows_affected() != 1 {
return Err(AppError::NotFound("club not found".into()));
}
}
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
sqlx::query(
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) VALUES (?, 'achievement', ?, ?, 0, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(format!("Achievement: {}", def.title))
.bind(body)
.bind(now)
.execute(&mut **tx)
.await?;
newly_unlocked.push(def.clone());
}
+11 -8
View File
@@ -1,4 +1,8 @@
use crate::{db::Pool, error::AppResult, services::{club, pack}};
use crate::{
db::Pool,
error::AppResult,
services::{club, pack},
};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
@@ -8,7 +12,7 @@ const STREAK_7_PACK: &str = "silver_pack";
#[derive(Debug, serde::Serialize)]
pub struct CheckinStatus {
pub available: bool,
pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in)
pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
}
}
pub async fn claim(
pool: &Pool,
profile_id: &str,
club_id: &str,
) -> AppResult<CheckinResult> {
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
@@ -82,7 +82,10 @@ pub async fn claim(
}
}
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
let last_streak = row
.as_ref()
.map(|(s, last_at)| compute_next_streak(*s, last_at))
.unwrap_or(1);
let idx = (last_streak - 1).rem_euclid(7) as usize;
let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
+242 -1
View File
@@ -1,7 +1,7 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::club::Club,
models::{card::OwnedCard, club::Club},
};
use chrono::Utc;
@@ -126,3 +126,244 @@ pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i
.await?;
Ok(new_balance)
}
// ─────────────────────── squad manager assignment ───────────────────────────
//
// Generic, ownership-backed canonical state: one owned item assigned as a
// squad's manager (migration 0023 `squad_managers`). Core stores the assignment
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
// meaning of "manager" (itemType/contract/chemistry), never Core.
const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, \
acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards";
/// The club's most-recently-updated squad id (its "active" squad), matching the
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
)
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// The owned card assigned as the manager of `club_id`'s active squad, if any.
pub async fn get_squad_manager(pool: &Pool, club_id: &str) -> AppResult<Option<OwnedCard>> {
let Some(squad_id) = active_squad_id(pool, club_id).await? else {
return Ok(None);
};
get_squad_manager_for_squad(pool, &squad_id, club_id).await
}
/// The owned card assigned as `squad_id`'s manager, re-validated to still belong
/// to `club_id`. The club-ownership re-check means a stale assignment left by a
/// market transfer (which moves ownership by UPDATE, bypassing ON DELETE
/// CASCADE) never surfaces a manager the club no longer owns.
pub async fn get_squad_manager_for_squad(
pool: &Pool,
squad_id: &str,
club_id: &str,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_SELECT} WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
AND club_id = ?"
))
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// Assign `owned_card_id` as the manager of `club_id`'s active squad, replacing
/// any existing assignment. Fail-closed: both the squad and the owned card MUST
/// belong to `club_id`, so a client can neither manage another club's squad nor
/// assign a card it does not own. One manager per squad (the PK REPLACE), so a
/// re-assignment never accumulates duplicate rows.
pub async fn set_squad_manager(pool: &Pool, club_id: &str, owned_card_id: &str) -> AppResult<()> {
let squad_id = active_squad_id(pool, club_id)
.await?
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
set_squad_manager_for_squad(pool, club_id, &squad_id, owned_card_id).await
}
/// Squad-scoped variant of [`set_squad_manager`].
pub async fn set_squad_manager_for_squad(
pool: &Pool,
club_id: &str,
squad_id: &str,
owned_card_id: &str,
) -> AppResult<()> {
let squad_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?;
if squad_ok.is_none() {
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
}
let card_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?;
if card_ok.is_none() {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
}
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, updated_at) \
VALUES (?, ?, ?)",
)
.bind(squad_id)
.bind(owned_card_id)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}
/// Remove the manager assignment from `club_id`'s active squad (idempotent — a
/// club with no squad or no manager is a successful no-op).
pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
sqlx::query(
"DELETE FROM squad_managers WHERE squad_id IN \
(SELECT id FROM squads WHERE club_id = ?)",
)
.bind(club_id)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
const TS: &str = "2026-01-01T00:00:00Z";
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs:
/// club-a owns `mgr` + `mgr2` + `player`, club-b owns `foreign`.
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
let dir = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite://{}", dir.path().join("core.db").display());
let pool = db::init_pool(&url, 5).await.expect("init pool");
db::run_migrations(&pool).await.expect("migrations");
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(profile)
.bind(profile)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
.execute(&pool).await.expect("club");
}
for (id, club) in [
("mgr", "club-a"),
("mgr2", "club-a"),
("player", "club-a"),
("foreign", "club-b"),
] {
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind(id).bind(club).bind("def-mgr").bind(TS)
.execute(&pool).await.expect("owned card");
}
// club-a has one squad.
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)")
.bind(TS).bind(TS).execute(&pool).await.expect("squad");
(dir, url, pool)
}
async fn manager_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_managers")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn manager_persists_across_reload_and_restart() {
let (dir, url, pool) = fixture().await;
// SAVE.
set_squad_manager(&pool, "club-a", "mgr")
.await
.expect("assign");
// RELOAD (same pool).
let got = get_squad_manager(&pool, "club-a").await.unwrap();
assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr"));
// RESTART: close the pool and reopen the same DB file.
pool.close().await;
let reopened = db::init_pool(&url, 5).await.expect("reopen");
db::run_migrations(&reopened).await.expect("migrations");
let after = get_squad_manager(&reopened, "club-a").await.unwrap();
assert_eq!(
after.as_ref().map(|c| c.id.as_str()),
Some("mgr"),
"manager assignment must survive a server restart"
);
drop(dir);
}
#[tokio::test]
async fn reassignment_replaces_and_never_duplicates() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
set_squad_manager(&pool, "club-a", "mgr2").await.unwrap();
assert_eq!(manager_rows(&pool).await, 1, "one manager per squad");
let got = get_squad_manager(&pool, "club-a").await.unwrap();
assert_eq!(got.map(|c| c.id), Some("mgr2".to_string()));
}
#[tokio::test]
async fn clear_removes_and_no_resurrection() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
clear_squad_manager(&pool, "club-a").await.unwrap();
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
assert_eq!(manager_rows(&pool).await, 0);
// Clearing again is an idempotent no-op.
clear_squad_manager(&pool, "club-a").await.unwrap();
}
#[tokio::test]
async fn rejects_card_the_club_does_not_own() {
let (_dir, _url, pool) = fixture().await;
let err = set_squad_manager(&pool, "club-a", "foreign").await;
assert!(err.is_err(), "cannot assign a card owned by another club");
assert_eq!(manager_rows(&pool).await, 0);
}
#[tokio::test]
async fn quick_sell_of_manager_cascades_the_assignment_away() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
// A quick-sell/discard DELETEs the owned row; ON DELETE CASCADE must
// remove the assignment so the sold manager is never resurrected.
sqlx::query("DELETE FROM owned_cards WHERE id = 'mgr'")
.execute(&pool)
.await
.expect("delete owned card");
assert_eq!(manager_rows(&pool).await, 0);
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
#[tokio::test]
async fn no_manager_when_none_assigned() {
let (_dir, _url, pool) = fixture().await;
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
}
+21 -14
View File
@@ -184,24 +184,32 @@ pub async fn pick_card(
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
if all_filled {
let (coins, pack, avg) = compute_reward(card_db, &picks);
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339()))
(
None,
"completed".to_string(),
coins,
pack,
avg,
Some(chrono::Utc::now().to_rfc3339()),
)
} else {
let min_overall = difficulty_min_overall(&session.difficulty);
let next_pos = &pick_order[next_index];
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),
"active".to_string(),
0,
None,
0,
None,
)
}
let candidates_json = serde_json::to_string(&next_candidates).map_err(|e| {
AppError::Internal(anyhow::anyhow!("serialization failed: {e}"))
})?;
(
Some(candidates_json),
"active".to_string(),
0,
None,
0,
None,
)
}
};
let picks_json = serde_json::to_string(&picks)
@@ -294,8 +302,7 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe
}
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
let pick_order: Vec<String> =
serde_json::from_str(&session.pick_order).unwrap_or_default();
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
let candidates: Vec<String> = session
.current_candidates
+5 -1
View File
@@ -26,7 +26,11 @@ pub async fn get_active_session(
.map_err(Into::into)
}
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> {
pub async fn get_session(
pool: &Pool,
session_id: &str,
profile_id: &str,
) -> AppResult<FutChampsSession> {
sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
))
+10 -5
View File
@@ -149,7 +149,9 @@ pub async fn buy_listing(
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::NotFound("listing not found or already sold".into()));
return Err(AppError::NotFound(
"listing not found or already sold".into(),
));
}
if let Err(e) = club::spend_coins(pool, club_id, listing.price).await {
let _ = sqlx::query("UPDATE market_listings SET sold = 0 WHERE id = ?")
@@ -308,9 +310,10 @@ pub async fn get_listings_by_seller(
let with_cards = listings
.into_iter()
.filter_map(|l| {
card_db
.get(&l.card_id)
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
listing: l,
card: card.clone(),
})
})
.collect();
Ok(with_cards)
@@ -326,7 +329,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?;
.ok_or_else(|| {
AppError::NotFound(format!("listing '{listing_id}' not found or already sold"))
})?;
sqlx::query("DELETE FROM market_listings WHERE id = ?")
.bind(listing_id)
File diff suppressed because it is too large Load Diff
+68 -4
View File
@@ -6,6 +6,7 @@ use crate::{
},
};
use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::path::Path;
use uuid::Uuid;
@@ -67,10 +68,7 @@ pub async fn increment_metric(
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs
.iter()
.filter(|d| d.metric.as_str() == metric)
{
for def in defs.iter().filter(|d| d.metric.as_str() == 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 = ?"
)
@@ -123,6 +121,72 @@ pub async fn increment_metric(
Ok(completed_ids)
}
/// Transaction-scoped [`increment_metric`] for the atomic match-completion path.
/// Same semantics, but every read/write runs inside the caller's transaction so
/// objective progress commits (or rolls back) together with the coins, XP, and
/// statistics of the same match. `now` is threaded so one match stamps a single
/// timestamp.
pub async fn increment_metric_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
defs: &[ObjectiveDefinition],
metric: &str,
amount: i64,
now: &str,
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs.iter().filter(|d| d.metric.as_str() == 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 = ?"
)
.bind(profile_id)
.bind(&def.id)
.fetch_optional(&mut **tx)
.await?;
if let Some(prog) = existing {
if prog.completed {
continue;
}
let new_val = (prog.current + amount).min(def.target);
let now_complete = new_val >= def.target;
sqlx::query(
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
)
.bind(new_val)
.bind(now_complete)
.bind(now)
.bind(&prog.id)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
} else {
let new_val = amount.min(def.target);
let now_complete = new_val >= def.target;
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
)
.bind(&id)
.bind(profile_id)
.bind(&def.id)
.bind(new_val)
.bind(now_complete)
.bind(now)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
}
}
Ok(completed_ids)
}
pub async fn claim_objective(
pool: &Pool,
profile_id: &str,
+9 -10
View File
@@ -73,14 +73,13 @@ pub async fn open_pack(
// Atomically claim the pack before minting any cards: only one concurrent opener
// flips opened 0->1, so a double-open cannot mint the reward twice (duplication).
let claimed = sqlx::query(
"UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0",
)
.bind(pack_id)
.bind(club_id)
.execute(pool)
.await?
.rows_affected();
let claimed =
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0")
.bind(pack_id)
.bind(club_id)
.execute(pool)
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::BadRequest("pack already opened".into()));
}
@@ -134,8 +133,8 @@ pub async fn open_pack(
}
}
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
.unwrap_or_default();
let card_ids_json =
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
+5 -1
View File
@@ -100,7 +100,11 @@ pub async fn add_xp_with_levelup(
}
tracing::info!(profile_id, new_level = lvl, coins, "level up");
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
events.push(LevelUpEvent {
new_level: lvl,
coins_granted: coins,
pack_granted: pack,
});
}
Ok(events)
+1169 -71
View File
File diff suppressed because it is too large Load Diff
+13 -9
View File
@@ -20,9 +20,11 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
.bind(&now)
.execute(pool)
.await?;
fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing immediately after insert")))
fetch(pool, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"season row missing immediately after insert"
))
})
}
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
@@ -68,9 +70,11 @@ pub async fn record_match(
.execute(pool)
.await?;
let season = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after record_match update")))?;
let season = fetch(pool, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"season row missing after record_match update"
))
})?;
if !season.is_complete() {
return Ok((season, None));
@@ -145,9 +149,9 @@ pub async fn record_match(
pack_awarded: pack_id.map(String::from),
};
let updated = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
let updated = fetch(pool, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!("season row missing after season rollover"))
})?;
Ok((updated, Some(summary)))
}
+93 -1
View File
@@ -1,6 +1,7 @@
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
use sqlx::{Sqlite, Transaction};
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, matches_dnf, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
@@ -77,6 +78,77 @@ pub async fn record_match(
Ok(())
}
/// Record a completed match within an existing transaction (the atomic
/// match-completion path). `outcome` is `win` | `draw` | `loss` | `dnf`. A DNF
/// (abandon/quit) increments its own bucket — never `matches_lost` — and, like a
/// loss, resets the win streak. All-or-nothing with the caller's transaction; it
/// never commits on its own, so a later failure rolls this back with everything
/// else.
pub async fn record_match_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
outcome: &str,
goals_for: i64,
goals_against: i64,
coins: i64,
now: &str,
) -> AppResult<()> {
sqlx::query("INSERT OR IGNORE INTO statistics (profile_id, updated_at) VALUES (?, ?)")
.bind(profile_id)
.bind(now)
.execute(&mut **tx)
.await?;
let current_streak: i64 =
sqlx::query_scalar("SELECT win_streak FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_one(&mut **tx)
.await?;
let (w, d, l, dnf) = match outcome {
"win" => (1i64, 0i64, 0i64, 0i64),
"draw" => (0, 1, 0, 0),
"dnf" => (0, 0, 0, 1),
_ => (0, 0, 1, 0),
};
let new_streak = if outcome == "win" {
current_streak + 1
} else {
0
};
sqlx::query(
"UPDATE statistics SET
matches_played = matches_played + 1,
matches_won = matches_won + ?,
matches_drawn = matches_drawn + ?,
matches_lost = matches_lost + ?,
matches_dnf = matches_dnf + ?,
goals_scored = goals_scored + ?,
goals_conceded = goals_conceded + ?,
total_coins_earned = total_coins_earned + ?,
win_streak = ?,
best_win_streak = MAX(best_win_streak, ?),
updated_at = ?
WHERE profile_id = ?",
)
.bind(w)
.bind(d)
.bind(l)
.bind(dnf)
.bind(goals_for)
.bind(goals_against)
.bind(coins)
.bind(new_streak)
.bind(new_streak)
.bind(now)
.bind(profile_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
get_or_create(pool, profile_id).await?;
let now = chrono::Utc::now().to_rfc3339();
@@ -121,6 +193,26 @@ pub async fn record_position_goals(
Ok(())
}
/// Transaction-scoped [`record_position_goals`] for the atomic match-completion
/// path.
pub async fn record_position_goals_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
positions: &[String],
) -> AppResult<()> {
for position in positions {
sqlx::query(
"INSERT INTO position_goals (profile_id, position, goals) VALUES (?, ?, 1) \
ON CONFLICT(profile_id, position) DO UPDATE SET goals = goals + 1",
)
.bind(profile_id)
.bind(position)
.execute(&mut **tx)
.await?;
}
Ok(())
}
pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> {
let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
+8 -10
View File
@@ -16,14 +16,12 @@ pub const MAX_TRAINING_BONUS: i64 = 3;
pub const POSITION_CHANGE_COST: i64 = 500;
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
}
/// Apply a chemistry style to an owned card.
@@ -64,8 +62,8 @@ pub async fn change_position(
new_position: &str,
) -> AppResult<OwnedCard> {
let valid_positions = [
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
"CF", "ST",
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
"ST",
];
if !valid_positions.contains(&new_position) {
return Err(AppError::BadRequest(format!(
+15 -3
View File
@@ -294,7 +294,12 @@ async fn test_sbc_rejects_duplicate_cards() {
.as_str()
.unwrap()
.to_string();
let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
let (s, _) = json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, coll) = json_get(&app, "/collection").await;
@@ -312,9 +317,16 @@ async fn test_sbc_rejects_duplicate_cards() {
}),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST, "duplicate submission must be rejected: {result}");
assert_eq!(
s,
StatusCode::BAD_REQUEST,
"duplicate submission must be rejected: {result}"
);
assert!(
result["error"].as_str().unwrap_or_default().contains("duplicate"),
result["error"]
.as_str()
.unwrap_or_default()
.contains("duplicate"),
"expected a duplicate-card error, got: {result}"
);
}