diff --git a/migrations/0022_match_completions.sql b/migrations/0022_match_completions.sql new file mode 100644 index 0000000..448ae3f --- /dev/null +++ b/migrations/0022_match_completions.sql @@ -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; diff --git a/src/app.rs b/src/app.rs index 7ab20a5..ce442eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -250,6 +250,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .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)) diff --git a/src/models/match_result.rs b/src/models/match_result.rs index 3062f0c..644dc6f 100644 --- a/src/models/match_result.rs +++ b/src/models/match_result.rs @@ -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, } + +/// 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>, +} + +/// 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, + /// Level-ups gained from this match's XP (empty on a replay). + pub level_ups: Vec, + /// Achievements unlocked by this match (empty on a replay). + pub achievements_unlocked: Vec, + pub match_record: Match, +} diff --git a/src/models/statistics.rs b/src/models/statistics.rs index 71e52c6..3e329bd 100644 --- a/src/models/statistics.rs +++ b/src/models/statistics.rs @@ -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, diff --git a/src/routes/matches.rs b/src/routes/matches.rs index 50c0465..f9f581b 100644 --- a/src/routes/matches.rs +++ b/src/routes/matches.rs @@ -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}, }; @@ -83,3 +85,28 @@ pub async fn post_match_result( 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, + game: GameId, + Json(req): Json, +) -> AppResult> { + 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)) +} diff --git a/src/services/achievement.rs b/src/services/achievement.rs index 5a80af6..0cd8707 100644 --- a/src/services/achievement.rs +++ b/src/services/achievement.rs @@ -1,12 +1,14 @@ use crate::{ db::Pool, - error::AppResult, + error::{AppError, AppResult}, models::achievement::{AchievementDefinition, PlayerAchievement}, services::{club as club_svc, notification}, }; use anyhow::Context; use std::path::Path; use uuid::Uuid; +use sqlx::{Sqlite, Transaction}; +use std::collections::{HashMap, HashSet}; pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result> { let dir = Path::new(data_dir).join("achievements"); @@ -185,6 +187,171 @@ pub async fn check_and_unlock( 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 { + 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> { + if defs.is_empty() { + return Ok(vec![]); + } + + let unlocked_ids: Vec = + 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 = Default::default(); + let mut newly_unlocked: Vec = 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()); + } + } + + Ok(newly_unlocked) +} + /// Return all achievement definitions annotated with unlock status. pub async fn list_with_status( pool: &Pool, diff --git a/src/services/match_service.rs b/src/services/match_service.rs index 94159a0..da4e45f 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -1,11 +1,15 @@ use crate::{ db::Pool, - error::AppResult, + error::{AppError, AppResult}, models::{ achievement::AchievementDefinition, card::OwnedCard, - match_result::{Match, MatchRewardResult, SubmitMatchRequest}, + match_result::{ + CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind, MatchRewardResult, + SubmitMatchRequest, + }, objective::ObjectiveDefinition, + profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent}, }, services::{ achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, @@ -13,6 +17,8 @@ use crate::{ }, }; use rand::{seq::SliceRandom, Rng}; +use sqlx::{Sqlite, Transaction}; +use uuid::Uuid; const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"]; @@ -339,3 +345,808 @@ async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppR Ok(expired) } + +// ─────────────────────────── Atomic match completion ──────────────────────── + +/// Coins + XP for a canonical result. A DNF earns the loss tier — an abandon is +/// economically a loss — while a no-contest grants nothing. WIN/DRAW/LOSS keep +/// the existing amounts. +fn rewards_for(result: MatchResultKind) -> (i64, i64) { + match result { + MatchResultKind::Win => (COINS_WIN, XP_WIN), + MatchResultKind::Draw => (COINS_DRAW, XP_DRAW), + MatchResultKind::Loss => (COINS_LOSS, XP_LOSS), + MatchResultKind::Dnf => (COINS_LOSS, XP_LOSS), + MatchResultKind::NoContest => (0, 0), + } +} + +/// Parse a persisted canonical result token back into [`MatchResultKind`]. +fn parse_result(s: &str) -> AppResult { + Ok(match s { + "win" => MatchResultKind::Win, + "draw" => MatchResultKind::Draw, + "loss" => MatchResultKind::Loss, + "dnf" => MatchResultKind::Dnf, + "no_contest" => MatchResultKind::NoContest, + other => { + return Err(AppError::Internal(anyhow::anyhow!( + "unknown persisted match result {other:?}" + ))) + } + }) +} + +/// Fault-injection points for [`complete_match`]. Every point is AFTER a durable +/// write, so a fault MUST roll the entire match back — integrity comes from the +/// SQLite transaction, never from compensating cleanup. Only constructed in +/// tests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FaultPoint { + AfterCompletionRow, + AfterHistory, + AfterCoins, + AfterXp, + AfterStatistics, + AfterObjectives, + BeforeCommit, +} + +fn inject_fault(actual: Option, point: FaultPoint) -> AppResult<()> { + if actual == Some(point) { + return Err(AppError::Internal(anyhow::anyhow!( + "injected match-completion fault at {point:?}" + ))); + } + Ok(()) +} + +/// Complete a match exactly once, atomically. This is the authoritative +/// economic entry point for a finished match: it validates the request, refuses +/// a duplicate via the durable `(profile_id, match_identity)` guard, and — for +/// an economic result — persists history, grants coins, grants XP + level-ups, +/// updates W/D/L/DNF statistics, advances objectives, and unlocks achievements, +/// all in one transaction that either commits together or rolls back whole. +/// +/// A replay (sequential, restart, concurrent, or a conflicting re-report of the +/// same match) is an idempotent no-op that echoes the persisted canonical +/// result with `applied = false`. +pub async fn complete_match( + pool: &Pool, + profile_id: &str, + club_id: &str, + req: &CompleteMatchRequest, + obj_defs: &[ObjectiveDefinition], + ach_defs: &[AchievementDefinition], +) -> AppResult { + complete_match_inner(pool, profile_id, club_id, req, obj_defs, ach_defs, None).await +} + +#[allow(clippy::too_many_arguments)] +async fn complete_match_inner( + pool: &Pool, + profile_id: &str, + club_id: &str, + req: &CompleteMatchRequest, + obj_defs: &[ObjectiveDefinition], + ach_defs: &[AchievementDefinition], + fault: Option, +) -> AppResult { + if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 { + return Err(AppError::BadRequest( + "goals_for and goals_against must each be between 0 and 99".into(), + )); + } + if req.match_identity.trim().is_empty() { + return Err(AppError::BadRequest("match_identity must not be empty".into())); + } + + let result = req.result; + let (coins, xp) = rewards_for(result); + let outcome = result.as_str(); + let now = chrono::Utc::now().to_rfc3339(); + let match_id = Uuid::new_v4().to_string(); + let completion_id = Uuid::new_v4().to_string(); + + let mut tx = pool.begin().await?; + + // 1. Durable match-history row FIRST. This is also the transaction's first + // write, so it takes SQLite's single writer lock and serializes + // overlapping completions. The `match_completions` guard below carries a + // FK to this row, so it must exist before the guard is written. + let match_record = Match { + id: match_id.clone(), + profile_id: profile_id.to_string(), + squad_id: req.squad_id.clone(), + opponent_name: req.opponent_name.clone(), + goals_for: req.goals_for, + goals_against: req.goals_against, + outcome: outcome.to_string(), + coins_awarded: coins, + xp_awarded: xp, + mode: req.mode.clone(), + played_at: now.clone(), + }; + sqlx::query( + "INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&match_record.id) + .bind(&match_record.profile_id) + .bind(&match_record.squad_id) + .bind(&match_record.opponent_name) + .bind(match_record.goals_for) + .bind(match_record.goals_against) + .bind(&match_record.outcome) + .bind(match_record.coins_awarded) + .bind(match_record.xp_awarded) + .bind(&match_record.mode) + .bind(&match_record.played_at) + .execute(&mut *tx) + .await?; + inject_fault(fault, FaultPoint::AfterHistory)?; + + // 2. Economic-idempotency guard. Enforces the durable + // (profile_id, match_identity) uniqueness: a duplicate — sequential, + // concurrent, restart, or a conflicting re-report with a different result + // — collides here and rolls the whole match (including the history row + // just written) back before any reward is granted. + let guard = sqlx::query( + "INSERT INTO match_completions \ + (id, profile_id, match_identity, result, coins_awarded, xp_awarded, match_id, completed_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&completion_id) + .bind(profile_id) + .bind(&req.match_identity) + .bind(outcome) + .bind(coins) + .bind(xp) + .bind(&match_id) + .bind(&now) + .execute(&mut *tx) + .await; + + match guard { + Ok(_) => {} + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { + // Already economically completed: roll back this attempt and echo + // the canonical persisted result. The first completion wins. + tx.rollback().await?; + return already_completed(pool, profile_id, club_id, &req.match_identity).await; + } + Err(e) => { + tx.rollback().await?; + return Err(e.into()); + } + } + inject_fault(fault, FaultPoint::AfterCompletionRow)?; + + let mut objectives_updated = Vec::new(); + let mut level_ups = Vec::new(); + let mut achievements_unlocked = Vec::new(); + + // A no-contest is recorded (history + idempotency) but has ZERO economic + // effect: no coins, XP, statistics, objectives, or achievements. + if result.is_economic() { + // 3. Coins. + if coins > 0 { + let credited = + sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") + .bind(coins) + .bind(&now) + .bind(club_id) + .execute(&mut *tx) + .await?; + if credited.rows_affected() != 1 { + return Err(AppError::NotFound("club not found".into())); + } + } + inject_fault(fault, FaultPoint::AfterCoins)?; + + // 4. XP + level-ups (level rewards credited in-transaction). + level_ups = grant_xp_with_levelup_tx(&mut tx, profile_id, club_id, xp, &now).await?; + inject_fault(fault, FaultPoint::AfterXp)?; + + // 5. W/D/L/DNF statistics. + statistics::record_match_tx( + &mut tx, + profile_id, + outcome, + req.goals_for, + req.goals_against, + coins, + &now, + ) + .await?; + if let Some(positions) = &req.goal_positions { + statistics::record_position_goals_tx(&mut tx, profile_id, positions).await?; + } + inject_fault(fault, FaultPoint::AfterStatistics)?; + + // 6. Objectives. + objectives_updated.append( + &mut objective::increment_metric_tx( + &mut tx, + profile_id, + obj_defs, + "matchesplayed", + 1, + &now, + ) + .await?, + ); + if result == MatchResultKind::Win { + objectives_updated.append( + &mut objective::increment_metric_tx( + &mut tx, + profile_id, + obj_defs, + "matcheswon", + 1, + &now, + ) + .await?, + ); + } + objectives_updated.append( + &mut objective::increment_metric_tx( + &mut tx, + profile_id, + obj_defs, + "goalsscored", + req.goals_for, + &now, + ) + .await?, + ); + objectives_updated.append( + &mut objective::increment_metric_tx( + &mut tx, + profile_id, + obj_defs, + "coinsearned", + coins, + &now, + ) + .await?, + ); + inject_fault(fault, FaultPoint::AfterObjectives)?; + + // 7. Achievements. + achievements_unlocked = + achievement::check_and_unlock_tx(&mut tx, ach_defs, profile_id, club_id, &now).await?; + } + inject_fault(fault, FaultPoint::BeforeCommit)?; + + // 8. Echo the post-completion balance, then commit everything atomically. + let coins_balance: i64 = sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + + Ok(MatchCompletionResult { + applied: true, + match_identity: req.match_identity.clone(), + result, + coins_awarded: coins, + xp_awarded: xp, + coins_balance, + objectives_updated, + level_ups, + achievements_unlocked, + match_record, + }) +} + +/// Build the idempotent echo for an already-completed match. Reads the persisted +/// canonical result (never re-derives it) so a WIN-then-LOSS re-report returns +/// the WIN that actually landed. +async fn already_completed( + pool: &Pool, + profile_id: &str, + club_id: &str, + match_identity: &str, +) -> AppResult { + let (match_id, result_str, coins, xp): (String, String, i64, i64) = sqlx::query_as( + "SELECT match_id, result, coins_awarded, xp_awarded FROM match_completions \ + WHERE profile_id = ? AND match_identity = ?", + ) + .bind(profile_id) + .bind(match_identity) + .fetch_optional(pool) + .await? + .ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "match_completions row missing after unique violation" + )) + })?; + + let result = parse_result(&result_str)?; + let match_record = sqlx::query_as::<_, Match>( + "SELECT id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at FROM matches WHERE id = ?", + ) + .bind(&match_id) + .fetch_one(pool) + .await?; + let coins_balance: i64 = sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_one(pool) + .await?; + + Ok(MatchCompletionResult { + applied: false, + match_identity: match_identity.to_string(), + result, + coins_awarded: coins, + xp_awarded: xp, + coins_balance, + objectives_updated: vec![], + level_ups: vec![], + achievements_unlocked: vec![], + match_record, + }) +} + +/// Transaction-scoped XP grant with level-ups. Mirrors +/// [`crate::services::profile::add_xp_with_levelup`] but runs entirely inside the +/// caller's transaction (level-up coins + packs credited in-tx), so it commits or +/// rolls back with the rest of the match. Notifications are intentionally not +/// emitted here — they are non-durable side effects the pooled path owns. +async fn grant_xp_with_levelup_tx( + tx: &mut Transaction<'_, Sqlite>, + profile_id: &str, + club_id: &str, + xp_to_add: i64, + now: &str, +) -> AppResult> { + let current_xp: i64 = sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?") + .bind(profile_id) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?; + + let old_level = level_for_xp(current_xp); + let new_total = current_xp + xp_to_add; + let new_level = level_for_xp(new_total); + + sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?") + .bind(new_total) + .bind(new_level) + .bind(now) + .bind(profile_id) + .execute(&mut **tx) + .await?; + + let mut events = Vec::new(); + for lvl in (old_level + 1)..=new_level { + let coins = coins_for_level(lvl); + let pack = pack_for_level(lvl).map(String::from); + if coins > 0 { + let credited = + sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") + .bind(coins) + .bind(now) + .bind(club_id) + .execute(&mut **tx) + .await?; + if credited.rows_affected() != 1 { + return Err(AppError::NotFound("club not found".into())); + } + } + if let Some(pack_id) = &pack { + sqlx::query( + "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(club_id) + .bind(pack_id) + .bind(now) + .execute(&mut **tx) + .await?; + } + events.push(LevelUpEvent { + new_level: lvl, + coins_granted: coins, + pack_granted: pack, + }); + } + + Ok(events) +} + +#[cfg(test)] +mod match_completion_tests { + use super::*; + use crate::db; + use crate::models::objective::{ObjectiveDefinition, ObjectiveMetric, ObjectiveType}; + use std::sync::Arc; + use tempfile::TempDir; + + const PROFILE: &str = "profile"; + const CLUB: &str = "club"; + const START_COINS: i64 = 1000; + + struct Fixture { + pool: Pool, + url: String, + _dir: TempDir, + } + + async fn seed(pool: &Pool) { + sqlx::query( + "INSERT INTO profiles (id, username, game_id, created_at, updated_at) \ + VALUES (?, ?, 'fifa17', 't', 't')", + ) + .bind(PROFILE) + .bind(PROFILE) + .execute(pool) + .await + .expect("seed profile"); + sqlx::query( + "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \ + VALUES (?, ?, ?, ?, 't', 't')", + ) + .bind(CLUB) + .bind(PROFILE) + .bind(CLUB) + .bind(START_COINS) + .execute(pool) + .await + .expect("seed club"); + } + + async fn new_fixture() -> Fixture { + 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"); + seed(&pool).await; + Fixture { + pool, + url, + _dir: dir, + } + } + + fn req(identity: &str, result: MatchResultKind, gf: i64, ga: i64) -> CompleteMatchRequest { + CompleteMatchRequest { + match_identity: identity.into(), + result, + squad_id: "squad".into(), + opponent_name: "Opponent".into(), + goals_for: gf, + goals_against: ga, + mode: "seasons".into(), + goal_positions: None, + } + } + + async fn complete(pool: &Pool, req: &CompleteMatchRequest) -> AppResult { + complete_match(pool, PROFILE, CLUB, req, &[], &[]).await + } + + async fn coins(pool: &Pool) -> i64 { + sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") + .bind(CLUB) + .fetch_one(pool) + .await + .unwrap() + } + async fn xp(pool: &Pool) -> i64 { + sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?") + .bind(PROFILE) + .fetch_one(pool) + .await + .unwrap() + } + async fn stat(pool: &Pool, col: &str) -> i64 { + let sql = format!("SELECT {col} FROM statistics WHERE profile_id = ?"); + sqlx::query_scalar(&sql) + .bind(PROFILE) + .fetch_optional(pool) + .await + .unwrap() + .unwrap_or(0) + } + async fn count(pool: &Pool, table: &str) -> i64 { + let sql = format!("SELECT COUNT(*) FROM {table}"); + sqlx::query_scalar(&sql).fetch_one(pool).await.unwrap() + } + + #[tokio::test] + async fn win_grants_reward_stats_and_history() { + let fx = new_fixture().await; + let r = complete(&fx.pool, &req("m1", MatchResultKind::Win, 3, 1)) + .await + .unwrap(); + assert!(r.applied); + assert_eq!(r.coins_awarded, COINS_WIN); + assert_eq!(r.xp_awarded, XP_WIN); + assert_eq!(r.coins_balance, START_COINS + COINS_WIN); + assert_eq!(r.match_record.outcome, "win"); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN); + assert_eq!(xp(&fx.pool).await, XP_WIN); + assert_eq!(stat(&fx.pool, "matches_played").await, 1); + assert_eq!(stat(&fx.pool, "matches_won").await, 1); + assert_eq!(stat(&fx.pool, "goals_scored").await, 3); + assert_eq!(stat(&fx.pool, "goals_conceded").await, 1); + assert_eq!(stat(&fx.pool, "win_streak").await, 1); + assert_eq!(count(&fx.pool, "matches").await, 1); + assert_eq!(count(&fx.pool, "match_completions").await, 1); + } + + #[tokio::test] + async fn draw_and_loss_reward_tiers() { + let fx = new_fixture().await; + let d = complete(&fx.pool, &req("d", MatchResultKind::Draw, 1, 1)) + .await + .unwrap(); + assert_eq!(d.coins_awarded, COINS_DRAW); + assert_eq!(stat(&fx.pool, "matches_drawn").await, 1); + let l = complete(&fx.pool, &req("l", MatchResultKind::Loss, 0, 2)) + .await + .unwrap(); + assert_eq!(l.coins_awarded, COINS_LOSS); + assert_eq!(stat(&fx.pool, "matches_lost").await, 1); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_DRAW + COINS_LOSS); + // A draw then a loss both reset/keep the streak at zero. + assert_eq!(stat(&fx.pool, "win_streak").await, 0); + } + + #[tokio::test] + async fn dnf_is_loss_economics_with_its_own_bucket() { + let fx = new_fixture().await; + let r = complete(&fx.pool, &req("dnf", MatchResultKind::Dnf, 0, 0)) + .await + .unwrap(); + assert!(r.applied); + assert_eq!(r.coins_awarded, COINS_LOSS, "DNF earns the loss tier"); + assert_eq!(r.xp_awarded, XP_LOSS); + assert_eq!(r.match_record.outcome, "dnf"); + assert_eq!(stat(&fx.pool, "matches_dnf").await, 1); + assert_eq!(stat(&fx.pool, "matches_lost").await, 0, "DNF is not a loss row"); + assert_eq!(stat(&fx.pool, "matches_played").await, 1); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_LOSS); + } + + #[tokio::test] + async fn no_contest_has_zero_economic_effect() { + let fx = new_fixture().await; + let r = complete(&fx.pool, &req("nc", MatchResultKind::NoContest, 0, 0)) + .await + .unwrap(); + assert!(r.applied); + assert_eq!(r.coins_awarded, 0); + assert_eq!(r.xp_awarded, 0); + assert_eq!(r.match_record.outcome, "no_contest"); + assert_eq!(coins(&fx.pool).await, START_COINS, "no coins for a void"); + assert_eq!(xp(&fx.pool).await, 0); + assert_eq!(stat(&fx.pool, "matches_played").await, 0, "not counted as played"); + // Still recorded for history + idempotency. + assert_eq!(count(&fx.pool, "matches").await, 1); + assert_eq!(count(&fx.pool, "match_completions").await, 1); + } + + #[tokio::test] + async fn sequential_duplicate_is_idempotent() { + let fx = new_fixture().await; + let first = complete(&fx.pool, &req("m", MatchResultKind::Win, 2, 0)) + .await + .unwrap(); + assert!(first.applied); + // A stale retry after success: same identity, applied once only. + let second = complete(&fx.pool, &req("m", MatchResultKind::Win, 2, 0)) + .await + .unwrap(); + assert!(!second.applied); + assert_eq!(second.result, MatchResultKind::Win); + assert_eq!(second.coins_balance, START_COINS + COINS_WIN); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN); + assert_eq!(xp(&fx.pool).await, XP_WIN); + assert_eq!(stat(&fx.pool, "matches_played").await, 1); + assert_eq!(count(&fx.pool, "matches").await, 1); + assert_eq!(count(&fx.pool, "match_completions").await, 1); + } + + #[tokio::test] + async fn conflicting_win_then_loss_keeps_first_canonical() { + let fx = new_fixture().await; + let win = complete(&fx.pool, &req("x", MatchResultKind::Win, 3, 0)) + .await + .unwrap(); + assert!(win.applied); + // Same match re-reported with the OPPOSITE result — only the first wins. + let conflict = complete(&fx.pool, &req("x", MatchResultKind::Loss, 0, 3)) + .await + .unwrap(); + assert!(!conflict.applied); + assert_eq!(conflict.result, MatchResultKind::Win); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN); + assert_eq!(stat(&fx.pool, "matches_won").await, 1); + assert_eq!(stat(&fx.pool, "matches_lost").await, 0); + } + + #[tokio::test] + async fn restart_replay_survives_pool_reopen() { + let fx = new_fixture().await; + complete(&fx.pool, &req("x", MatchResultKind::Win, 1, 0)) + .await + .unwrap(); + fx.pool.close().await; + + // Reopen a fresh pool on the SAME file — the durable guard persists. + let pool = db::init_pool(&fx.url, 5).await.unwrap(); + db::run_migrations(&pool).await.unwrap(); + let replay = complete(&pool, &req("x", MatchResultKind::Win, 1, 0)) + .await + .unwrap(); + assert!(!replay.applied, "replay after restart must not re-apply"); + assert_eq!(coins(&pool).await, START_COINS + COINS_WIN); + // A genuinely new match still credits. + let fresh = complete(&pool, &req("y", MatchResultKind::Win, 1, 0)) + .await + .unwrap(); + assert!(fresh.applied); + assert_eq!(coins(&pool).await, START_COINS + 2 * COINS_WIN); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_duplicate_applies_exactly_once() { + let fx = new_fixture().await; + let pool = Arc::new(fx.pool.clone()); + let mut handles = Vec::new(); + for _ in 0..8 { + let p = pool.clone(); + handles.push(tokio::spawn(async move { + complete(&p, &req("race", MatchResultKind::Win, 4, 2)).await + })); + } + let mut applied = 0; + for h in handles { + if let Ok(r) = h.await.unwrap() { + if r.applied { + applied += 1; + } + } + } + assert_eq!(applied, 1, "exactly one racer applies the economic effect"); + assert_eq!(coins(&pool).await, START_COINS + COINS_WIN); + assert_eq!(count(&pool, "match_completions").await, 1); + assert_eq!(count(&pool, "matches").await, 1); + assert_eq!(stat(&pool, "matches_played").await, 1); + } + + #[tokio::test] + async fn empty_match_identity_is_rejected() { + let fx = new_fixture().await; + let err = complete(&fx.pool, &req(" ", MatchResultKind::Win, 1, 0)) + .await + .unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + assert_eq!(coins(&fx.pool).await, START_COINS); + assert_eq!(count(&fx.pool, "match_completions").await, 0); + } + + #[tokio::test] + async fn unknown_club_rolls_back_whole_match() { + let fx = new_fixture().await; + // A completion aimed at a club that does not exist: the coin credit + // affects no row, the transaction fails, and NOTHING is persisted. + let err = complete_match( + &fx.pool, + PROFILE, + "no-such-club", + &req("m", MatchResultKind::Win, 1, 0), + &[], + &[], + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::NotFound(_))); + assert_eq!(count(&fx.pool, "match_completions").await, 0); + assert_eq!(count(&fx.pool, "matches").await, 0); + assert_eq!(coins(&fx.pool).await, START_COINS); + } + + const ALL_FAULTS: &[FaultPoint] = &[ + FaultPoint::AfterCompletionRow, + FaultPoint::AfterHistory, + FaultPoint::AfterCoins, + FaultPoint::AfterXp, + FaultPoint::AfterStatistics, + FaultPoint::AfterObjectives, + FaultPoint::BeforeCommit, + ]; + + #[tokio::test] + async fn fault_at_every_stage_rolls_back_completely() { + for &fp in ALL_FAULTS { + let fx = new_fixture().await; + let r = complete_match_inner( + &fx.pool, + PROFILE, + CLUB, + &req("m", MatchResultKind::Win, 3, 1), + &[], + &[], + Some(fp), + ) + .await; + assert!(r.is_err(), "{fp:?} should error"); + // Re-read persisted state: the whole match rolled back. + assert_eq!(coins(&fx.pool).await, START_COINS, "{fp:?} coins"); + assert_eq!(xp(&fx.pool).await, 0, "{fp:?} xp"); + assert_eq!(stat(&fx.pool, "matches_played").await, 0, "{fp:?} stats"); + assert_eq!(count(&fx.pool, "matches").await, 0, "{fp:?} history"); + assert_eq!( + count(&fx.pool, "match_completions").await, + 0, + "{fp:?} guard row" + ); + // The rolled-back guard frees the identity, so a clean retry succeeds. + let retry = complete(&fx.pool, &req("m", MatchResultKind::Win, 3, 1)) + .await + .unwrap(); + assert!(retry.applied, "{fp:?} retry-after-fault must apply"); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN, "{fp:?} retry"); + } + } + + fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition { + ObjectiveDefinition { + id: id.into(), + title: id.into(), + description: id.into(), + objective_type: ObjectiveType::Lifetime, + metric, + target, + reward_coins: 0, + reward_pack_id: None, + reward_xp: 0, + } + } + + fn ach(id: &str, trigger: &str, threshold: i64, reward: i64) -> AchievementDefinition { + AchievementDefinition { + id: id.into(), + title: id.into(), + description: id.into(), + icon: String::new(), + trigger: trigger.into(), + threshold, + reward_coins: reward, + rarity: "common".into(), + } + } + + #[tokio::test] + async fn objectives_and_achievements_apply_exactly_once() { + let fx = new_fixture().await; + let objs = vec![ + obj("played", ObjectiveMetric::MatchesPlayed, 1), + obj("won", ObjectiveMetric::MatchesWon, 1), + ]; + let achs = vec![ach("first_win", "matches_won", 1, 50)]; + + let first = complete_match(&fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 2, 0), &objs, &achs) + .await + .unwrap(); + assert!(first.applied); + assert_eq!(first.objectives_updated.len(), 2); + assert_eq!(first.achievements_unlocked.len(), 1); + // Match reward + achievement reward. + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN + 50); + assert_eq!(count(&fx.pool, "player_achievements").await, 1); + + // Replay: no double objectives/achievements/coins. + let replay = complete_match(&fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 2, 0), &objs, &achs) + .await + .unwrap(); + assert!(!replay.applied); + assert!(replay.objectives_updated.is_empty()); + assert!(replay.achievements_unlocked.is_empty()); + assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN + 50); + assert_eq!(count(&fx.pool, "player_achievements").await, 1); + } +} diff --git a/src/services/objective.rs b/src/services/objective.rs index 315a7be..0ed203a 100644 --- a/src/services/objective.rs +++ b/src/services/objective.rs @@ -8,6 +8,7 @@ use crate::{ use anyhow::Context; use std::path::Path; use uuid::Uuid; +use sqlx::{Sqlite, Transaction}; pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result> { let dir = Path::new(data_dir).join("objectives"); @@ -120,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> { + 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, diff --git a/src/services/statistics.rs b/src/services/statistics.rs index d213761..0418398 100644 --- a/src/services/statistics.rs +++ b/src/services/statistics.rs @@ -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 { 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> { let rows: Vec<(String, i64)> = sqlx::query_as( "SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",