fix(matches): close the second, unguarded match-economy authority
CI / Build, lint & test (push) Successful in 3m15s
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:
+25
-9
@@ -71,17 +71,33 @@ All game-content data is loaded at startup from `data/` into `Arc`-wrapped colle
|
||||
8. Increment pack stats + objective progress
|
||||
9. Return `PackOpenResult { pack_id, cards }`
|
||||
|
||||
## Data Flow: Match Result
|
||||
## Data Flow: Match Completion
|
||||
|
||||
1. `POST /matches/result` → `routes::matches::post_match_result`
|
||||
1. `POST /matches/complete` → `routes::matches::post_match_complete`
|
||||
2. Fetch profile + club
|
||||
3. `services::match_service::process_match(...)`
|
||||
4. Determine outcome (win/draw/loss), compute coins + XP
|
||||
5. Insert match record
|
||||
6. `club::add_coins`, `profile::add_xp`
|
||||
7. `statistics::record_match`
|
||||
8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned
|
||||
9. Return `MatchRewardResult`
|
||||
3. `services::match_service::complete_match(...)` — everything below runs in ONE
|
||||
transaction and either commits together or rolls back whole
|
||||
4. Insert the match-history row (also takes SQLite's writer lock, serializing
|
||||
overlapping completions)
|
||||
5. Insert the `match_completions` guard row. `UNIQUE(profile_id, match_identity)`
|
||||
makes the economy exactly-once: a duplicate — sequential, concurrent, after a
|
||||
restart, or a conflicting re-report — collides here and the whole attempt
|
||||
rolls back, then echoes the persisted result with `applied = false`
|
||||
6. Coins, XP + level-ups, W/D/L/DNF statistics, objective metrics, achievements
|
||||
7. Opt-in only: `expire_loans` (loan tick-down/removal) and `advance_season`
|
||||
(Core's own division model, which grants coins and a pack at season end).
|
||||
Both default OFF so a game with its own loan/season model — FIFA 17 — is
|
||||
unaffected
|
||||
8. Commit, then the route emits player notifications for what landed (never
|
||||
inside the transaction, and only when `applied`)
|
||||
9. Return `MatchCompletionResult`
|
||||
|
||||
`POST /matches/result` was REMOVED as an economy path. It performed the same
|
||||
grants across a dozen separate writes with no transaction and no idempotency
|
||||
key, which made it a second economy authority that re-credited on every call and
|
||||
could half-apply on any mid-way failure. It now rejects and names
|
||||
`/matches/complete`. Exactly-once requires a caller-supplied match identity,
|
||||
which its request shape did not carry and could not derive.
|
||||
|
||||
## Single-Profile Design
|
||||
|
||||
|
||||
Reference in New Issue
Block a user