Compare commits
1 Commits
90210702c3
...
1df03d4287
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df03d4287 |
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Present when this match ended a Core season. `None` unless the caller set
|
||||
/// `advance_season`, and `None` on a replay.
|
||||
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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
|
||||
|
||||
@@ -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<String> {
|
||||
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()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user