diff --git a/src/models/match_result.rs b/src/models/match_result.rs index 13e5d12..4dc1ceb 100644 --- a/src/models/match_result.rs +++ b/src/models/match_result.rs @@ -133,6 +133,23 @@ pub struct CompleteMatchRequest { /// its own wire). Only a caller using Core's season model opts in. #[serde(default)] pub advance_season: bool, + /// Owned-card instances that TOOK THE FIELD in this match, whose one-match + /// training effects it consumes. + /// + /// Supplied by the caller rather than derived here, and deliberately so. + /// FIFA 17's training rule keys on the player PLAYING, and who played is + /// game-specific knowledge Core does not have: its match wire carries no + /// lineup at all (LIVE_PROVEN over 36,149 captured requests). Core must also + /// not resolve it from the squad at completion time, because the squad at + /// end is provably not the squad that started — a captured match began at + /// 20:33:20 and the next squad save landed 12 minutes later with no + /// `/match/end` in between. The adapter therefore snapshots at kickoff and + /// passes the result here. + /// + /// Empty expires nothing, so a caller that cannot identify participants is + /// simply inert instead of clearing a whole club. + #[serde(default)] + pub participants: Vec, } /// Outcome of [`crate::services::match_service::complete_match`]. @@ -158,6 +175,10 @@ pub struct MatchCompletionResult { /// 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, + /// Owned card instances whose one-match training effect this match consumed. + /// Empty when the caller passed no participants, and empty on a replay — + /// the effect is consumed exactly once, by the first completion. + pub expired_training: 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, diff --git a/src/services/match_service.rs b/src/services/match_service.rs index 9e7dbfc..c2165fe 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -8,7 +8,9 @@ use crate::{ objective::ObjectiveDefinition, profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent}, }, - services::{achievement, card_db::CardDb, objective, season as season_svc, statistics}, + services::{ + achievement, card_db::CardDb, objective, season as season_svc, statistics, training, + }, }; use rand::{seq::SliceRandom, Rng}; use sqlx::{Sqlite, Transaction}; @@ -350,6 +352,7 @@ async fn complete_match_inner( let mut level_ups = Vec::new(); let mut achievements_unlocked = Vec::new(); let mut expired_loans = Vec::new(); + let mut expired_training = Vec::new(); let mut season_end = None; // A no-contest is recorded (history + idempotency) but has ZERO economic @@ -454,6 +457,12 @@ async fn complete_match_inner( season_end = season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?; } + // 9. One-match training effects are consumed by the players who took the + // field. Inside the same transaction and the same `is_economic` + // guard as everything else, so a NoContest voids it exactly as it + // voids coins and statistics, and a rollback leaves the boosts intact. + expired_training = + training::expire_for_instances_tx(&mut tx, club_id, &req.participants).await?; } inject_fault(fault, FaultPoint::BeforeCommit)?; @@ -475,6 +484,7 @@ async fn complete_match_inner( level_ups, achievements_unlocked, expired_loans, + expired_training, season_end, match_record, }) @@ -525,6 +535,7 @@ async fn already_completed( objectives_updated: vec![], level_ups: vec![], expired_loans: vec![], + expired_training: vec![], season_end: None, achievements_unlocked: vec![], match_record, @@ -664,6 +675,7 @@ mod match_completion_tests { goal_positions: None, expire_loans: false, advance_season: false, + participants: vec![], } } @@ -950,6 +962,60 @@ mod match_completion_tests { } } + /// Training expiry must be atomic with the match, in BOTH directions. + /// + /// `BeforeCommit` is the discriminating fault: it fires AFTER the training + /// delete has already run inside the transaction. If the boost were removed + /// outside the transaction — or the transaction did not actually cover it — + /// the row would be gone here while the match itself rolled back, which is + /// exactly the split-brain state (match rejected, training consumed) that + /// must not exist. + #[tokio::test] + async fn a_rolled_back_match_leaves_training_intact() { + let fx = new_fixture().await; + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \ + VALUES ('inst', ?, 'card', 0, 't')", + ) + .bind(CLUB) + .execute(&fx.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO owned_card_training \ + (owned_card_id, attribute_index, amount, source_card_id, applied_at) \ + VALUES ('inst', 4, 15, 'fifa17_5003012', 't')", + ) + .execute(&fx.pool) + .await + .unwrap(); + + let mut r = req("m", MatchResultKind::Win, 3, 1); + r.participants = vec!["inst".into()]; + + let failed = complete_match_inner( + &fx.pool, + PROFILE, + CLUB, + &r, + &[], + &[], + Some(FaultPoint::BeforeCommit), + ) + .await; + assert!(failed.is_err(), "the injected fault must fail the match"); + assert_eq!( + count(&fx.pool, "owned_card_training").await, + 1, + "a rolled-back match must NOT consume the boost" + ); + + // And the clean retry consumes it exactly once. + let ok = complete(&fx.pool, &r).await.unwrap(); + assert_eq!(ok.expired_training, vec!["inst".to_string()]); + assert_eq!(count(&fx.pool, "owned_card_training").await, 0); + } + fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition { ObjectiveDefinition { id: id.into(), diff --git a/src/services/training.rs b/src/services/training.rs index 605842b..32110a2 100644 --- a/src/services/training.rs +++ b/src/services/training.rs @@ -74,6 +74,50 @@ pub async fn load_for_club( .collect()) } +/// Consume the training effects of the instances that took the field, inside a +/// caller-supplied transaction. Returns the instances actually cleared. +/// +/// FIFA 17 training is a ONE-MATCH effect: it "is reflected in the following +/// match and expires after this", and a card applied to someone who stays on the +/// bench or in the reserves "will continue to benefit from the training effect +/// until he plays" (DOCUMENTED — fifauteam's contemporaneous FIFA 17 guide). +/// So the trigger is the PLAYER PLAYING, not the match merely completing, and +/// the caller must pass the instances that played — never a whole club. +/// +/// `club_id` is not redundant with the ids: it scopes the delete so a caller +/// cannot expire another club's effects by guessing an instance id. +/// +/// Idempotent by construction. Deleting an already-absent row is a no-op, so a +/// replayed match cannot "expire twice"; combined with the caller's +/// `match_completions` uniqueness guard, the mutation happens exactly once and a +/// replay is a silent no-op rather than a second effect. +pub async fn expire_for_instances_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + club_id: &str, + instance_ids: &[String], +) -> AppResult> { + let mut expired = Vec::new(); + for id in instance_ids { + // DELETE .. RETURNING so the report is what the database actually + // removed, not what we hoped it would: an id that carried no training, + // or belongs to another club, simply does not appear. + let hit: Option<(String,)> = sqlx::query_as( + "DELETE FROM owned_card_training \ + WHERE owned_card_id = ? \ + AND owned_card_id IN (SELECT id FROM owned_cards WHERE club_id = ?) \ + RETURNING owned_card_id", + ) + .bind(id) + .bind(club_id) + .fetch_optional(&mut **tx) + .await?; + if let Some((got,)) = hit { + expired.push(got); + } + } + Ok(expired) +} + /// The definition's six attributes in canonical slot order. /// /// THIS ORDER IS THE CONTRACT that `attribute_index` indexes. It is diff --git a/tests/integration_test.rs b/tests/integration_test.rs index f63e015..fe14159 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -3675,3 +3675,242 @@ async fn test_collection_reports_owned_rows_it_cannot_project() { .collect(); assert!(!ids.contains(&"ghost")); } + +// ─────────────────── One-match training expiry (lifecycle row 12) ──────────── +// +// FIFA 17 training is a ONE-MATCH effect that is consumed by the player PLAYING, +// not by the match merely completing: a card on someone who stays on the bench +// "will continue to benefit from the training effect until he plays" +// (DOCUMENTED). Core therefore expires exactly the instances the caller says +// took the field, and nothing else. + +/// Seed a club with two owned instances, both carrying a training effect. +/// Returns `(club_id, played_id, benched_id)`. +async fn seed_two_trained( + app: &axum::Router, + pool: &sqlx::SqlitePool, + who: &str, +) -> (String, String, String) { + auth(app, who).await; + let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1") + .fetch_one(pool) + .await + .expect("club exists after auth"); + for id in ["played", "benched"] { + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \ + VALUES (?, ?, 'card_raregold_001', 0, '2026-01-01T00:00:00Z')", + ) + .bind(id) + .bind(&club_id) + .execute(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO owned_card_training \ + (owned_card_id, attribute_index, amount, source_card_id, applied_at) \ + VALUES (?, 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')", + ) + .bind(id) + .execute(pool) + .await + .unwrap(); + } + (club_id, "played".to_string(), "benched".to_string()) +} + +async fn training_rows(pool: &sqlx::SqlitePool) -> Vec { + sqlx::query_scalar("SELECT owned_card_id FROM owned_card_training ORDER BY owned_card_id") + .fetch_all(pool) + .await + .unwrap() +} + +/// The core of the documented rule: only the players who took the field lose +/// their boost. Expiring the whole squad — or the whole club — would clear the +/// benched player the rule explicitly protects. +#[tokio::test] +async fn a_match_expires_training_only_for_the_players_who_played() { + let (app, pool) = build_test_app_with_pool().await; + let (_club, played, benched) = seed_two_trained(&app, &pool, "ExpiryScope").await; + + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "expiry-scope-1", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles", + "participants": [played] + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["expired_training"], serde_json::json!(["played"])); + assert_eq!( + training_rows(&pool).await, + vec![benched], + "the benched player must keep his boost" + ); +} + +/// A caller that cannot identify participants must be INERT, never a club wipe. +#[tokio::test] +async fn a_match_with_no_participants_expires_nothing() { + let (app, pool) = build_test_app_with_pool().await; + seed_two_trained(&app, &pool, "ExpiryNone").await; + + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "expiry-none-1", "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["expired_training"], serde_json::json!([])); + assert_eq!(training_rows(&pool).await, vec!["benched", "played"]); +} + +/// Replay safety. The economic guard already stops double rewards; the training +/// mutation must ride the SAME canonical identity so a resubmitted completion +/// cannot consume a second, freshly-applied boost. +#[tokio::test] +async fn a_replayed_completion_does_not_expire_training_twice() { + let (app, pool) = build_test_app_with_pool().await; + let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryReplay").await; + + let submit = || { + json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "expiry-replay-1", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles", + "participants": [played] + }), + ) + }; + + let (_, first) = submit().await; + assert_eq!(first["applied"], true); + assert_eq!(first["expired_training"], serde_json::json!(["played"])); + + // Re-apply a boost to the same instance, then replay the SAME match. + sqlx::query( + "INSERT INTO owned_card_training \ + (owned_card_id, attribute_index, amount, source_card_id, applied_at) \ + VALUES ('played', 4, 15, 'fifa17_5003012', '2026-01-02T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + + let (_, second) = submit().await; + assert_eq!(second["applied"], false, "replay must not re-apply"); + assert_eq!( + second["expired_training"], + serde_json::json!([]), + "a replay reports no mutation" + ); + assert!( + training_rows(&pool).await.contains(&"played".to_string()), + "the replay must NOT consume the newly applied boost" + ); +} + +/// `NoContest` is a voided match: it grants no coins, XP or statistics, so it +/// must not consume a one-match effect either. Core's `is_economic` guard is the +/// single place that decides this, and training now sits inside it. +#[tokio::test] +async fn a_no_contest_match_does_not_expire_training() { + let (app, pool) = build_test_app_with_pool().await; + let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryVoid").await; + + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "expiry-void-1", "result": "no_contest", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 0, "goals_against": 0, "mode": "squad_battles", + "participants": [played] + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["expired_training"], serde_json::json!([])); + assert_eq!( + training_rows(&pool).await, + vec!["benched", "played"], + "a voided match consumes nothing" + ); +} + +/// An id belonging to somebody else's club must not be expirable by guessing it. +#[tokio::test] +async fn training_expiry_is_scoped_to_the_completing_club() { + let (app, pool) = build_test_app_with_pool().await; + seed_two_trained(&app, &pool, "ExpiryScoped").await; + + // A genuinely separate club, built properly so the FKs hold — the point of + // the test is club scoping, not a dangling row. + // + // created_at is deliberately in the FUTURE: `get_active_profile` selects + // `WHERE game_id = ? ORDER BY created_at ASC LIMIT 1`, and `game_id` + // defaults to 'fifa23' (migration 0016), so a rival dated earlier than the + // authed profile would silently BECOME the active profile and this test + // would assert the opposite of what it means. + sqlx::query( + "INSERT INTO profiles (id, username, created_at, updated_at) \ + VALUES ('other-profile', 'Rival', '2099-01-01T00:00:00Z', '2099-01-01T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO clubs (id, profile_id, name, created_at, updated_at) \ + VALUES ('other-club', 'other-profile', 'Rival FC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \ + VALUES ('foreign', 'other-club', 'card_raregold_001', 0, '2026-01-01T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO owned_card_training \ + (owned_card_id, attribute_index, amount, source_card_id, applied_at) \ + VALUES ('foreign', 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + + let (status, body) = json_post( + &app, + "/matches/complete", + serde_json::json!({ + "match_identity": "expiry-scoped-1", "result": "win", + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles", + "participants": ["foreign"] + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["expired_training"], + serde_json::json!([]), + "another club's effect must not be reachable" + ); + assert!(training_rows(&pool).await.contains(&"foreign".to_string())); +}