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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user