fix(matches): close the second, unguarded match-economy authority
CI / Build, lint & test (push) Successful in 3m15s

`POST /matches/result` granted coins, XP, level-ups, statistics, four objective
metrics, loan expiry, season progression and achievements across a dozen
SEPARATE writes with no transaction and no idempotency key. Every call
re-credited the same match, and any mid-way failure half-applied it. It sat
beside `/matches/complete`, so nothing stopped one match being paid twice
through two different doors.

It cannot be made exactly-once in place: that needs a caller-supplied match
identity, and this request shape has none. Deriving one from the body would
collapse two legitimate matches with the same scoreline into one — the
under-credit trap already documented for the `fp:` fallback. So the route fails
closed: it rejects with a message naming `/matches/complete`, rather than 404,
so a caller learns why.

The behaviour it uniquely drove is kept, not deleted. `process_match` was the
ONLY caller of loan expiry and Core's season model, so both move into
`complete_match`'s transaction behind opt-in `expire_loans` / `advance_season`
flags. Both default OFF, which keeps the FIFA 17 retail path byte-identical:
FIFA 17 has its own loan and Seasons models, and Core's season END GRANTS coins
and a pack — invisible economy on a path that never asked for it. Their pooled
implementations are replaced by `expire_loans_tx` and
`season::record_match_tx`, so a loan that expires or a season that ends commits
with the match that caused it.

Notifications (level-up / objective / loan / season) were pooled side effects of
the removed path. They now emit from the route AFTER the commit — never inside
the transaction, since a failed notification must not roll back a completed
match — and only when `applied`, so a replay no longer re-notifies. The pooled
path had no replay concept and notified every time.

Also fixes a real bug this surfaced: `/auth/reset` never deleted
`match_completions`, which carries un-cascaded foreign keys to BOTH `matches`
and `profiles`. Any profile that completed a match through the authoritative
route — i.e. every FIFA 17 profile after a retail match — failed to reset with a
database error. It is now deleted first, and ordering is documented.

Tests: the 20 integration call sites move to the authoritative route through one
helper that mints a per-call identity (each call IS a distinct match). New
coverage for the closed path: it rejects without moving the balance or writing
history; Core progression stays off unless opted into; a replay does not
duplicate notifications; and a profile that completed matches can still be
reset.
This commit is contained in:
funman300
2026-08-21 04:47:57 +00:00
parent f0550e2ae1
commit bae0a2bdaa
8 changed files with 466 additions and 324 deletions
+22 -26
View File
@@ -50,16 +50,6 @@ impl MatchResultKind {
}
}
#[derive(Debug, Deserialize)]
pub struct SubmitMatchRequest {
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
pub goal_positions: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Match {
pub id: String,
@@ -111,22 +101,6 @@ impl Match {
}
}
#[derive(Debug, Serialize)]
pub struct MatchRewardResult {
pub match_record: Match,
pub coins_awarded: i64,
pub xp_awarded: i64,
pub objectives_updated: Vec<String>,
/// Owned card IDs removed because the loan expired this match.
pub expired_loans: Vec<String>,
/// Present when this match completed the current season.
pub season_end: Option<crate::models::season::SeasonEndSummary>,
/// Non-empty when the player levelled up one or more times from this match's XP.
pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked as a result of this match.
pub achievements_unlocked: Vec<AchievementDefinition>,
}
/// Request to atomically complete a match exactly once. `match_identity` is the
/// opaque, host-supplied per-match token that keys durable economic idempotency
/// (persona/profile + match_identity). `result` is the canonical outcome the
@@ -143,6 +117,22 @@ pub struct CompleteMatchRequest {
pub mode: String,
#[serde(default)]
pub goal_positions: Option<Vec<String>>,
/// Tick down `loan_matches_remaining` for this squad's starters and remove
/// the cards whose loan ran out.
///
/// OFF by default so a game whose loan model is its own (FIFA 17 does not
/// route loans through Core) is unaffected. Callers of Core's own match
/// modes opt in.
#[serde(default)]
pub expire_loans: bool,
/// Advance Core's OWN season model (division progress, and its end-of-season
/// coin/pack award).
///
/// OFF by default: this grants economy, and it is NOT the same thing as a
/// game's native seasons (FIFA 17 offline Seasons are the adapter's, keyed by
/// its own wire). Only a caller using Core's season model opts in.
#[serde(default)]
pub advance_season: bool,
}
/// Outcome of [`crate::services::match_service::complete_match`].
@@ -165,5 +155,11 @@ pub struct MatchCompletionResult {
pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked by this match (empty on a replay).
pub achievements_unlocked: Vec<AchievementDefinition>,
/// 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>,
/// 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>,
pub match_record: Match,
}