diff --git a/README.md b/README.md index 7904754..77ff052 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/op | `GET` | `/squad` | Get active squad | | `POST` | `/squad` | Save squad | | `GET` | `/objectives` | List objectives with progress | -| `POST` | `/matches/result` | Submit match result + receive rewards | +| `POST` | `/matches/complete` | Complete a match exactly once + receive rewards | | `GET` | `/sbc` | List SBC definitions | | `POST` | `/sbc/submit` | Submit SBC solution | | `GET` | `/market` | Browse NPC transfer market | @@ -95,10 +95,11 @@ curl http://localhost:8080/club # Open your starter pack curl -X POST http://localhost:8080/packs/open/ -# Submit a match win -curl -X POST http://localhost:8080/matches/result \ +# Submit a match win. `match_identity` keys exactly-once economy: resubmitting the +# same identity echoes the first result and grants nothing twice. +curl -X POST http://localhost:8080/matches/complete \ -H 'Content-Type: application/json' \ - -d '{"squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}' + -d '{"match_identity":"match-1","result":"win","squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}' ``` --- diff --git a/docs/architecture.md b/docs/architecture.md index af8a04e..bd589e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,17 +71,33 @@ All game-content data is loaded at startup from `data/` into `Arc`-wrapped colle 8. Increment pack stats + objective progress 9. Return `PackOpenResult { pack_id, cards }` -## Data Flow: Match Result +## Data Flow: Match Completion -1. `POST /matches/result` → `routes::matches::post_match_result` +1. `POST /matches/complete` → `routes::matches::post_match_complete` 2. Fetch profile + club -3. `services::match_service::process_match(...)` -4. Determine outcome (win/draw/loss), compute coins + XP -5. Insert match record -6. `club::add_coins`, `profile::add_xp` -7. `statistics::record_match` -8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned -9. Return `MatchRewardResult` +3. `services::match_service::complete_match(...)` — everything below runs in ONE + transaction and either commits together or rolls back whole +4. Insert the match-history row (also takes SQLite's writer lock, serializing + overlapping completions) +5. Insert the `match_completions` guard row. `UNIQUE(profile_id, match_identity)` + makes the economy exactly-once: a duplicate — sequential, concurrent, after a + restart, or a conflicting re-report — collides here and the whole attempt + rolls back, then echoes the persisted result with `applied = false` +6. Coins, XP + level-ups, W/D/L/DNF statistics, objective metrics, achievements +7. Opt-in only: `expire_loans` (loan tick-down/removal) and `advance_season` + (Core's own division model, which grants coins and a pack at season end). + Both default OFF so a game with its own loan/season model — FIFA 17 — is + unaffected +8. Commit, then the route emits player notifications for what landed (never + inside the transaction, and only when `applied`) +9. Return `MatchCompletionResult` + +`POST /matches/result` was REMOVED as an economy path. It performed the same +grants across a dozen separate writes with no transaction and no idempotency +key, which made it a second economy authority that re-credited on every call and +could half-apply on any mid-way failure. It now rejects and names +`/matches/complete`. Exactly-once requires a caller-supplied match identity, +which its request shape did not carry and could not derive. ## Single-Profile Design diff --git a/src/models/match_result.rs b/src/models/match_result.rs index 644dc6f..13e5d12 100644 --- a/src/models/match_result.rs +++ b/src/models/match_result.rs @@ -50,16 +50,6 @@ impl MatchResultKind { } } -#[derive(Debug, Deserialize)] -pub struct SubmitMatchRequest { - pub squad_id: String, - pub opponent_name: String, - pub goals_for: i64, - pub goals_against: i64, - pub mode: String, - pub goal_positions: Option>, -} - #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Match { pub id: String, @@ -111,22 +101,6 @@ impl Match { } } -#[derive(Debug, Serialize)] -pub struct MatchRewardResult { - pub match_record: Match, - pub coins_awarded: i64, - pub xp_awarded: i64, - pub objectives_updated: Vec, - /// Owned card IDs removed because the loan expired this match. - pub expired_loans: Vec, - /// Present when this match completed the current season. - pub season_end: Option, - /// Non-empty when the player levelled up one or more times from this match's XP. - pub level_ups: Vec, - /// 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 @@ -143,6 +117,22 @@ pub struct CompleteMatchRequest { pub mode: String, #[serde(default)] pub goal_positions: Option>, + /// Tick down `loan_matches_remaining` for this squad's starters and remove + /// the cards whose loan ran out. + /// + /// OFF by default so a game whose loan model is its own (FIFA 17 does not + /// route loans through Core) is unaffected. Callers of Core's own match + /// modes opt in. + #[serde(default)] + pub expire_loans: bool, + /// Advance Core's OWN season model (division progress, and its end-of-season + /// coin/pack award). + /// + /// OFF by default: this grants economy, and it is NOT the same thing as a + /// game's native seasons (FIFA 17 offline Seasons are the adapter's, keyed by + /// its own wire). Only a caller using Core's season model opts in. + #[serde(default)] + pub advance_season: bool, } /// Outcome of [`crate::services::match_service::complete_match`]. @@ -165,5 +155,11 @@ pub struct MatchCompletionResult { pub level_ups: Vec, /// Achievements unlocked by this match (empty on a replay). pub achievements_unlocked: Vec, + /// Owned card ids removed because their loan expired on this match. Empty + /// unless the caller set `expire_loans`, and empty on a replay. + pub expired_loans: Vec, + /// Present when this match ended a Core season. `None` unless the caller set + /// `advance_season`, and `None` on a replay. + pub season_end: Option, pub match_record: Match, } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index b53bd69..2d64d40 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -102,7 +102,12 @@ pub async fn post_auth_reset( } } + // Order matters: `match_completions` carries un-cascaded foreign keys to BOTH + // `matches` and `profiles`, so it has to go before either of them or the + // reset fails with a constraint error. Any profile that completed a match + // through /matches/complete has rows here. for table in [ + "match_completions", "fut_champs_sessions", "sbc_submissions", "objective_progress", diff --git a/src/routes/matches.rs b/src/routes/matches.rs index f9f581b..a4da472 100644 --- a/src/routes/matches.rs +++ b/src/routes/matches.rs @@ -8,11 +8,9 @@ use serde_json::{json, Value}; use crate::{ app::AppState, - error::AppResult, - models::match_result::{ - CompleteMatchRequest, Match, MatchCompletionResult, MatchRewardResult, SubmitMatchRequest, - }, - services::{club as club_svc, match_service, profile as profile_svc}, + error::{AppError, AppResult}, + models::match_result::{CompleteMatchRequest, Match, MatchCompletionResult}, + services::{club as club_svc, match_service, notification, profile as profile_svc}, }; #[derive(Deserialize)] @@ -65,25 +63,28 @@ pub async fn get_opponent( Ok(Json(opponent)) } -pub async fn post_match_result( - 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::process_match( - &state.pool, - &profile.id, - &club.id, - &req, - &state.obj_defs, - &state.achievement_defs, - ) - .await?; - - Ok(Json(result)) +/// `POST /matches/result` — REMOVED as an economy path, and deliberately kept as +/// an explicit rejection rather than a 404. +/// +/// It used to grant coins, XP, level-ups, statistics, objectives, loan expiry, +/// season progression and achievements across a dozen separate writes with NO +/// transaction and NO idempotency key, which made it a second economy authority +/// that could re-credit the same match on every call and could half-apply on any +/// mid-way failure. Exactly-once needs a caller-supplied match identity, which +/// this request shape does not carry and cannot derive (a body fingerprint would +/// collapse two legitimate matches with the same scoreline into one). +/// +/// Callers submit to `/matches/complete` with a `match_identity`; the loan and +/// season behaviour this route used to trigger is available there via +/// `expire_loans` / `advance_season`. +pub async fn post_match_result() -> AppResult> { + Err(AppError::BadRequest( + "POST /matches/result is no longer an economy path: it had no transaction \ + and no idempotency key. Submit to POST /matches/complete with a \ + match_identity (and expire_loans / advance_season if you need Core's loan \ + and season progression)." + .into(), + )) } /// `POST /matches/complete` — the authoritative, atomic, exactly-once match @@ -108,5 +109,79 @@ pub async fn post_match_complete( ) .await?; + // Player-visible notifications are non-durable side effects, so they are + // emitted AFTER the economy transaction commits, never inside it — a failed + // notification must not roll back a completed match. Gated on `applied`, so + // an idempotent replay does not re-notify (the pooled path this replaced had + // no such guard). Achievement notifications are written in-transaction by + // `check_and_unlock_tx` and are deliberately not repeated here. + if result.applied { + emit_match_notifications(&state, &result).await; + } + Ok(Json(result)) } + +async fn emit_match_notifications(state: &AppState, result: &MatchCompletionResult) { + for ev in &result.level_ups { + let body = match &ev.pack_granted { + Some(pack) => format!( + "You reached level {}! Reward: {} coins + {pack}.", + ev.new_level, ev.coins_granted + ), + None => format!( + "You reached level {}! Reward: {} coins.", + ev.new_level, ev.coins_granted + ), + }; + let _ = notification::create( + &state.pool, + "level_up", + &format!("Level {}!", ev.new_level), + &body, + ) + .await; + } + + for obj_id in &result.objectives_updated { + let display_name = state + .obj_defs + .iter() + .find(|d| &d.id == obj_id) + .map(|d| d.title.as_str()) + .unwrap_or(obj_id.as_str()); + let body = format!("\"{display_name}\" is now complete. Claim your reward in Objectives."); + let _ = notification::create( + &state.pool, + "objective_complete", + "Objective complete!", + &body, + ) + .await; + } + + for owned_id in &result.expired_loans { + let body = + format!("Loan card (id: {owned_id}) has expired and been removed from your club."); + let _ = notification::create(&state.pool, "loan_expired", "Loan card expired", &body).await; + } + + if let Some(se) = &result.season_end { + use crate::models::season::SeasonResult; + let direction = match se.result { + SeasonResult::Promoted => "Promoted", + SeasonResult::Relegated => "Relegated", + SeasonResult::Maintained => "Maintained", + }; + let body = format!( + "{direction} — now in Division {}. Rewards: {} coins{}.", + se.new_division, + se.coins_awarded, + se.pack_awarded + .as_deref() + .map(|p| format!(" + {p}")) + .unwrap_or_default() + ); + let _ = notification::create(&state.pool, "season_end", "Season complete!", &body).await; + } +} diff --git a/src/services/match_service.rs b/src/services/match_service.rs index 0172292..af49e39 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -4,17 +4,11 @@ use crate::{ models::{ achievement::AchievementDefinition, card::OwnedCard, - match_result::{ - CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind, MatchRewardResult, - SubmitMatchRequest, - }, + match_result::{CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind}, 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, - statistics, - }, + services::{achievement, card_db::CardDb, objective, season as season_svc, statistics}, }; use rand::{seq::SliceRandom, Rng}; use sqlx::{Sqlite, Transaction}; @@ -127,182 +121,15 @@ const XP_WIN: i64 = 200; const XP_DRAW: i64 = 75; const XP_LOSS: i64 = 30; -pub async fn process_match( - pool: &Pool, - profile_id: &str, +/// Decrement `loan_matches_remaining` for each loan card in the squad's starting +/// XI, removing the cards whose loan ran out and returning their +/// `owned_card_id`s. Runs inside the caller's transaction so a loan that expires +/// commits (or rolls back) with the match that consumed it. +async fn expire_loans_tx( + tx: &mut Transaction<'_, Sqlite>, club_id: &str, - req: &SubmitMatchRequest, - obj_defs: &[ObjectiveDefinition], - ach_defs: &[AchievementDefinition], -) -> AppResult { - if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 { - return Err(crate::error::AppError::BadRequest( - "goals_for and goals_against must each be between 0 and 99".into(), - )); - } - - let outcome = if req.goals_for > req.goals_against { - "win" - } else if req.goals_for == req.goals_against { - "draw" - } else { - "loss" - }; - - let (coins, xp) = match outcome { - "win" => (COINS_WIN, XP_WIN), - "draw" => (COINS_DRAW, XP_DRAW), - _ => (COINS_LOSS, XP_LOSS), - }; - - let match_record = Match::new( - profile_id, - &req.squad_id, - &req.opponent_name, - req.goals_for, - req.goals_against, - &req.mode, - coins, - xp, - ); - - 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(pool) - .await?; - - club::add_coins(pool, club_id, coins).await?; - let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?; - - for ev in &level_ups { - let body = if let Some(ref pack) = ev.pack_granted { - format!( - "You reached level {}! Reward: {} coins + {pack}.", - ev.new_level, ev.coins_granted - ) - } else { - format!( - "You reached level {}! Reward: {} coins.", - ev.new_level, ev.coins_granted - ) - }; - let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body) - .await; - } - - statistics::record_match( - pool, - profile_id, - outcome, - req.goals_for, - req.goals_against, - coins, - ) - .await?; - - if let Some(positions) = &req.goal_positions { - statistics::record_position_goals(pool, profile_id, positions).await?; - } - - let mut objectives_updated = Vec::new(); - - let mut completed = - objective::increment_metric(pool, profile_id, obj_defs, "matchesplayed", 1).await?; - objectives_updated.append(&mut completed); - - if outcome == "win" { - let mut c = - objective::increment_metric(pool, profile_id, obj_defs, "matcheswon", 1).await?; - objectives_updated.append(&mut c); - } - - let mut c = - objective::increment_metric(pool, profile_id, obj_defs, "goalsscored", req.goals_for) - .await?; - objectives_updated.append(&mut c); - - let mut c = - objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?; - objectives_updated.append(&mut c); - - for obj_id in &objectives_updated { - let display_name = obj_defs - .iter() - .find(|d| &d.id == obj_id) - .map(|d| d.title.as_str()) - .unwrap_or(obj_id.as_str()); - let body = format!( - "\"{}\" is now complete. Claim your reward in Objectives.", - display_name - ); - let _ = - notification::create(pool, "objective_complete", "Objective complete!", &body).await; - } - - // Decrement loan matches remaining for squad starters; collect expired loan IDs. - let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?; - - for owned_id in &expired_loans { - let body = - format!("Loan card (id: {owned_id}) has expired and been removed from your club."); - let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await; - } - - // Update season progress (creates the season row if it doesn't exist yet). - season_svc::get_or_create(pool, profile_id).await?; - let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?; - - if let Some(ref se) = season_end { - use crate::models::season::SeasonResult; - let direction = match se.result { - SeasonResult::Promoted => "Promoted", - SeasonResult::Relegated => "Relegated", - SeasonResult::Maintained => "Maintained", - }; - let body = format!( - "{direction} — now in Division {}. Rewards: {} coins{}.", - se.new_division, - se.coins_awarded, - se.pack_awarded - .as_deref() - .map(|p| format!(" + {p}")) - .unwrap_or_default() - ); - let _ = notification::create(pool, "season_end", "Season complete!", &body).await; - } - - let achievements_unlocked = achievement::check_and_unlock(pool, ach_defs, profile_id, club_id) - .await - .unwrap_or_default(); - - Ok(MatchRewardResult { - match_record, - coins_awarded: coins, - xp_awarded: xp, - objectives_updated, - expired_loans, - season_end, - level_ups, - achievements_unlocked, - }) -} - -/// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI. -/// Removes cards whose remaining count hits 0 and returns their owned_card_ids. -async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult> { - // Get starters (is_on_bench = 0) for this squad + squad_id: &str, +) -> AppResult> { let starters: Vec<(String, String)> = sqlx::query_as( "SELECT sp.id, sp.owned_card_id FROM squad_players sp \ JOIN squads s ON s.id = sp.squad_id \ @@ -310,39 +137,36 @@ async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppR ) .bind(squad_id) .bind(club_id) - .fetch_all(pool) + .fetch_all(&mut **tx) .await?; let mut expired = Vec::new(); - for (_sp_id, owned_id) in starters { let card = sqlx::query_as::<_, OwnedCard>( "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ FROM owned_cards WHERE id = ? AND is_loan = 1", ) .bind(&owned_id) - .fetch_optional(pool) + .fetch_optional(&mut **tx) .await?; if let Some(c) = card { let remaining = c.loan_matches_remaining.unwrap_or(0); if remaining <= 1 { - // Loan expired — remove from collection sqlx::query("DELETE FROM owned_cards WHERE id = ?") .bind(&owned_id) - .execute(pool) + .execute(&mut **tx) .await?; expired.push(owned_id); } else { sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?") .bind(remaining - 1) .bind(&owned_id) - .execute(pool) + .execute(&mut **tx) .await?; } } } - Ok(expired) } @@ -526,6 +350,8 @@ async fn complete_match_inner( let mut objectives_updated = Vec::new(); let mut level_ups = Vec::new(); let mut achievements_unlocked = Vec::new(); + let mut expired_loans = Vec::new(); + let mut season_end = None; // A no-contest is recorded (history + idempotency) but has ZERO economic // effect: no coins, XP, statistics, objectives, or achievements. @@ -617,6 +443,18 @@ async fn complete_match_inner( // 7. Achievements. achievements_unlocked = achievement::check_and_unlock_tx(&mut tx, ach_defs, profile_id, club_id, &now).await?; + + // 8. Opt-in progression. Both default OFF: a game whose loans and + // seasons are its own (FIFA 17) must not have Core's model advance — + // and Core's season end GRANTS coins and a pack, which would be + // invisible economy on a path that never asked for it. + if req.expire_loans { + expired_loans = expire_loans_tx(&mut tx, club_id, &req.squad_id).await?; + } + if req.advance_season { + season_end = + season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?; + } } inject_fault(fault, FaultPoint::BeforeCommit)?; @@ -637,6 +475,8 @@ async fn complete_match_inner( objectives_updated, level_ups, achievements_unlocked, + expired_loans, + season_end, match_record, }) } @@ -685,6 +525,8 @@ async fn already_completed( coins_balance, objectives_updated: vec![], level_ups: vec![], + expired_loans: vec![], + season_end: None, achievements_unlocked: vec![], match_record, }) @@ -821,6 +663,8 @@ mod match_completion_tests { goals_against: ga, mode: "seasons".into(), goal_positions: None, + expire_loans: false, + advance_season: false, } } diff --git a/src/services/season.rs b/src/services/season.rs index fc8859f..ab2a172 100644 --- a/src/services/season.rs +++ b/src/services/season.rs @@ -2,8 +2,8 @@ use crate::{ db::Pool, error::{AppError, AppResult}, models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult}, - services::{club, pack}, }; +use sqlx::{Sqlite, Transaction}; use uuid::Uuid; /// Get the current season for a profile, creating it if it doesn't exist. @@ -38,21 +38,34 @@ async fn fetch(pool: &Pool, profile_id: &str) -> AppResult> { Ok(s) } -/// Record a match result in the season; end the season if the quota is met. +/// Record a match in Core's season model inside the caller's transaction, +/// ending the season when the quota is met. /// -/// Returns the updated season and an optional end-of-season summary. -pub async fn record_match( - pool: &Pool, +/// Mirrors [`record_match`] but every write — the season row, the rollover, the +/// end-of-season coin and pack award, and the history entry — commits or rolls +/// back with the match that caused it. Creates the season row if absent, so a +/// first match does not need a separate call. +pub async fn record_match_tx( + tx: &mut Transaction<'_, Sqlite>, club_id: &str, profile_id: &str, outcome: &str, -) -> AppResult<(Season, Option)> { + now: &str, +) -> AppResult> { + sqlx::query( + "INSERT OR IGNORE INTO seasons (profile_id, division, season_number, season_points, \ + matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)", + ) + .bind(profile_id) + .bind(now) + .execute(&mut **tx) + .await?; + let points = match outcome { "win" => 3, "draw" => 1, _ => 0, }; - sqlx::query( "UPDATE seasons SET \ season_points = season_points + ?, \ @@ -67,32 +80,28 @@ pub async fn record_match( .bind(outcome) .bind(outcome) .bind(profile_id) - .execute(pool) + .execute(&mut **tx) .await?; - let season = fetch(pool, profile_id).await?.ok_or_else(|| { + let season = fetch_tx(tx, profile_id).await?.ok_or_else(|| { AppError::Internal(anyhow::anyhow!( - "season row missing after record_match update" + "season row missing after record_match_tx update" )) })?; - if !season.is_complete() { - return Ok((season, None)); + return Ok(None); } - // Season complete — calculate result and start next let result = season.end_result(); let old_div = season.division; let coins = season.season_reward_coins(); let pack_id = season.season_reward_pack(); - let new_div = match result { SeasonResult::Promoted => (old_div - 1).max(1), SeasonResult::Relegated => (old_div + 1).min(10), SeasonResult::Maintained => old_div, }; let new_season = season.season_number + 1; - let now = chrono::Utc::now().to_rfc3339(); sqlx::query( "UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \ @@ -101,30 +110,46 @@ pub async fn record_match( ) .bind(new_div) .bind(new_season) - .bind(&now) + .bind(now) .bind(profile_id) - .execute(pool) + .execute(&mut **tx) .await?; - // Grant rewards - club::add_coins(pool, club_id, coins).await?; + 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_def) = pack_id { - pack::grant_pack(pool, club_id, pack_def).await?; + 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_def) + .bind(now) + .execute(&mut **tx) + .await?; } - // Persist history entry before rolling over let result_str = match result { SeasonResult::Promoted => "promoted", SeasonResult::Maintained => "maintained", SeasonResult::Relegated => "relegated", }; - let history_id = Uuid::new_v4().to_string(); - let _ = sqlx::query( + sqlx::query( "INSERT INTO season_history (id, profile_id, season_number, division, season_points, \ wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", ) - .bind(&history_id) + .bind(Uuid::new_v4().to_string()) .bind(profile_id) .bind(season.season_number) .bind(old_div) @@ -136,23 +161,28 @@ pub async fn record_match( .bind(new_div) .bind(coins) .bind(pack_id) - .bind(&now) - .execute(pool) - .await; + .bind(now) + .execute(&mut **tx) + .await?; - let summary = SeasonEndSummary { + Ok(Some(SeasonEndSummary { result, old_division: old_div, new_division: new_div, new_season_number: new_season, coins_awarded: coins, 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")) - })?; - Ok((updated, Some(summary))) +async fn fetch_tx(tx: &mut Transaction<'_, Sqlite>, profile_id: &str) -> AppResult> { + Ok(sqlx::query_as::<_, Season>( + "SELECT profile_id, division, season_number, season_points, matches_played, \ + wins, draws, losses, started_at FROM seasons WHERE profile_id = ?", + ) + .bind(profile_id) + .fetch_optional(&mut **tx) + .await?) } /// Return past seasons for a profile, newest first (max 20). diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 0c55340..49a1696 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -78,6 +78,199 @@ async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode (status, serde_json::from_slice(&body).unwrap()) } +/// Submit ONE match through the authoritative exactly-once route. +/// +/// `POST /matches/result` was removed as an economy path: it had no transaction +/// and no idempotency key. `/matches/complete` requires a caller-supplied +/// `match_identity`, so every call here mints a fresh one — each call is a +/// distinct match, which is what these tests mean. `expire_loans` and +/// `advance_season` are opted in to keep the Core-mode behaviour the old route +/// used to trigger implicitly. +/// +/// Takes the legacy payload shape and derives the canonical `result` from the +/// scoreline, so call sites read the same as the match they describe. +async fn post_match(app: &axum::Router, payload: Value) -> (StatusCode, Value) { + static NEXT_MATCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let n = NEXT_MATCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + let goals_for = payload["goals_for"].as_i64().unwrap_or(0); + let goals_against = payload["goals_against"].as_i64().unwrap_or(0); + let result = match goals_for.cmp(&goals_against) { + std::cmp::Ordering::Greater => "win", + std::cmp::Ordering::Equal => "draw", + std::cmp::Ordering::Less => "loss", + }; + + let mut body = payload; + body["match_identity"] = serde_json::json!(format!("test-match-{n}")); + body["result"] = serde_json::json!(result); + body["expire_loans"] = serde_json::json!(true); + body["advance_season"] = serde_json::json!(true); + json_post(app, "/matches/complete", body).await +} + +// ── Legacy match-result path is closed ─────────────────────────────────────── + +/// `POST /matches/result` used to be a SECOND economy authority: a dozen writes +/// with no transaction and no idempotency key, so it re-credited the same match +/// on every call. It must now reject and grant nothing, pointing callers at the +/// exactly-once route. +#[tokio::test] +async fn legacy_match_result_route_rejects_and_grants_nothing() { + let app = build_test_app().await; + auth(&app, "LegacyGuard").await; + + let (_, before) = json_get(&app, "/club").await; + let coins_before = before["coins"].as_i64().expect("coins"); + + let (status, body) = json_post( + &app, + "/matches/result", + serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["error"] + .as_str() + .unwrap_or_default() + .contains("/matches/complete"), + "the rejection must name the route that replaced it: {body}" + ); + + let (_, after) = json_get(&app, "/club").await; + assert_eq!( + after["coins"].as_i64().expect("coins"), + coins_before, + "a rejected legacy submit must not move the balance" + ); + let (_, history) = json_get(&app, "/matches").await; + assert!( + history["matches"].as_array().is_none_or(|m| m.is_empty()), + "a rejected legacy submit must not write match history: {history}" + ); +} + +/// The behaviour the legacy route used to trigger implicitly is still reachable, +/// but only when the caller opts in — a game with its own loan and season models +/// (FIFA 17) must not have Core's advance behind its back, because Core's +/// season end GRANTS coins. +#[tokio::test] +async fn core_progression_is_opt_in_on_the_authoritative_route() { + let app = build_test_app().await; + auth(&app, "OptIn").await; + + // Default: no opt-in fields at all. + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "opt-in-off", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["applied"], true); + assert_eq!( + body["season_end"], + serde_json::Value::Null, + "Core's season must not advance unless asked" + ); + let (_, division) = json_get(&app, "/division").await; + assert_eq!( + division["matches_played"], 0, + "no opt-in means Core's season model saw no match" + ); + + // Opting in advances it. + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "opt-in-on", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles", + "advance_season": true + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (_, division) = json_get(&app, "/division").await; + assert_eq!(division["matches_played"], 1); +} + +/// A replay must not re-notify. The pooled path this replaced emitted a fresh +/// notification every time it was called, because it had no replay concept. +#[tokio::test] +async fn replayed_completion_does_not_duplicate_notifications() { + let app = build_test_app().await; + auth(&app, "ReplayNotify").await; + + let submit = || { + json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "same-match", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + }), + ) + }; + + let (status, first) = submit().await; + assert_eq!(status, StatusCode::OK, "{first}"); + assert_eq!(first["applied"], true); + let (_, notifications) = json_get(&app, "/notifications").await; + let after_first = notifications["notifications"].as_array().unwrap().len(); + + let (status, second) = submit().await; + assert_eq!(status, StatusCode::OK, "{second}"); + assert_eq!(second["applied"], false, "replay must not re-apply"); + let (_, notifications) = json_get(&app, "/notifications").await; + assert_eq!( + notifications["notifications"].as_array().unwrap().len(), + after_first, + "a replay must not emit a second set of notifications" + ); +} + +/// A profile that completed a match through the authoritative route must still +/// be resettable: `match_completions` holds un-cascaded foreign keys to both +/// `matches` and `profiles`. +#[tokio::test] +async fn reset_clears_a_profile_that_completed_matches() { + let app = build_test_app().await; + auth(&app, "ResetMe").await; + + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "reset-match", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 2, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let (status, body) = json_post( + &app, + "/auth/reset", + serde_json::json!({ "confirm": "reset" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (status, _) = json_get(&app, "/profile").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + // ── Existing tests ─────────────────────────────────────────────────────────── #[tokio::test] @@ -125,7 +318,7 @@ async fn test_match_result_awards_coins() { "goals_against": 1, "mode": "squad_battles" }); - let (status, json) = json_post(&app, "/matches/result", payload).await; + let (status, json) = post_match(&app, payload).await; assert_eq!(status, StatusCode::OK); assert_eq!(json["match_record"]["outcome"], "win"); assert!(json["coins_awarded"].as_i64().unwrap() > 0); @@ -154,7 +347,7 @@ async fn test_match_with_goal_positions_tracks_stats() { "mode": "squad_battles", "goal_positions": ["ST", "ST", "CAM"] }); - let (status, _) = json_post(&app, "/matches/result", payload).await; + let (status, _) = post_match(&app, payload).await; assert_eq!(status, StatusCode::OK); let (status, stats) = json_get(&app, "/statistics").await; @@ -424,9 +617,8 @@ async fn test_win_streak_tracking() { auth(&app, "StreakPlayer").await; for _ in 0..3 { - let (s, _) = json_post( + let (s, _) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", @@ -980,9 +1172,8 @@ async fn test_division_updates_after_wins() { // Play 3 wins for _ in 0..3 { - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 2, "goals_against": 0, "mode": "squad_battles" @@ -1004,9 +1195,8 @@ async fn test_season_ends_after_10_matches_and_promotes() { // Win all 10 matches of the season for _ in 0..10 { - let (s, result) = json_post( + let (s, result) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 3, "goals_against": 0, "mode": "squad_battles" @@ -1111,9 +1301,8 @@ async fn test_notifications_include_completed_objectives() { auth(&app, "ObjNotifPlayer").await; // Play enough matches to complete the "daily_play_1_match" objective - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -1136,9 +1325,8 @@ async fn test_match_result_returns_season_info() { let app = build_test_app().await; auth(&app, "SeasonMatchPlayer").await; - let (s, result) = json_post( + let (s, result) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 2, "goals_against": 1, "mode": "squad_battles" @@ -1579,9 +1767,8 @@ async fn test_rivals_weekly_reward_claim() { auth(&app, "RivalsClaimPlayer").await; // Play a match to create the season row - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -1601,9 +1788,8 @@ async fn test_rivals_reward_increments_week_counter() { let app = build_test_app().await; auth(&app, "RivalsWeekCounter").await; - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -1730,9 +1916,8 @@ async fn test_match_result_includes_level_ups_on_first_win() { // With enough wins we cross the 500 XP threshold (level 2). let mut level_ups_seen = false; for _ in 0..5 { - let (status, json) = json_post( + let (status, json) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 3, "goals_against": 0, "mode": "squad_battles" @@ -1791,9 +1976,8 @@ async fn test_level_up_creates_persistent_notification() { // Play several wins to guarantee crossing the 500 XP threshold (level 2) for _ in 0..5 { - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 3, "goals_against": 0, "mode": "squad_battles" @@ -1819,9 +2003,8 @@ async fn test_mark_all_notifications_read() { // Generate a notification via level-up for _ in 0..5 { - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 3, "goals_against": 0, "mode": "squad_battles" @@ -1843,9 +2026,8 @@ async fn test_mark_single_notification_read() { // Generate level-up notifications for _ in 0..5 { - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 3, "goals_against": 0, "mode": "squad_battles" @@ -1901,9 +2083,8 @@ async fn test_first_match_achievement_unlocks() { let app = build_test_app().await; auth(&app, "FirstMatchAchPlayer").await; - let (_, result) = json_post( + let (_, result) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -1924,9 +2105,8 @@ async fn test_first_win_achievement_unlocks_on_win() { let app = build_test_app().await; auth(&app, "FirstWinAchPlayer").await; - let (_, result) = json_post( + let (_, result) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 2, "goals_against": 0, "mode": "squad_battles" @@ -1948,9 +2128,8 @@ async fn test_achievements_not_duplicated_on_second_match() { auth(&app, "NoDupAchPlayer").await; // First match — first_match unlocks - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -1959,9 +2138,8 @@ async fn test_achievements_not_duplicated_on_second_match() { .await; // Second match — first_match must NOT appear again - let (_, result) = json_post( + let (_, result) = post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -1983,9 +2161,8 @@ async fn test_achievement_grants_coins() { let coins_before = club_before["coins"].as_i64().unwrap_or(0); // first_match achievement grants 500 coins on top of match reward - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -2027,9 +2204,8 @@ async fn test_auth_reset_clears_profile() { auth(&app, "ResetPlayer").await; // Play a match to generate some state - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 1, "goals_against": 0, "mode": "squad_battles" @@ -2102,9 +2278,8 @@ async fn test_division_history_records_after_season_end() { // Win all 10 matches to complete and promote for _ in 0..10 { - json_post( + post_match( &app, - "/matches/result", serde_json::json!({ "squad_id": "dummy", "opponent_name": "Bot", "goals_for": 3, "goals_against": 0, "mode": "squad_battles"