feat(match): consume one-match training effects for the players who played
CI / Build, lint & test (push) Successful in 3m19s
CI / Build, lint & test (push) Successful in 3m19s
FIFA 17 training is a ONE-MATCH effect and the trigger is the PLAYER PLAYING, not the match completing: 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, corroborated across two of its pages). So Core expires exactly the instances the caller names, and never a whole club. The participant list is supplied rather than derived here, deliberately: - The FIFA 17 match wire carries NO lineup. Across 36,149 captured requests the 19 match creates carry 5 keys and the 13 ends carry 6; the tokens "lineup" and "substitut" appear ZERO times, while "kitNumber" appears 1311 times and the same extraction recovers 23 instance ids from PUT /squad/0 in that same pcap. The absence is measured against a working positive control, not assumed. - Core must not resolve it from the squad at completion either: the squad at end is provably not the squad that started (a captured match began 20:33:20 and the next squad save landed 12 minutes later with no /match/end between). Empty participants therefore expires nothing, so a caller that cannot identify who played is inert instead of destructive. The mutation sits inside the existing single match transaction, under the same is_economic guard as coins and statistics, so NoContest voids it exactly as it voids everything else, and a rollback leaves boosts intact. Tests: participant scoping (the benched player keeps his boost), empty-participant inertness, club scoping, replay (a resubmitted completion does not consume a freshly reapplied boost), NoContest, and a BeforeCommit fault that fires AFTER the delete to prove the split-brain state "match rejected but training consumed" cannot occur.
This commit is contained in:
@@ -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