diff --git a/Cargo.toml b/Cargo.toml index b41ad37..bdd2eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,3 +37,4 @@ axum-macros = "0.4" axum-test = "14" tokio = { version = "1", features = ["full"] } tower = { version = "0.5", features = ["util"] } +tempfile = "3" diff --git a/migrations/0020_atomic_sbc_submissions.sql b/migrations/0020_atomic_sbc_submissions.sql new file mode 100644 index 0000000..0643f3c --- /dev/null +++ b/migrations/0020_atomic_sbc_submissions.sql @@ -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; diff --git a/migrations/0021_sbc_challenge_squads.sql b/migrations/0021_sbc_challenge_squads.sql new file mode 100644 index 0000000..d66c8ee --- /dev/null +++ b/migrations/0021_sbc_challenge_squads.sql @@ -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) +); diff --git a/src/app.rs b/src/app.rs index 26070e0..7ab20a5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -251,8 +251,13 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/result", post(routes::matches::post_match_result)) .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)) diff --git a/src/models/sbc.rs b/src/models/sbc.rs index bafe983..f29bd50 100644 --- a/src/models/sbc.rs +++ b/src/models/sbc.rs @@ -33,16 +33,17 @@ pub struct SbcReward { pub pack_id: Option, } -/// 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, 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, } +#[derive(Debug, Deserialize)] +pub struct SaveSbcSquadRequest { + pub owned_card_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbcSquadState { + pub sbc_id: String, + pub owned_card_ids: Vec, +} + #[derive(Debug, Serialize)] pub struct SbcResult { pub passed: bool, diff --git a/src/routes/sbc.rs b/src/routes/sbc.rs index cc86e33..d9dd6ee 100644 --- a/src/routes/sbc.rs +++ b/src/routes/sbc.rs @@ -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, game: GameId) -> AppResult> { + 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, + game: GameId, + Path(sbc_id): Path, +) -> AppResult> { + 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, + game: GameId, + Path(sbc_id): Path, + 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 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, 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)) } diff --git a/src/services/sbc.rs b/src/services/sbc.rs index c69087d..b6d65d9 100644 --- a/src/services/sbc.rs +++ b/src/services/sbc.rs @@ -2,13 +2,15 @@ use crate::{ db::Pool, error::{AppError, AppResult}, models::{ + achievement::AchievementDefinition, card::CardDefinition, - objective::ObjectiveDefinition, - sbc::{SbcDefinition, SbcResult, SubmitSbcRequest}, + objective::{ObjectiveDefinition, ObjectiveProgress}, + sbc::{SbcDefinition, SbcResult, SbcSquadState, SubmitSbcRequest}, }, - services::{card_db::CardDb, club, objective, statistics}, + services::card_db::CardDb, }; use anyhow::Context; +use sqlx::{Sqlite, Transaction}; use std::path::Path; use uuid::Uuid; @@ -36,110 +38,574 @@ pub fn load_sbc_definitions(data_dir: &str) -> anyhow::Result Ok(defs) } -pub async fn submit_sbc( +pub async fn completion_counts( + pool: &Pool, + profile_id: &str, +) -> AppResult> { + let rows: Vec<(String, i64)> = sqlx::query_as( + "SELECT sbc_id, COUNT(*) FROM sbc_submissions \ + WHERE profile_id = ? AND passed = 1 GROUP BY sbc_id", + ) + .bind(profile_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().collect()) +} + +pub async fn load_sbc_squad( + pool: &Pool, + profile_id: &str, + sbc_id: &str, +) -> AppResult { + let ids_json: Option = sqlx::query_scalar( + "SELECT owned_card_ids FROM sbc_challenge_squads \ + WHERE profile_id = ? AND sbc_id = ?", + ) + .bind(profile_id) + .bind(sbc_id) + .fetch_optional(pool) + .await?; + let owned_card_ids = ids_json + .map(|json| serde_json::from_str(&json)) + .transpose()? + .unwrap_or_default(); + Ok(SbcSquadState { + sbc_id: sbc_id.to_owned(), + owned_card_ids, + }) +} + +pub async fn save_sbc_squad( pool: &Pool, - card_db: &CardDb, sbc_defs: &[SbcDefinition], - obj_defs: &[ObjectiveDefinition], profile_id: &str, club_id: &str, + sbc_id: &str, + owned_card_ids: &[String], +) -> AppResult { + if !sbc_defs.iter().any(|def| def.id == sbc_id) { + return Err(AppError::NotFound(format!("SBC {sbc_id} not found"))); + } + if owned_card_ids.len() > MAX_SBC_CARDS { + return Err(AppError::BadRequest(format!( + "too many cards in SBC squad ({}, max {MAX_SBC_CARDS})", + owned_card_ids.len() + ))); + } + let mut seen = std::collections::HashSet::with_capacity(owned_card_ids.len()); + if let Some(duplicate) = owned_card_ids.iter().find(|id| !seen.insert(*id)) { + return Err(AppError::BadRequest(format!( + "duplicate card in SBC squad: {duplicate}" + ))); + } + + let ids_json = serde_json::to_string(owned_card_ids)?; + let now = chrono::Utc::now().to_rfc3339(); + let mut tx = pool.begin().await?; + let outcome: AppResult<()> = async { + // First write obtains SQLite's writer lock before ownership reads, so a + // concurrent consumption cannot leave a saved squad referencing stale items. + sqlx::query( + "INSERT INTO sbc_challenge_squads (profile_id, sbc_id, owned_card_ids, updated_at) \ + VALUES (?, ?, ?, ?) \ + ON CONFLICT(profile_id, sbc_id) DO UPDATE SET \ + owned_card_ids = excluded.owned_card_ids, updated_at = excluded.updated_at", + ) + .bind(profile_id) + .bind(sbc_id) + .bind(&ids_json) + .bind(&now) + .execute(&mut *tx) + .await?; + + for owned_id in owned_card_ids { + let is_loan: Option = + sqlx::query_scalar("SELECT is_loan FROM owned_cards WHERE id = ? AND club_id = ?") + .bind(owned_id) + .bind(club_id) + .fetch_optional(&mut *tx) + .await?; + match is_loan { + None => { + return Err(AppError::NotFound(format!( + "owned card {owned_id} not found" + ))) + } + Some(true) => { + return Err(AppError::Conflict(format!( + "loan card {owned_id} is not eligible for SBC submission" + ))) + } + Some(false) => {} + } + } + Ok(()) + } + .await; + match outcome { + Ok(()) => tx.commit().await?, + Err(error) => { + tx.rollback().await?; + return Err(error); + } + } + Ok(SbcSquadState { + sbc_id: sbc_id.to_owned(), + owned_card_ids: owned_card_ids.to_vec(), + }) +} + +#[derive(Clone, Copy)] +pub struct SbcSubmissionContext<'a> { + pub card_db: &'a CardDb, + pub sbc_defs: &'a [SbcDefinition], + pub objective_defs: &'a [ObjectiveDefinition], + pub achievement_defs: &'a [AchievementDefinition], + pub profile_id: &'a str, + pub club_id: &'a str, +} + +pub async fn submit_sbc( + pool: &Pool, + context: SbcSubmissionContext<'_>, req: &SubmitSbcRequest, ) -> AppResult { - let def = sbc_defs + submit_sbc_inner(pool, &context, req, None).await +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FaultPoint { + AfterValidation, + AfterEligibility, + AfterConsumption, + AfterSubmission, + AfterRewards, + AfterStatistics, + BeforeCommit, +} + +fn inject_fault(actual: Option, point: FaultPoint) -> AppResult<()> { + if actual == Some(point) { + return Err(AppError::Internal(anyhow::anyhow!( + "injected SBC fault at {point:?}" + ))); + } + Ok(()) +} + +async fn submit_sbc_inner( + pool: &Pool, + context: &SbcSubmissionContext<'_>, + req: &SubmitSbcRequest, + fault: Option, +) -> AppResult { + let def = context + .sbc_defs .iter() .find(|d| d.id == req.sbc_id) .ok_or_else(|| AppError::NotFound(format!("SBC {} not found", req.sbc_id)))?; - // Reject duplicate owned-card ids and bound the list length. A repeated id would - // resolve the SAME owned card N times (each fetch succeeds), so `validate_sbc` - // counts it toward the squad size and passes, while the DELETE loop removes it - // only once — i.e. any SBC satisfiable with a single duplicated card = free - // reward. An unbounded list is also a cheap DoS. if req.owned_card_ids.len() > MAX_SBC_CARDS { return Err(AppError::BadRequest(format!( "too many cards in submission ({}, max {MAX_SBC_CARDS})", req.owned_card_ids.len() ))); } - { - let mut seen = std::collections::HashSet::with_capacity(req.owned_card_ids.len()); - if let Some(dup) = req.owned_card_ids.iter().find(|id| !seen.insert(*id)) { - return Err(AppError::BadRequest(format!( - "duplicate card in submission: {dup}" - ))); - } + + let mut seen = std::collections::HashSet::with_capacity(req.owned_card_ids.len()); + if let Some(dup) = req.owned_card_ids.iter().find(|id| !seen.insert(*id)) { + return Err(AppError::BadRequest(format!( + "duplicate card in submission: {dup}" + ))); } - // Resolve cards from DB - let mut cards: Vec = Vec::new(); - for owned_id in &req.owned_card_ids { + if let Some(expires_at) = def.expires_at.as_deref() { + let expires_at = chrono::DateTime::parse_from_rfc3339(expires_at) + .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid SBC expiry: {e}")))?; + if expires_at <= chrono::Utc::now() { + return Err(AppError::Conflict(format!("SBC {} has expired", def.id))); + } + } + if def.reward.coins < 0 || def.reward.xp < 0 { + return Err(AppError::Internal(anyhow::anyhow!( + "SBC {} has a negative reward", + def.id + ))); + } + if def.requirements.squad_size == 0 || def.requirements.squad_size as usize > MAX_SBC_CARDS { + return Err(AppError::Internal(anyhow::anyhow!( + "SBC {} has an invalid squad size", + def.id + ))); + } + if def.requirements.min_chemistry.is_some() { + return Err(AppError::Internal(anyhow::anyhow!( + "SBC {} requires unsupported chemistry validation", + def.id + ))); + } + + let mut canonical_ids = req.owned_card_ids.clone(); + canonical_ids.sort_unstable(); + let card_ids_json = serde_json::to_string(&canonical_ids)?; + let submission_id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let mut tx = pool.begin().await?; + + let outcome = submit_sbc_transaction( + &mut tx, + context.card_db, + context.objective_defs, + context.achievement_defs, + context.profile_id, + context.club_id, + def, + &canonical_ids, + &card_ids_json, + &submission_id, + &now, + fault, + ) + .await; + + match outcome { + Ok(result) if result.passed => { + tx.commit().await?; + Ok(result) + } + Ok(result) => { + tx.rollback().await?; + Ok(result) + } + Err(error) => { + tx.rollback().await?; + Err(error) + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn submit_sbc_transaction( + tx: &mut Transaction<'_, Sqlite>, + card_db: &CardDb, + obj_defs: &[ObjectiveDefinition], + achievement_defs: &[AchievementDefinition], + profile_id: &str, + club_id: &str, + def: &SbcDefinition, + owned_card_ids: &[String], + card_ids_json: &str, + submission_id: &str, + now: &str, + fault: Option, +) -> AppResult { + // This pending row is deliberately the first statement. It obtains SQLite's + // single-writer lock before any ownership read, serializing overlapping submits. + // Any rejection rolls the row back; only passed rows survive. + sqlx::query( + "INSERT INTO sbc_submissions \ + (id, profile_id, club_id, sbc_id, submitted_card_ids, passed, submitted_at, repeatable) \ + VALUES (?, ?, ?, ?, ?, 0, ?, ?)", + ) + .bind(submission_id) + .bind(profile_id) + .bind(club_id) + .bind(&def.id) + .bind(card_ids_json) + .bind(now) + .bind(def.repeatable) + .execute(&mut **tx) + .await?; + + let prior_completion: i64 = if def.repeatable { + sqlx::query_scalar( + "SELECT COUNT(*) FROM sbc_submissions \ + WHERE profile_id = ? AND sbc_id = ? AND submitted_card_ids = ? \ + AND passed = 1 AND id <> ?", + ) + .bind(profile_id) + .bind(&def.id) + .bind(card_ids_json) + .bind(submission_id) + .fetch_one(&mut **tx) + .await? + } else { + sqlx::query_scalar( + "SELECT COUNT(*) FROM sbc_submissions \ + WHERE profile_id = ? AND sbc_id = ? AND passed = 1 AND id <> ?", + ) + .bind(profile_id) + .bind(&def.id) + .bind(submission_id) + .fetch_one(&mut **tx) + .await? + }; + if prior_completion > 0 { + return Err(AppError::Conflict(format!( + "SBC {} already completed", + def.id + ))); + } + inject_fault(fault, FaultPoint::AfterValidation)?; + + let mut cards = Vec::with_capacity(owned_card_ids.len()); + for owned_id in owned_card_ids { let row = sqlx::query_as::<_, crate::models::card::OwnedCard>( - "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?" + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \ + chemistry_style, position_override, training_bonus \ + FROM owned_cards WHERE id = ? AND club_id = ?", ) .bind(owned_id) .bind(club_id) - .fetch_optional(pool) + .fetch_optional(&mut **tx) .await? .ok_or_else(|| AppError::NotFound(format!("owned card {owned_id} not found")))?; + if row.is_loan { + return Err(AppError::Conflict(format!( + "loan card {owned_id} is not eligible for SBC submission" + ))); + } let card = card_db.get(&row.card_id).ok_or_else(|| { AppError::NotFound(format!("card definition {} not found", row.card_id)) })?; - cards.push(card.clone()); + cards.push(card); } + inject_fault(fault, FaultPoint::AfterEligibility)?; let (passed, failures) = validate_sbc(def, &cards); - - if passed { - // Consume cards - for owned_id in &req.owned_card_ids { - sqlx::query("DELETE FROM owned_cards WHERE id = ?") - .bind(owned_id) - .execute(pool) - .await?; - } - - // Record submission - let sub_id = Uuid::new_v4().to_string(); - let card_ids_json = serde_json::to_string(&req.owned_card_ids)?; - sqlx::query( - "INSERT INTO sbc_submissions (id, profile_id, club_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, ?, 1, ?)" - ) - .bind(&sub_id) - .bind(profile_id) - .bind(club_id) - .bind(&req.sbc_id) - .bind(&card_ids_json) - .bind(chrono::Utc::now().to_rfc3339()) - .execute(pool) - .await?; - - // Grant reward - if def.reward.coins > 0 { - club::add_coins(pool, club_id, def.reward.coins).await?; - } - if let Some(pack_id) = &def.reward.pack_id { - crate::services::pack::grant_pack(pool, club_id, pack_id).await?; - } - - statistics::increment_sbcs_completed(pool, profile_id).await?; - objective::increment_metric(pool, profile_id, obj_defs, "sbcscompleted", 1).await?; - - Ok(SbcResult { - passed: true, - failures: vec![], - reward: Some(def.reward.clone()), - }) - } else { - Ok(SbcResult { + if !passed { + return Ok(SbcResult { passed: false, failures, reward: None, - }) + }); } + + for owned_id in owned_card_ids { + // Squad membership is a projection of ownership, not an eligibility veto: + // consuming a valid club item atomically removes every squad reference first. + sqlx::query("DELETE FROM squad_players WHERE owned_card_id = ?") + .bind(owned_id) + .execute(&mut **tx) + .await?; + let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?") + .bind(owned_id) + .bind(club_id) + .execute(&mut **tx) + .await?; + if deleted.rows_affected() != 1 { + return Err(AppError::Conflict(format!( + "owned card {owned_id} changed during submission" + ))); + } + } + inject_fault(fault, FaultPoint::AfterConsumption)?; + + let recorded = sqlx::query("UPDATE sbc_submissions SET passed = 1 WHERE id = ? AND passed = 0") + .bind(submission_id) + .execute(&mut **tx) + .await?; + sqlx::query("DELETE FROM sbc_challenge_squads WHERE profile_id = ? AND sbc_id = ?") + .bind(profile_id) + .bind(&def.id) + .execute(&mut **tx) + .await?; + if recorded.rows_affected() != 1 { + return Err(AppError::Conflict("SBC submission record changed".into())); + } + inject_fault(fault, FaultPoint::AfterSubmission)?; + + 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())); + } + } + if def.reward.xp > 0 { + let credited = sqlx::query("UPDATE profiles SET xp = xp + ?, updated_at = ? WHERE id = ?") + .bind(def.reward.xp) + .bind(now) + .bind(profile_id) + .execute(&mut **tx) + .await?; + if credited.rows_affected() != 1 { + return Err(AppError::NotFound("profile not found".into())); + } + } + if let Some(pack_id) = &def.reward.pack_id { + 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?; + } + inject_fault(fault, FaultPoint::AfterRewards)?; + + sqlx::query( + "INSERT OR IGNORE INTO statistics \ + (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) \ + VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)", + ) + .bind(profile_id) + .bind(now) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE statistics \ + SET sbcs_completed = sbcs_completed + 1, \ + total_coins_earned = total_coins_earned + ?, updated_at = ? \ + WHERE profile_id = ?", + ) + .bind(def.reward.coins) + .bind(now) + .bind(profile_id) + .execute(&mut **tx) + .await?; + increment_sbc_objectives(tx, profile_id, obj_defs, now).await?; + unlock_sbc_achievements(tx, profile_id, club_id, achievement_defs, now).await?; + inject_fault(fault, FaultPoint::AfterStatistics)?; + inject_fault(fault, FaultPoint::BeforeCommit)?; + + Ok(SbcResult { + passed: true, + failures: vec![], + reward: Some(def.reward.clone()), + }) } -fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec) { +async fn unlock_sbc_achievements( + tx: &mut Transaction<'_, Sqlite>, + profile_id: &str, + club_id: &str, + defs: &[AchievementDefinition], + now: &str, +) -> AppResult<()> { + let completed: i64 = + sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?") + .bind(profile_id) + .fetch_one(&mut **tx) + .await?; + + for def in defs + .iter() + .filter(|def| def.trigger == "sbcs_completed" && completed >= 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?; + } + Ok(()) +} + +async fn increment_sbc_objectives( + tx: &mut Transaction<'_, Sqlite>, + profile_id: &str, + defs: &[ObjectiveDefinition], + now: &str, +) -> AppResult<()> { + for def in defs + .iter() + .filter(|def| def.metric.as_str() == "sbcscompleted") + { + 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(progress) = existing { + if progress.completed { + continue; + } + let current = (progress.current + 1).min(def.target); + sqlx::query( + "UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? \ + WHERE id = ?", + ) + .bind(current) + .bind(current >= def.target) + .bind(now) + .bind(&progress.id) + .execute(&mut **tx) + .await?; + } else { + let current = 1_i64.min(def.target); + sqlx::query( + "INSERT INTO objective_progress \ + (id, profile_id, objective_id, current, completed, claimed, updated_at) \ + VALUES (?, ?, ?, ?, ?, 0, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(profile_id) + .bind(&def.id) + .bind(current) + .bind(current >= def.target) + .bind(now) + .execute(&mut **tx) + .await?; + } + } + Ok(()) +} + +fn validate_sbc(def: &SbcDefinition, cards: &[&CardDefinition]) -> (bool, Vec) { let mut failures = Vec::new(); let req = &def.requirements; @@ -222,7 +688,10 @@ fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec(cards: &'a [CardDefinition], key: F) -> std::collections::HashMap<&'a str, usize> +fn count_by<'a, F>( + cards: &'a [&'a CardDefinition], + key: F, +) -> std::collections::HashMap<&'a str, usize> where F: Fn(&'a CardDefinition) -> &'a str, { @@ -233,3 +702,632 @@ where m }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + db, + models::{ + achievement::AchievementDefinition, + card::{CardDefinition, Rarity}, + objective::{ObjectiveDefinition, ObjectiveMetric, ObjectiveType}, + sbc::{SbcRequirements, SbcReward}, + }, + }; + use sqlx::Row; + use tempfile::TempDir; + + const PROFILE_ID: &str = "profile"; + const CLUB_ID: &str = "club"; + const NOW: &str = "2026-08-18T00:00:00Z"; + + struct Fixture { + pool: Pool, + _temp_dir: TempDir, + card_db: CardDb, + definition: SbcDefinition, + } + + #[derive(Debug, PartialEq, Eq)] + struct State { + owned_cards: i64, + saved_squads: i64, + squad_players: i64, + coins: i64, + xp: i64, + packs: i64, + submissions: i64, + successful_submissions: i64, + sbcs_completed: Option, + total_coins_earned: Option, + objective_rows: i64, + achievements: i64, + notifications: i64, + } + + impl Fixture { + async fn new(repeatable: bool) -> Self { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let database_url = format!("sqlite://{}", temp_dir.path().join("core.db").display()); + let pool = db::init_pool(&database_url, 5).await.expect("init pool"); + db::run_migrations(&pool).await.expect("migrations"); + seed_identity(&pool, PROFILE_ID, CLUB_ID).await; + + let cards = (1..=4) + .map(|index| { + let card = card_definition(index); + (card.id.clone(), card) + }) + .collect(); + Self { + pool, + _temp_dir: temp_dir, + card_db: CardDb { cards }, + definition: sbc_definition(repeatable), + } + } + + async fn seed_cards(&self, ids: &[&str]) { + for id in ids { + let definition_id = format!("definition-{id}"); + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, acquired_at) \ + VALUES (?, ?, ?, ?)", + ) + .bind(id) + .bind(CLUB_ID) + .bind(definition_id) + .bind(NOW) + .execute(&self.pool) + .await + .expect("seed owned card"); + } + } + + fn submission_context(&self) -> SbcSubmissionContext<'_> { + SbcSubmissionContext { + card_db: &self.card_db, + sbc_defs: std::slice::from_ref(&self.definition), + objective_defs: &[], + achievement_defs: &[], + profile_id: PROFILE_ID, + club_id: CLUB_ID, + } + } + + async fn submit(&self, ids: &[&str]) -> AppResult { + submit_sbc(&self.pool, self.submission_context(), &request(ids)).await + } + } + + fn card_definition(index: u8) -> CardDefinition { + CardDefinition { + id: format!("definition-card-{index}"), + name: format!("Card {index}"), + overall: 60, + position: "ST".into(), + nation: "Nation".into(), + league: "League".into(), + club: "Club".into(), + pace: 60, + shooting: 60, + passing: 60, + dribbling: 60, + defending: 60, + physical: 60, + rarity: Rarity::Bronze, + image_path: None, + } + } + + fn sbc_definition(repeatable: bool) -> SbcDefinition { + SbcDefinition { + id: "atomic-sbc".into(), + name: "Atomic SBC".into(), + description: "transaction test".into(), + requirements: SbcRequirements { + squad_size: 2, + min_overall: None, + max_overall: Some(64), + min_chemistry: None, + required_leagues: vec![], + required_nations: vec![], + required_clubs: vec![], + min_players_from_same_league: None, + min_players_from_same_nation: None, + min_players_from_same_club: None, + }, + reward: SbcReward { + coins: 100, + xp: 50, + pack_id: Some("reward-pack".into()), + }, + expires_at: None, + repeatable, + } + } + + fn request(ids: &[&str]) -> SubmitSbcRequest { + SubmitSbcRequest { + sbc_id: "atomic-sbc".into(), + owned_card_ids: ids.iter().map(|id| (*id).to_owned()).collect(), + } + } + + async fn seed_identity(pool: &Pool, profile_id: &str, club_id: &str) { + sqlx::query( + "INSERT INTO profiles (id, username, game_id, created_at, updated_at) \ + VALUES (?, ?, 'fifa17', ?, ?)", + ) + .bind(profile_id) + .bind(profile_id) + .bind(NOW) + .bind(NOW) + .execute(pool) + .await + .expect("seed profile"); + sqlx::query( + "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \ + VALUES (?, ?, ?, 1000, ?, ?)", + ) + .bind(club_id) + .bind(profile_id) + .bind(club_id) + .bind(NOW) + .bind(NOW) + .execute(pool) + .await + .expect("seed club"); + } + + async fn state(pool: &Pool) -> State { + let stats = sqlx::query( + "SELECT sbcs_completed, total_coins_earned FROM statistics WHERE profile_id = ?", + ) + .bind(PROFILE_ID) + .fetch_optional(pool) + .await + .expect("statistics"); + State { + owned_cards: count(pool, "SELECT COUNT(*) FROM owned_cards").await, + saved_squads: count(pool, "SELECT COUNT(*) FROM sbc_challenge_squads").await, + squad_players: count(pool, "SELECT COUNT(*) FROM squad_players").await, + coins: sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?") + .bind(CLUB_ID) + .fetch_one(pool) + .await + .expect("coins"), + xp: sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?") + .bind(PROFILE_ID) + .fetch_one(pool) + .await + .expect("xp"), + packs: count(pool, "SELECT COUNT(*) FROM packs").await, + submissions: count(pool, "SELECT COUNT(*) FROM sbc_submissions").await, + successful_submissions: count( + pool, + "SELECT COUNT(*) FROM sbc_submissions WHERE passed = 1", + ) + .await, + sbcs_completed: stats.as_ref().map(|row| row.get("sbcs_completed")), + total_coins_earned: stats.as_ref().map(|row| row.get("total_coins_earned")), + objective_rows: count(pool, "SELECT COUNT(*) FROM objective_progress").await, + achievements: count(pool, "SELECT COUNT(*) FROM player_achievements").await, + notifications: count(pool, "SELECT COUNT(*) FROM notifications").await, + } + } + + async fn count(pool: &Pool, query: &str) -> i64 { + sqlx::query_scalar(query) + .fetch_one(pool) + .await + .expect("count") + } + + async fn assert_single_success(fixture: &Fixture) { + let final_state = state(&fixture.pool).await; + assert_eq!(final_state.owned_cards, 0); + assert_eq!(final_state.coins, 1100); + assert_eq!(final_state.xp, 50); + assert_eq!(final_state.packs, 1); + assert_eq!(final_state.submissions, 1); + assert_eq!(final_state.successful_submissions, 1); + assert_eq!(final_state.sbcs_completed, Some(1)); + assert_eq!(final_state.total_coins_earned, Some(100)); + } + + #[tokio::test] + async fn duplicate_foreign_missing_and_consumed_cards_are_rejected() { + let fixture = Fixture::new(true).await; + fixture.seed_cards(&["card-1", "card-2"]).await; + + let duplicate = fixture.submit(&["card-1", "card-1"]).await; + assert!(matches!(duplicate, Err(AppError::BadRequest(_)))); + + seed_identity(&fixture.pool, "foreign-profile", "foreign-club").await; + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, acquired_at) \ + VALUES ('foreign-card', 'foreign-club', 'definition-card-3', ?)", + ) + .bind(NOW) + .execute(&fixture.pool) + .await + .expect("seed foreign card"); + let foreign = fixture.submit(&["card-1", "foreign-card"]).await; + + assert!(matches!(foreign, Err(AppError::NotFound(_)))); + + let missing = fixture.submit(&["card-1", "missing-card"]).await; + assert!(matches!(missing, Err(AppError::NotFound(_)))); + + fixture + .submit(&["card-1", "card-2"]) + .await + .expect("first submit"); + fixture.seed_cards(&["card-3"]).await; + let consumed = fixture.submit(&["card-1", "card-3"]).await; + assert!(matches!(consumed, Err(AppError::NotFound(_)))); + } + #[tokio::test] + async fn challenge_squad_progress_is_durable_validated_and_consumed_on_success() { + let fixture = Fixture::new(false).await; + fixture.seed_cards(&["card-1", "card-2"]).await; + let saved = save_sbc_squad( + &fixture.pool, + std::slice::from_ref(&fixture.definition), + PROFILE_ID, + CLUB_ID, + &fixture.definition.id, + &["card-2".into(), "card-1".into()], + ) + .await + .expect("save squad"); + assert_eq!(saved.owned_card_ids, ["card-2", "card-1"]); + assert_eq!( + load_sbc_squad(&fixture.pool, PROFILE_ID, &fixture.definition.id) + .await + .expect("load squad") + .owned_card_ids, + ["card-2", "card-1"] + ); + + let duplicate = save_sbc_squad( + &fixture.pool, + std::slice::from_ref(&fixture.definition), + PROFILE_ID, + CLUB_ID, + &fixture.definition.id, + &["card-1".into(), "card-1".into()], + ) + .await; + assert!(matches!(duplicate, Err(AppError::BadRequest(_)))); + assert_eq!( + load_sbc_squad(&fixture.pool, PROFILE_ID, &fixture.definition.id) + .await + .expect("load original") + .owned_card_ids, + ["card-2", "card-1"] + ); + + assert!( + fixture + .submit(&["card-1", "card-2"]) + .await + .expect("submit") + .passed + ); + assert!( + load_sbc_squad(&fixture.pool, PROFILE_ID, &fixture.definition.id) + .await + .expect("load cleared") + .owned_card_ids + .is_empty() + ); + } + + #[tokio::test] + async fn loan_cards_and_requirement_failures_preserve_all_state() { + let mut fixture = Fixture::new(true).await; + fixture.seed_cards(&["card-1"]).await; + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \ + VALUES ('card-2', ?, 'definition-card-2', 1, ?)", + ) + .bind(CLUB_ID) + .bind(NOW) + .execute(&fixture.pool) + .await + .expect("seed loan"); + let before = state(&fixture.pool).await; + let loan = fixture.submit(&["card-1", "card-2"]).await; + assert!(matches!(loan, Err(AppError::Conflict(_)))); + assert_eq!(state(&fixture.pool).await, before); + + fixture.definition.requirements.max_overall = Some(50); + let failed = fixture + .submit(&["card-1", "card-2"]) + .await + .expect_err("loan remains an eligibility error"); + assert!(matches!(failed, AppError::Conflict(_))); + assert_eq!(state(&fixture.pool).await, before); + + sqlx::query("UPDATE owned_cards SET is_loan = 0 WHERE id = 'card-2'") + .execute(&fixture.pool) + .await + .expect("convert loan"); + let before_requirements = state(&fixture.pool).await; + let failed = fixture + .submit(&["card-1", "card-2"]) + .await + .expect("validation response"); + assert!(!failed.passed); + assert!(!failed.failures.is_empty()); + assert_eq!(state(&fixture.pool).await, before_requirements); + } + + #[tokio::test] + async fn sbc_objectives_and_achievements_commit_exactly_once() { + let fixture = Fixture::new(false).await; + fixture + .seed_cards(&["card-1", "card-2", "card-3", "card-4"]) + .await; + let objective = ObjectiveDefinition { + id: "sbc-objective".into(), + title: "Submit".into(), + description: "Submit once".into(), + objective_type: ObjectiveType::Milestone, + metric: ObjectiveMetric::SbcsCompleted, + target: 1, + reward_coins: 0, + reward_pack_id: None, + reward_xp: 0, + }; + let achievement = AchievementDefinition { + id: "sbc-achievement".into(), + title: "SBC complete".into(), + description: "Complete an SBC.".into(), + icon: "sbc".into(), + trigger: "sbcs_completed".into(), + threshold: 1, + reward_coins: 25, + rarity: "bronze".into(), + }; + let context = SbcSubmissionContext { + card_db: &fixture.card_db, + sbc_defs: std::slice::from_ref(&fixture.definition), + objective_defs: std::slice::from_ref(&objective), + achievement_defs: std::slice::from_ref(&achievement), + profile_id: PROFILE_ID, + club_id: CLUB_ID, + }; + let first = submit_sbc(&fixture.pool, context, &request(&["card-1", "card-2"])) + .await + .expect("first submit"); + assert!(first.passed); + + let replay = submit_sbc(&fixture.pool, context, &request(&["card-3", "card-4"])).await; + assert!(matches!(replay, Err(AppError::Conflict(_)))); + + let final_state = state(&fixture.pool).await; + assert_eq!(final_state.coins, 1125); + assert_eq!(final_state.achievements, 1); + assert_eq!(final_state.notifications, 1); + assert_eq!(final_state.objective_rows, 1); + let progress: (i64, bool) = sqlx::query_as( + "SELECT current, completed FROM objective_progress \ + WHERE profile_id = ? AND objective_id = ?", + ) + .bind(PROFILE_ID) + .bind(&objective.id) + .fetch_one(&fixture.pool) + .await + .expect("objective progress"); + assert_eq!(progress, (1, true)); + } + + #[tokio::test] + async fn squad_cards_are_consumed_with_their_projection_links() { + let fixture = Fixture::new(false).await; + fixture.seed_cards(&["card-1", "card-2"]).await; + sqlx::query( + "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \ + VALUES ('squad', ?, 'Squad', '4-4-2', ?, ?)", + ) + .bind(CLUB_ID) + .bind(NOW) + .bind(NOW) + .execute(&fixture.pool) + .await + .expect("seed squad"); + for (index, card_id) in ["card-1", "card-2"].iter().enumerate() { + sqlx::query( + "INSERT INTO squad_players \ + (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \ + VALUES (?, 'squad', ?, ?, 0, 0)", + ) + .bind(format!("slot-{index}")) + .bind(card_id) + .bind(index as i64) + .execute(&fixture.pool) + .await + .expect("seed squad player"); + } + + assert!( + fixture + .submit(&["card-1", "card-2"]) + .await + .expect("submit") + .passed + ); + assert_single_success(&fixture).await; + assert_eq!(state(&fixture.pool).await.squad_players, 0); + } + + #[tokio::test] + async fn concurrent_identical_submissions_grant_once() { + let fixture = Fixture::new(false).await; + fixture.seed_cards(&["card-1", "card-2"]).await; + let first = fixture.submit(&["card-1", "card-2"]); + let second = fixture.submit(&["card-1", "card-2"]); + let (first, second) = tokio::join!(first, second); + + let outcomes = [first, second]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Ok(result) if result.passed)) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Err(AppError::Conflict(_)))) + .count(), + 1 + ); + assert_single_success(&fixture).await; + } + + #[tokio::test] + async fn concurrent_overlapping_submissions_consume_one_card_set() { + let fixture = Fixture::new(true).await; + fixture.seed_cards(&["card-1", "card-2", "card-3"]).await; + let first = fixture.submit(&["card-1", "card-2"]); + let second = fixture.submit(&["card-2", "card-3"]); + let (first, second) = tokio::join!(first, second); + + let outcomes = [first, second]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Ok(result) if result.passed)) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Err(AppError::NotFound(_)))) + .count(), + 1 + ); + let final_state = state(&fixture.pool).await; + assert_eq!(final_state.owned_cards, 1); + assert_eq!(final_state.packs, 1); + assert_eq!(final_state.successful_submissions, 1); + assert_eq!(final_state.sbcs_completed, Some(1)); + } + + #[tokio::test] + async fn repeatability_and_canonical_replay_rules_are_durable() { + let fixture = Fixture::new(true).await; + fixture + .seed_cards(&["card-1", "card-2", "card-3", "card-4"]) + .await; + assert!( + fixture + .submit(&["card-2", "card-1"]) + .await + .expect("first") + .passed + ); + let replay = fixture.submit(&["card-1", "card-2"]).await; + assert!(matches!(replay, Err(AppError::Conflict(_)))); + assert!( + fixture + .submit(&["card-3", "card-4"]) + .await + .expect("second unique") + .passed + ); + + let final_state = state(&fixture.pool).await; + assert_eq!(final_state.coins, 1200); + assert_eq!(final_state.xp, 100); + assert_eq!(final_state.packs, 2); + assert_eq!(final_state.successful_submissions, 2); + assert_eq!(final_state.sbcs_completed, Some(2)); + } + + #[tokio::test] + async fn every_injected_fault_rolls_back_all_state() { + let points = [ + FaultPoint::AfterValidation, + FaultPoint::AfterEligibility, + FaultPoint::AfterConsumption, + FaultPoint::AfterSubmission, + FaultPoint::AfterRewards, + FaultPoint::AfterStatistics, + FaultPoint::BeforeCommit, + ]; + for point in points { + let fixture = Fixture::new(false).await; + fixture.seed_cards(&["card-1", "card-2"]).await; + save_sbc_squad( + &fixture.pool, + std::slice::from_ref(&fixture.definition), + PROFILE_ID, + CLUB_ID, + &fixture.definition.id, + &["card-1".into(), "card-2".into()], + ) + .await + .expect("save challenge squad"); + let before = state(&fixture.pool).await; + let context = fixture.submission_context(); + let result = submit_sbc_inner( + &fixture.pool, + &context, + &request(&["card-1", "card-2"]), + Some(point), + ) + .await; + assert!( + matches!(result, Err(AppError::Internal(_))), + "unexpected result at {point:?}: {result:?}" + ); + assert_eq!(state(&fixture.pool).await, before, "fault at {point:?}"); + } + } + + #[tokio::test] + async fn committed_submission_survives_reopen_and_blocks_replay() { + let fixture = Fixture::new(false).await; + fixture.seed_cards(&["card-1", "card-2"]).await; + assert!( + fixture + .submit(&["card-1", "card-2"]) + .await + .expect("submit") + .passed + ); + let database_url = format!( + "sqlite://{}", + fixture._temp_dir.path().join("core.db").display() + ); + fixture.pool.close().await; + let reopened = db::init_pool(&database_url, 5).await.expect("reopen"); + db::run_migrations(&reopened).await.expect("migrations"); + + let context = SbcSubmissionContext { + card_db: &fixture.card_db, + sbc_defs: std::slice::from_ref(&fixture.definition), + objective_defs: &[], + achievement_defs: &[], + profile_id: PROFILE_ID, + club_id: CLUB_ID, + }; + let replay = submit_sbc(&reopened, context, &request(&["card-1", "card-2"])).await; + assert!(matches!(replay, Err(AppError::Conflict(_)))); + assert_single_success(&Fixture { + pool: reopened, + _temp_dir: fixture._temp_dir, + card_db: fixture.card_db, + definition: fixture.definition, + }) + .await; + } +}