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:
@@ -70,7 +70,7 @@ DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/op
|
|||||||
| `GET` | `/squad` | Get active squad |
|
| `GET` | `/squad` | Get active squad |
|
||||||
| `POST` | `/squad` | Save squad |
|
| `POST` | `/squad` | Save squad |
|
||||||
| `GET` | `/objectives` | List objectives with progress |
|
| `GET` | `/objectives` | List objectives with progress |
|
||||||
| `POST` | `/matches/result` | Submit match result + receive rewards |
|
| `POST` | `/matches/complete` | Complete a match exactly once + receive rewards |
|
||||||
| `GET` | `/sbc` | List SBC definitions |
|
| `GET` | `/sbc` | List SBC definitions |
|
||||||
| `POST` | `/sbc/submit` | Submit SBC solution |
|
| `POST` | `/sbc/submit` | Submit SBC solution |
|
||||||
| `GET` | `/market` | Browse NPC transfer market |
|
| `GET` | `/market` | Browse NPC transfer market |
|
||||||
@@ -95,10 +95,11 @@ curl http://localhost:8080/club
|
|||||||
# Open your starter pack
|
# Open your starter pack
|
||||||
curl -X POST http://localhost:8080/packs/open/<pack_id>
|
curl -X POST http://localhost:8080/packs/open/<pack_id>
|
||||||
|
|
||||||
# Submit a match win
|
# Submit a match win. `match_identity` keys exactly-once economy: resubmitting the
|
||||||
curl -X POST http://localhost:8080/matches/result \
|
# same identity echoes the first result and grants nothing twice.
|
||||||
|
curl -X POST http://localhost:8080/matches/complete \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}'
|
-d '{"match_identity":"match-1","result":"win","squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+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
|
8. Increment pack stats + objective progress
|
||||||
9. Return `PackOpenResult { pack_id, cards }`
|
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
|
2. Fetch profile + club
|
||||||
3. `services::match_service::process_match(...)`
|
3. `services::match_service::complete_match(...)` — everything below runs in ONE
|
||||||
4. Determine outcome (win/draw/loss), compute coins + XP
|
transaction and either commits together or rolls back whole
|
||||||
5. Insert match record
|
4. Insert the match-history row (also takes SQLite's writer lock, serializing
|
||||||
6. `club::add_coins`, `profile::add_xp`
|
overlapping completions)
|
||||||
7. `statistics::record_match`
|
5. Insert the `match_completions` guard row. `UNIQUE(profile_id, match_identity)`
|
||||||
8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned
|
makes the economy exactly-once: a duplicate — sequential, concurrent, after a
|
||||||
9. Return `MatchRewardResult`
|
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
|
## Single-Profile Design
|
||||||
|
|
||||||
|
|||||||
+22
-26
@@ -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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Match {
|
pub struct Match {
|
||||||
pub id: String,
|
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
|
/// Request to atomically complete a match exactly once. `match_identity` is the
|
||||||
/// opaque, host-supplied per-match token that keys durable economic idempotency
|
/// opaque, host-supplied per-match token that keys durable economic idempotency
|
||||||
/// (persona/profile + match_identity). `result` is the canonical outcome the
|
/// (persona/profile + match_identity). `result` is the canonical outcome the
|
||||||
@@ -143,6 +117,22 @@ pub struct CompleteMatchRequest {
|
|||||||
pub mode: String,
|
pub mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub goal_positions: Option<Vec<String>>,
|
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`].
|
/// Outcome of [`crate::services::match_service::complete_match`].
|
||||||
@@ -165,5 +155,11 @@ pub struct MatchCompletionResult {
|
|||||||
pub level_ups: Vec<LevelUpEvent>,
|
pub level_ups: Vec<LevelUpEvent>,
|
||||||
/// Achievements unlocked by this match (empty on a replay).
|
/// Achievements unlocked by this match (empty on a replay).
|
||||||
pub achievements_unlocked: Vec<AchievementDefinition>,
|
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,
|
pub match_record: Match,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,12 @@ pub async fn post_auth_reset(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Order matters: `match_completions` carries un-cascaded foreign keys to BOTH
|
||||||
|
// `matches` and `profiles`, so it has to go before either of them or the
|
||||||
|
// reset fails with a constraint error. Any profile that completed a match
|
||||||
|
// through /matches/complete has rows here.
|
||||||
for table in [
|
for table in [
|
||||||
|
"match_completions",
|
||||||
"fut_champs_sessions",
|
"fut_champs_sessions",
|
||||||
"sbc_submissions",
|
"sbc_submissions",
|
||||||
"objective_progress",
|
"objective_progress",
|
||||||
|
|||||||
+99
-24
@@ -8,11 +8,9 @@ use serde_json::{json, Value};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::match_result::{
|
models::match_result::{CompleteMatchRequest, Match, MatchCompletionResult},
|
||||||
CompleteMatchRequest, Match, MatchCompletionResult, MatchRewardResult, SubmitMatchRequest,
|
services::{club as club_svc, match_service, notification, profile as profile_svc},
|
||||||
},
|
|
||||||
services::{club as club_svc, match_service, profile as profile_svc},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -65,25 +63,28 @@ pub async fn get_opponent(
|
|||||||
Ok(Json(opponent))
|
Ok(Json(opponent))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_match_result(
|
/// `POST /matches/result` — REMOVED as an economy path, and deliberately kept as
|
||||||
State(state): State<AppState>,
|
/// an explicit rejection rather than a 404.
|
||||||
game: GameId,
|
///
|
||||||
Json(req): Json<SubmitMatchRequest>,
|
/// It used to grant coins, XP, level-ups, statistics, objectives, loan expiry,
|
||||||
) -> AppResult<Json<MatchRewardResult>> {
|
/// season progression and achievements across a dozen separate writes with NO
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
/// transaction and NO idempotency key, which made it a second economy authority
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
/// that could re-credit the same match on every call and could half-apply on any
|
||||||
|
/// mid-way failure. Exactly-once needs a caller-supplied match identity, which
|
||||||
let result = match_service::process_match(
|
/// this request shape does not carry and cannot derive (a body fingerprint would
|
||||||
&state.pool,
|
/// collapse two legitimate matches with the same scoreline into one).
|
||||||
&profile.id,
|
///
|
||||||
&club.id,
|
/// Callers submit to `/matches/complete` with a `match_identity`; the loan and
|
||||||
&req,
|
/// season behaviour this route used to trigger is available there via
|
||||||
&state.obj_defs,
|
/// `expire_loans` / `advance_season`.
|
||||||
&state.achievement_defs,
|
pub async fn post_match_result() -> AppResult<Json<Value>> {
|
||||||
)
|
Err(AppError::BadRequest(
|
||||||
.await?;
|
"POST /matches/result is no longer an economy path: it had no transaction \
|
||||||
|
and no idempotency key. Submit to POST /matches/complete with a \
|
||||||
Ok(Json(result))
|
match_identity (and expire_loans / advance_season if you need Core's loan \
|
||||||
|
and season progression)."
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
|
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
|
||||||
@@ -108,5 +109,79 @@ pub async fn post_match_complete(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Player-visible notifications are non-durable side effects, so they are
|
||||||
|
// emitted AFTER the economy transaction commits, never inside it — a failed
|
||||||
|
// notification must not roll back a completed match. Gated on `applied`, so
|
||||||
|
// an idempotent replay does not re-notify (the pooled path this replaced had
|
||||||
|
// no such guard). Achievement notifications are written in-transaction by
|
||||||
|
// `check_and_unlock_tx` and are deliberately not repeated here.
|
||||||
|
if result.applied {
|
||||||
|
emit_match_notifications(&state, &result).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn emit_match_notifications(state: &AppState, result: &MatchCompletionResult) {
|
||||||
|
for ev in &result.level_ups {
|
||||||
|
let body = match &ev.pack_granted {
|
||||||
|
Some(pack) => format!(
|
||||||
|
"You reached level {}! Reward: {} coins + {pack}.",
|
||||||
|
ev.new_level, ev.coins_granted
|
||||||
|
),
|
||||||
|
None => format!(
|
||||||
|
"You reached level {}! Reward: {} coins.",
|
||||||
|
ev.new_level, ev.coins_granted
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let _ = notification::create(
|
||||||
|
&state.pool,
|
||||||
|
"level_up",
|
||||||
|
&format!("Level {}!", ev.new_level),
|
||||||
|
&body,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
for obj_id in &result.objectives_updated {
|
||||||
|
let display_name = state
|
||||||
|
.obj_defs
|
||||||
|
.iter()
|
||||||
|
.find(|d| &d.id == obj_id)
|
||||||
|
.map(|d| d.title.as_str())
|
||||||
|
.unwrap_or(obj_id.as_str());
|
||||||
|
let body = format!("\"{display_name}\" is now complete. Claim your reward in Objectives.");
|
||||||
|
let _ = notification::create(
|
||||||
|
&state.pool,
|
||||||
|
"objective_complete",
|
||||||
|
"Objective complete!",
|
||||||
|
&body,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
for owned_id in &result.expired_loans {
|
||||||
|
let body =
|
||||||
|
format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
|
||||||
|
let _ = notification::create(&state.pool, "loan_expired", "Loan card expired", &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(se) = &result.season_end {
|
||||||
|
use crate::models::season::SeasonResult;
|
||||||
|
let direction = match se.result {
|
||||||
|
SeasonResult::Promoted => "Promoted",
|
||||||
|
SeasonResult::Relegated => "Relegated",
|
||||||
|
SeasonResult::Maintained => "Maintained",
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
"{direction} — now in Division {}. Rewards: {} coins{}.",
|
||||||
|
se.new_division,
|
||||||
|
se.coins_awarded,
|
||||||
|
se.pack_awarded
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| format!(" + {p}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
let _ = notification::create(&state.pool, "season_end", "Season complete!", &body).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-190
@@ -4,17 +4,11 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
achievement::AchievementDefinition,
|
achievement::AchievementDefinition,
|
||||||
card::OwnedCard,
|
card::OwnedCard,
|
||||||
match_result::{
|
match_result::{CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind},
|
||||||
CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind, MatchRewardResult,
|
|
||||||
SubmitMatchRequest,
|
|
||||||
},
|
|
||||||
objective::ObjectiveDefinition,
|
objective::ObjectiveDefinition,
|
||||||
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
||||||
},
|
},
|
||||||
services::{
|
services::{achievement, card_db::CardDb, objective, season as season_svc, statistics},
|
||||||
achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc,
|
|
||||||
statistics,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use rand::{seq::SliceRandom, Rng};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
use sqlx::{Sqlite, Transaction};
|
use sqlx::{Sqlite, Transaction};
|
||||||
@@ -127,182 +121,15 @@ const XP_WIN: i64 = 200;
|
|||||||
const XP_DRAW: i64 = 75;
|
const XP_DRAW: i64 = 75;
|
||||||
const XP_LOSS: i64 = 30;
|
const XP_LOSS: i64 = 30;
|
||||||
|
|
||||||
pub async fn process_match(
|
/// Decrement `loan_matches_remaining` for each loan card in the squad's starting
|
||||||
pool: &Pool,
|
/// XI, removing the cards whose loan ran out and returning their
|
||||||
profile_id: &str,
|
/// `owned_card_id`s. Runs inside the caller's transaction so a loan that expires
|
||||||
|
/// commits (or rolls back) with the match that consumed it.
|
||||||
|
async fn expire_loans_tx(
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
club_id: &str,
|
club_id: &str,
|
||||||
req: &SubmitMatchRequest,
|
squad_id: &str,
|
||||||
obj_defs: &[ObjectiveDefinition],
|
) -> AppResult<Vec<String>> {
|
||||||
ach_defs: &[AchievementDefinition],
|
|
||||||
) -> AppResult<MatchRewardResult> {
|
|
||||||
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
|
||||||
return Err(crate::error::AppError::BadRequest(
|
|
||||||
"goals_for and goals_against must each be between 0 and 99".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let outcome = if req.goals_for > req.goals_against {
|
|
||||||
"win"
|
|
||||||
} else if req.goals_for == req.goals_against {
|
|
||||||
"draw"
|
|
||||||
} else {
|
|
||||||
"loss"
|
|
||||||
};
|
|
||||||
|
|
||||||
let (coins, xp) = match outcome {
|
|
||||||
"win" => (COINS_WIN, XP_WIN),
|
|
||||||
"draw" => (COINS_DRAW, XP_DRAW),
|
|
||||||
_ => (COINS_LOSS, XP_LOSS),
|
|
||||||
};
|
|
||||||
|
|
||||||
let match_record = Match::new(
|
|
||||||
profile_id,
|
|
||||||
&req.squad_id,
|
|
||||||
&req.opponent_name,
|
|
||||||
req.goals_for,
|
|
||||||
req.goals_against,
|
|
||||||
&req.mode,
|
|
||||||
coins,
|
|
||||||
xp,
|
|
||||||
);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
)
|
|
||||||
.bind(&match_record.id)
|
|
||||||
.bind(&match_record.profile_id)
|
|
||||||
.bind(&match_record.squad_id)
|
|
||||||
.bind(&match_record.opponent_name)
|
|
||||||
.bind(match_record.goals_for)
|
|
||||||
.bind(match_record.goals_against)
|
|
||||||
.bind(&match_record.outcome)
|
|
||||||
.bind(match_record.coins_awarded)
|
|
||||||
.bind(match_record.xp_awarded)
|
|
||||||
.bind(&match_record.mode)
|
|
||||||
.bind(&match_record.played_at)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
|
||||||
let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?;
|
|
||||||
|
|
||||||
for ev in &level_ups {
|
|
||||||
let body = if let Some(ref pack) = ev.pack_granted {
|
|
||||||
format!(
|
|
||||||
"You reached level {}! Reward: {} coins + {pack}.",
|
|
||||||
ev.new_level, ev.coins_granted
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"You reached level {}! Reward: {} coins.",
|
|
||||||
ev.new_level, ev.coins_granted
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
statistics::record_match(
|
|
||||||
pool,
|
|
||||||
profile_id,
|
|
||||||
outcome,
|
|
||||||
req.goals_for,
|
|
||||||
req.goals_against,
|
|
||||||
coins,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(positions) = &req.goal_positions {
|
|
||||||
statistics::record_position_goals(pool, profile_id, positions).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut objectives_updated = Vec::new();
|
|
||||||
|
|
||||||
let mut completed =
|
|
||||||
objective::increment_metric(pool, profile_id, obj_defs, "matchesplayed", 1).await?;
|
|
||||||
objectives_updated.append(&mut completed);
|
|
||||||
|
|
||||||
if outcome == "win" {
|
|
||||||
let mut c =
|
|
||||||
objective::increment_metric(pool, profile_id, obj_defs, "matcheswon", 1).await?;
|
|
||||||
objectives_updated.append(&mut c);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut c =
|
|
||||||
objective::increment_metric(pool, profile_id, obj_defs, "goalsscored", req.goals_for)
|
|
||||||
.await?;
|
|
||||||
objectives_updated.append(&mut c);
|
|
||||||
|
|
||||||
let mut c =
|
|
||||||
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
|
||||||
objectives_updated.append(&mut c);
|
|
||||||
|
|
||||||
for obj_id in &objectives_updated {
|
|
||||||
let display_name = obj_defs
|
|
||||||
.iter()
|
|
||||||
.find(|d| &d.id == obj_id)
|
|
||||||
.map(|d| d.title.as_str())
|
|
||||||
.unwrap_or(obj_id.as_str());
|
|
||||||
let body = format!(
|
|
||||||
"\"{}\" is now complete. Claim your reward in Objectives.",
|
|
||||||
display_name
|
|
||||||
);
|
|
||||||
let _ =
|
|
||||||
notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
|
||||||
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
|
||||||
|
|
||||||
for owned_id in &expired_loans {
|
|
||||||
let body =
|
|
||||||
format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
|
|
||||||
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update season progress (creates the season row if it doesn't exist yet).
|
|
||||||
season_svc::get_or_create(pool, profile_id).await?;
|
|
||||||
let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?;
|
|
||||||
|
|
||||||
if let Some(ref se) = season_end {
|
|
||||||
use crate::models::season::SeasonResult;
|
|
||||||
let direction = match se.result {
|
|
||||||
SeasonResult::Promoted => "Promoted",
|
|
||||||
SeasonResult::Relegated => "Relegated",
|
|
||||||
SeasonResult::Maintained => "Maintained",
|
|
||||||
};
|
|
||||||
let body = format!(
|
|
||||||
"{direction} — now in Division {}. Rewards: {} coins{}.",
|
|
||||||
se.new_division,
|
|
||||||
se.coins_awarded,
|
|
||||||
se.pack_awarded
|
|
||||||
.as_deref()
|
|
||||||
.map(|p| format!(" + {p}"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
);
|
|
||||||
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let achievements_unlocked = achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(MatchRewardResult {
|
|
||||||
match_record,
|
|
||||||
coins_awarded: coins,
|
|
||||||
xp_awarded: xp,
|
|
||||||
objectives_updated,
|
|
||||||
expired_loans,
|
|
||||||
season_end,
|
|
||||||
level_ups,
|
|
||||||
achievements_unlocked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI.
|
|
||||||
/// Removes cards whose remaining count hits 0 and returns their owned_card_ids.
|
|
||||||
async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<Vec<String>> {
|
|
||||||
// Get starters (is_on_bench = 0) for this squad
|
|
||||||
let starters: Vec<(String, String)> = sqlx::query_as(
|
let starters: Vec<(String, String)> = sqlx::query_as(
|
||||||
"SELECT sp.id, sp.owned_card_id FROM squad_players sp \
|
"SELECT sp.id, sp.owned_card_id FROM squad_players sp \
|
||||||
JOIN squads s ON s.id = sp.squad_id \
|
JOIN squads s ON s.id = sp.squad_id \
|
||||||
@@ -310,39 +137,36 @@ async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppR
|
|||||||
)
|
)
|
||||||
.bind(squad_id)
|
.bind(squad_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut expired = Vec::new();
|
let mut expired = Vec::new();
|
||||||
|
|
||||||
for (_sp_id, owned_id) in starters {
|
for (_sp_id, owned_id) in starters {
|
||||||
let card = sqlx::query_as::<_, OwnedCard>(
|
let card = sqlx::query_as::<_, OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
||||||
FROM owned_cards WHERE id = ? AND is_loan = 1",
|
FROM owned_cards WHERE id = ? AND is_loan = 1",
|
||||||
)
|
)
|
||||||
.bind(&owned_id)
|
.bind(&owned_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(c) = card {
|
if let Some(c) = card {
|
||||||
let remaining = c.loan_matches_remaining.unwrap_or(0);
|
let remaining = c.loan_matches_remaining.unwrap_or(0);
|
||||||
if remaining <= 1 {
|
if remaining <= 1 {
|
||||||
// Loan expired — remove from collection
|
|
||||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||||
.bind(&owned_id)
|
.bind(&owned_id)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
expired.push(owned_id);
|
expired.push(owned_id);
|
||||||
} else {
|
} else {
|
||||||
sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?")
|
sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?")
|
||||||
.bind(remaining - 1)
|
.bind(remaining - 1)
|
||||||
.bind(&owned_id)
|
.bind(&owned_id)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(expired)
|
Ok(expired)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +350,8 @@ async fn complete_match_inner(
|
|||||||
let mut objectives_updated = Vec::new();
|
let mut objectives_updated = Vec::new();
|
||||||
let mut level_ups = Vec::new();
|
let mut level_ups = Vec::new();
|
||||||
let mut achievements_unlocked = Vec::new();
|
let mut achievements_unlocked = Vec::new();
|
||||||
|
let mut expired_loans = Vec::new();
|
||||||
|
let mut season_end = None;
|
||||||
|
|
||||||
// A no-contest is recorded (history + idempotency) but has ZERO economic
|
// A no-contest is recorded (history + idempotency) but has ZERO economic
|
||||||
// effect: no coins, XP, statistics, objectives, or achievements.
|
// effect: no coins, XP, statistics, objectives, or achievements.
|
||||||
@@ -617,6 +443,18 @@ async fn complete_match_inner(
|
|||||||
// 7. Achievements.
|
// 7. Achievements.
|
||||||
achievements_unlocked =
|
achievements_unlocked =
|
||||||
achievement::check_and_unlock_tx(&mut tx, ach_defs, profile_id, club_id, &now).await?;
|
achievement::check_and_unlock_tx(&mut tx, ach_defs, profile_id, club_id, &now).await?;
|
||||||
|
|
||||||
|
// 8. Opt-in progression. Both default OFF: a game whose loans and
|
||||||
|
// seasons are its own (FIFA 17) must not have Core's model advance —
|
||||||
|
// and Core's season end GRANTS coins and a pack, which would be
|
||||||
|
// invisible economy on a path that never asked for it.
|
||||||
|
if req.expire_loans {
|
||||||
|
expired_loans = expire_loans_tx(&mut tx, club_id, &req.squad_id).await?;
|
||||||
|
}
|
||||||
|
if req.advance_season {
|
||||||
|
season_end =
|
||||||
|
season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
inject_fault(fault, FaultPoint::BeforeCommit)?;
|
inject_fault(fault, FaultPoint::BeforeCommit)?;
|
||||||
|
|
||||||
@@ -637,6 +475,8 @@ async fn complete_match_inner(
|
|||||||
objectives_updated,
|
objectives_updated,
|
||||||
level_ups,
|
level_ups,
|
||||||
achievements_unlocked,
|
achievements_unlocked,
|
||||||
|
expired_loans,
|
||||||
|
season_end,
|
||||||
match_record,
|
match_record,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -685,6 +525,8 @@ async fn already_completed(
|
|||||||
coins_balance,
|
coins_balance,
|
||||||
objectives_updated: vec![],
|
objectives_updated: vec![],
|
||||||
level_ups: vec![],
|
level_ups: vec![],
|
||||||
|
expired_loans: vec![],
|
||||||
|
season_end: None,
|
||||||
achievements_unlocked: vec![],
|
achievements_unlocked: vec![],
|
||||||
match_record,
|
match_record,
|
||||||
})
|
})
|
||||||
@@ -821,6 +663,8 @@ mod match_completion_tests {
|
|||||||
goals_against: ga,
|
goals_against: ga,
|
||||||
mode: "seasons".into(),
|
mode: "seasons".into(),
|
||||||
goal_positions: None,
|
goal_positions: None,
|
||||||
|
expire_loans: false,
|
||||||
|
advance_season: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-33
@@ -2,8 +2,8 @@ use crate::{
|
|||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||||
services::{club, pack},
|
|
||||||
};
|
};
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Get the current season for a profile, creating it if it doesn't exist.
|
/// Get the current season for a profile, creating it if it doesn't exist.
|
||||||
@@ -38,21 +38,34 @@ async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
|||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a match result in the season; end the season if the quota is met.
|
/// Record a match in Core's season model inside the caller's transaction,
|
||||||
|
/// ending the season when the quota is met.
|
||||||
///
|
///
|
||||||
/// Returns the updated season and an optional end-of-season summary.
|
/// Mirrors [`record_match`] but every write — the season row, the rollover, the
|
||||||
pub async fn record_match(
|
/// end-of-season coin and pack award, and the history entry — commits or rolls
|
||||||
pool: &Pool,
|
/// back with the match that caused it. Creates the season row if absent, so a
|
||||||
|
/// first match does not need a separate call.
|
||||||
|
pub async fn record_match_tx(
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
club_id: &str,
|
club_id: &str,
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
outcome: &str,
|
outcome: &str,
|
||||||
) -> AppResult<(Season, Option<SeasonEndSummary>)> {
|
now: &str,
|
||||||
|
) -> AppResult<Option<SeasonEndSummary>> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO seasons (profile_id, division, season_number, season_points, \
|
||||||
|
matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let points = match outcome {
|
let points = match outcome {
|
||||||
"win" => 3,
|
"win" => 3,
|
||||||
"draw" => 1,
|
"draw" => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE seasons SET \
|
"UPDATE seasons SET \
|
||||||
season_points = season_points + ?, \
|
season_points = season_points + ?, \
|
||||||
@@ -67,32 +80,28 @@ pub async fn record_match(
|
|||||||
.bind(outcome)
|
.bind(outcome)
|
||||||
.bind(outcome)
|
.bind(outcome)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let season = fetch(pool, profile_id).await?.ok_or_else(|| {
|
let season = fetch_tx(tx, profile_id).await?.ok_or_else(|| {
|
||||||
AppError::Internal(anyhow::anyhow!(
|
AppError::Internal(anyhow::anyhow!(
|
||||||
"season row missing after record_match update"
|
"season row missing after record_match_tx update"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !season.is_complete() {
|
if !season.is_complete() {
|
||||||
return Ok((season, None));
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Season complete — calculate result and start next
|
|
||||||
let result = season.end_result();
|
let result = season.end_result();
|
||||||
let old_div = season.division;
|
let old_div = season.division;
|
||||||
let coins = season.season_reward_coins();
|
let coins = season.season_reward_coins();
|
||||||
let pack_id = season.season_reward_pack();
|
let pack_id = season.season_reward_pack();
|
||||||
|
|
||||||
let new_div = match result {
|
let new_div = match result {
|
||||||
SeasonResult::Promoted => (old_div - 1).max(1),
|
SeasonResult::Promoted => (old_div - 1).max(1),
|
||||||
SeasonResult::Relegated => (old_div + 1).min(10),
|
SeasonResult::Relegated => (old_div + 1).min(10),
|
||||||
SeasonResult::Maintained => old_div,
|
SeasonResult::Maintained => old_div,
|
||||||
};
|
};
|
||||||
let new_season = season.season_number + 1;
|
let new_season = season.season_number + 1;
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
|
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
|
||||||
@@ -101,30 +110,46 @@ pub async fn record_match(
|
|||||||
)
|
)
|
||||||
.bind(new_div)
|
.bind(new_div)
|
||||||
.bind(new_season)
|
.bind(new_season)
|
||||||
.bind(&now)
|
.bind(now)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Grant rewards
|
if coins > 0 {
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
let credited =
|
||||||
|
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(coins)
|
||||||
|
.bind(now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
if credited.rows_affected() != 1 {
|
||||||
|
return Err(AppError::NotFound("club not found".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(pack_def) = pack_id {
|
if let Some(pack_def) = pack_id {
|
||||||
pack::grant_pack(pool, club_id, pack_def).await?;
|
sqlx::query(
|
||||||
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(pack_def)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist history entry before rolling over
|
|
||||||
let result_str = match result {
|
let result_str = match result {
|
||||||
SeasonResult::Promoted => "promoted",
|
SeasonResult::Promoted => "promoted",
|
||||||
SeasonResult::Maintained => "maintained",
|
SeasonResult::Maintained => "maintained",
|
||||||
SeasonResult::Relegated => "relegated",
|
SeasonResult::Relegated => "relegated",
|
||||||
};
|
};
|
||||||
let history_id = Uuid::new_v4().to_string();
|
sqlx::query(
|
||||||
let _ = sqlx::query(
|
|
||||||
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
|
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
|
||||||
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
|
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
)
|
)
|
||||||
.bind(&history_id)
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.bind(season.season_number)
|
.bind(season.season_number)
|
||||||
.bind(old_div)
|
.bind(old_div)
|
||||||
@@ -136,23 +161,28 @@ pub async fn record_match(
|
|||||||
.bind(new_div)
|
.bind(new_div)
|
||||||
.bind(coins)
|
.bind(coins)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
.bind(&now)
|
.bind(now)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
let summary = SeasonEndSummary {
|
Ok(Some(SeasonEndSummary {
|
||||||
result,
|
result,
|
||||||
old_division: old_div,
|
old_division: old_div,
|
||||||
new_division: new_div,
|
new_division: new_div,
|
||||||
new_season_number: new_season,
|
new_season_number: new_season,
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
pack_awarded: pack_id.map(String::from),
|
pack_awarded: pack_id.map(String::from),
|
||||||
};
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
let updated = fetch(pool, profile_id).await?.ok_or_else(|| {
|
async fn fetch_tx(tx: &mut Transaction<'_, Sqlite>, profile_id: &str) -> AppResult<Option<Season>> {
|
||||||
AppError::Internal(anyhow::anyhow!("season row missing after season rollover"))
|
Ok(sqlx::query_as::<_, Season>(
|
||||||
})?;
|
"SELECT profile_id, division, season_number, season_points, matches_played, \
|
||||||
Ok((updated, Some(summary)))
|
wins, draws, losses, started_at FROM seasons WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return past seasons for a profile, newest first (max 20).
|
/// Return past seasons for a profile, newest first (max 20).
|
||||||
|
|||||||
+213
-38
@@ -78,6 +78,199 @@ async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode
|
|||||||
(status, serde_json::from_slice(&body).unwrap())
|
(status, serde_json::from_slice(&body).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit ONE match through the authoritative exactly-once route.
|
||||||
|
///
|
||||||
|
/// `POST /matches/result` was removed as an economy path: it had no transaction
|
||||||
|
/// and no idempotency key. `/matches/complete` requires a caller-supplied
|
||||||
|
/// `match_identity`, so every call here mints a fresh one — each call is a
|
||||||
|
/// distinct match, which is what these tests mean. `expire_loans` and
|
||||||
|
/// `advance_season` are opted in to keep the Core-mode behaviour the old route
|
||||||
|
/// used to trigger implicitly.
|
||||||
|
///
|
||||||
|
/// Takes the legacy payload shape and derives the canonical `result` from the
|
||||||
|
/// scoreline, so call sites read the same as the match they describe.
|
||||||
|
async fn post_match(app: &axum::Router, payload: Value) -> (StatusCode, Value) {
|
||||||
|
static NEXT_MATCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
let n = NEXT_MATCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
||||||
|
let goals_for = payload["goals_for"].as_i64().unwrap_or(0);
|
||||||
|
let goals_against = payload["goals_against"].as_i64().unwrap_or(0);
|
||||||
|
let result = match goals_for.cmp(&goals_against) {
|
||||||
|
std::cmp::Ordering::Greater => "win",
|
||||||
|
std::cmp::Ordering::Equal => "draw",
|
||||||
|
std::cmp::Ordering::Less => "loss",
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut body = payload;
|
||||||
|
body["match_identity"] = serde_json::json!(format!("test-match-{n}"));
|
||||||
|
body["result"] = serde_json::json!(result);
|
||||||
|
body["expire_loans"] = serde_json::json!(true);
|
||||||
|
body["advance_season"] = serde_json::json!(true);
|
||||||
|
json_post(app, "/matches/complete", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy match-result path is closed ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// `POST /matches/result` used to be a SECOND economy authority: a dozen writes
|
||||||
|
/// with no transaction and no idempotency key, so it re-credited the same match
|
||||||
|
/// on every call. It must now reject and grant nothing, pointing callers at the
|
||||||
|
/// exactly-once route.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_match_result_route_rejects_and_grants_nothing() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "LegacyGuard").await;
|
||||||
|
|
||||||
|
let (_, before) = json_get(&app, "/club").await;
|
||||||
|
let coins_before = before["coins"].as_i64().expect("coins");
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/result",
|
||||||
|
serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
|
||||||
|
assert!(
|
||||||
|
body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("/matches/complete"),
|
||||||
|
"the rejection must name the route that replaced it: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, after) = json_get(&app, "/club").await;
|
||||||
|
assert_eq!(
|
||||||
|
after["coins"].as_i64().expect("coins"),
|
||||||
|
coins_before,
|
||||||
|
"a rejected legacy submit must not move the balance"
|
||||||
|
);
|
||||||
|
let (_, history) = json_get(&app, "/matches").await;
|
||||||
|
assert!(
|
||||||
|
history["matches"].as_array().is_none_or(|m| m.is_empty()),
|
||||||
|
"a rejected legacy submit must not write match history: {history}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The behaviour the legacy route used to trigger implicitly is still reachable,
|
||||||
|
/// but only when the caller opts in — a game with its own loan and season models
|
||||||
|
/// (FIFA 17) must not have Core's advance behind its back, because Core's
|
||||||
|
/// season end GRANTS coins.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn core_progression_is_opt_in_on_the_authoritative_route() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "OptIn").await;
|
||||||
|
|
||||||
|
// Default: no opt-in fields at all.
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "opt-in-off", "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["applied"], true);
|
||||||
|
assert_eq!(
|
||||||
|
body["season_end"],
|
||||||
|
serde_json::Value::Null,
|
||||||
|
"Core's season must not advance unless asked"
|
||||||
|
);
|
||||||
|
let (_, division) = json_get(&app, "/division").await;
|
||||||
|
assert_eq!(
|
||||||
|
division["matches_played"], 0,
|
||||||
|
"no opt-in means Core's season model saw no match"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Opting in advances it.
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "opt-in-on", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"advance_season": true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
let (_, division) = json_get(&app, "/division").await;
|
||||||
|
assert_eq!(division["matches_played"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A replay must not re-notify. The pooled path this replaced emitted a fresh
|
||||||
|
/// notification every time it was called, because it had no replay concept.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replayed_completion_does_not_duplicate_notifications() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "ReplayNotify").await;
|
||||||
|
|
||||||
|
let submit = || {
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "same-match", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, first) = submit().await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{first}");
|
||||||
|
assert_eq!(first["applied"], true);
|
||||||
|
let (_, notifications) = json_get(&app, "/notifications").await;
|
||||||
|
let after_first = notifications["notifications"].as_array().unwrap().len();
|
||||||
|
|
||||||
|
let (status, second) = submit().await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{second}");
|
||||||
|
assert_eq!(second["applied"], false, "replay must not re-apply");
|
||||||
|
let (_, notifications) = json_get(&app, "/notifications").await;
|
||||||
|
assert_eq!(
|
||||||
|
notifications["notifications"].as_array().unwrap().len(),
|
||||||
|
after_first,
|
||||||
|
"a replay must not emit a second set of notifications"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A profile that completed a match through the authoritative route must still
|
||||||
|
/// be resettable: `match_completions` holds un-cascaded foreign keys to both
|
||||||
|
/// `matches` and `profiles`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reset_clears_a_profile_that_completed_matches() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "ResetMe").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "reset-match", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/auth/reset",
|
||||||
|
serde_json::json!({ "confirm": "reset" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
let (status, _) = json_get(&app, "/profile").await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Existing tests ───────────────────────────────────────────────────────────
|
// ── Existing tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -125,7 +318,7 @@ async fn test_match_result_awards_coins() {
|
|||||||
"goals_against": 1,
|
"goals_against": 1,
|
||||||
"mode": "squad_battles"
|
"mode": "squad_battles"
|
||||||
});
|
});
|
||||||
let (status, json) = json_post(&app, "/matches/result", payload).await;
|
let (status, json) = post_match(&app, payload).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(json["match_record"]["outcome"], "win");
|
assert_eq!(json["match_record"]["outcome"], "win");
|
||||||
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
||||||
@@ -154,7 +347,7 @@ async fn test_match_with_goal_positions_tracks_stats() {
|
|||||||
"mode": "squad_battles",
|
"mode": "squad_battles",
|
||||||
"goal_positions": ["ST", "ST", "CAM"]
|
"goal_positions": ["ST", "ST", "CAM"]
|
||||||
});
|
});
|
||||||
let (status, _) = json_post(&app, "/matches/result", payload).await;
|
let (status, _) = post_match(&app, payload).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
let (status, stats) = json_get(&app, "/statistics").await;
|
let (status, stats) = json_get(&app, "/statistics").await;
|
||||||
@@ -424,9 +617,8 @@ async fn test_win_streak_tracking() {
|
|||||||
auth(&app, "StreakPlayer").await;
|
auth(&app, "StreakPlayer").await;
|
||||||
|
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let (s, _) = json_post(
|
let (s, _) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy",
|
"squad_id": "dummy",
|
||||||
"opponent_name": "Bot",
|
"opponent_name": "Bot",
|
||||||
@@ -980,9 +1172,8 @@ async fn test_division_updates_after_wins() {
|
|||||||
|
|
||||||
// Play 3 wins
|
// Play 3 wins
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1004,9 +1195,8 @@ async fn test_season_ends_after_10_matches_and_promotes() {
|
|||||||
|
|
||||||
// Win all 10 matches of the season
|
// Win all 10 matches of the season
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
let (s, result) = json_post(
|
let (s, result) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1111,9 +1301,8 @@ async fn test_notifications_include_completed_objectives() {
|
|||||||
auth(&app, "ObjNotifPlayer").await;
|
auth(&app, "ObjNotifPlayer").await;
|
||||||
|
|
||||||
// Play enough matches to complete the "daily_play_1_match" objective
|
// Play enough matches to complete the "daily_play_1_match" objective
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1136,9 +1325,8 @@ async fn test_match_result_returns_season_info() {
|
|||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
auth(&app, "SeasonMatchPlayer").await;
|
auth(&app, "SeasonMatchPlayer").await;
|
||||||
|
|
||||||
let (s, result) = json_post(
|
let (s, result) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 2, "goals_against": 1, "mode": "squad_battles"
|
"goals_for": 2, "goals_against": 1, "mode": "squad_battles"
|
||||||
@@ -1579,9 +1767,8 @@ async fn test_rivals_weekly_reward_claim() {
|
|||||||
auth(&app, "RivalsClaimPlayer").await;
|
auth(&app, "RivalsClaimPlayer").await;
|
||||||
|
|
||||||
// Play a match to create the season row
|
// Play a match to create the season row
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1601,9 +1788,8 @@ async fn test_rivals_reward_increments_week_counter() {
|
|||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
auth(&app, "RivalsWeekCounter").await;
|
auth(&app, "RivalsWeekCounter").await;
|
||||||
|
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1730,9 +1916,8 @@ async fn test_match_result_includes_level_ups_on_first_win() {
|
|||||||
// With enough wins we cross the 500 XP threshold (level 2).
|
// With enough wins we cross the 500 XP threshold (level 2).
|
||||||
let mut level_ups_seen = false;
|
let mut level_ups_seen = false;
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
let (status, json) = json_post(
|
let (status, json) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1791,9 +1976,8 @@ async fn test_level_up_creates_persistent_notification() {
|
|||||||
|
|
||||||
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
|
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1819,9 +2003,8 @@ async fn test_mark_all_notifications_read() {
|
|||||||
|
|
||||||
// Generate a notification via level-up
|
// Generate a notification via level-up
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1843,9 +2026,8 @@ async fn test_mark_single_notification_read() {
|
|||||||
|
|
||||||
// Generate level-up notifications
|
// Generate level-up notifications
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1901,9 +2083,8 @@ async fn test_first_match_achievement_unlocks() {
|
|||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
auth(&app, "FirstMatchAchPlayer").await;
|
auth(&app, "FirstMatchAchPlayer").await;
|
||||||
|
|
||||||
let (_, result) = json_post(
|
let (_, result) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1924,9 +2105,8 @@ async fn test_first_win_achievement_unlocks_on_win() {
|
|||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
auth(&app, "FirstWinAchPlayer").await;
|
auth(&app, "FirstWinAchPlayer").await;
|
||||||
|
|
||||||
let (_, result) = json_post(
|
let (_, result) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1948,9 +2128,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
|
|||||||
auth(&app, "NoDupAchPlayer").await;
|
auth(&app, "NoDupAchPlayer").await;
|
||||||
|
|
||||||
// First match — first_match unlocks
|
// First match — first_match unlocks
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1959,9 +2138,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Second match — first_match must NOT appear again
|
// Second match — first_match must NOT appear again
|
||||||
let (_, result) = json_post(
|
let (_, result) = post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -1983,9 +2161,8 @@ async fn test_achievement_grants_coins() {
|
|||||||
let coins_before = club_before["coins"].as_i64().unwrap_or(0);
|
let coins_before = club_before["coins"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
// first_match achievement grants 500 coins on top of match reward
|
// first_match achievement grants 500 coins on top of match reward
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -2027,9 +2204,8 @@ async fn test_auth_reset_clears_profile() {
|
|||||||
auth(&app, "ResetPlayer").await;
|
auth(&app, "ResetPlayer").await;
|
||||||
|
|
||||||
// Play a match to generate some state
|
// Play a match to generate some state
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
@@ -2102,9 +2278,8 @@ async fn test_division_history_records_after_season_end() {
|
|||||||
|
|
||||||
// Win all 10 matches to complete and promote
|
// Win all 10 matches to complete and promote
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
json_post(
|
post_match(
|
||||||
&app,
|
&app,
|
||||||
"/matches/result",
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"squad_id": "dummy", "opponent_name": "Bot",
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
|||||||
Reference in New Issue
Block a user