Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae0a2bdaa | |||
| f0550e2ae1 | |||
| 2fb835200f | |||
| 5f9f556af8 | |||
| 9036f5f411 | |||
| b0306a9b1d | |||
| a034e74c16 |
@@ -70,7 +70,7 @@ DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/op
|
||||
| `GET` | `/squad` | Get active squad |
|
||||
| `POST` | `/squad` | Save squad |
|
||||
| `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 |
|
||||
| `POST` | `/sbc/submit` | Submit SBC solution |
|
||||
| `GET` | `/market` | Browse NPC transfer market |
|
||||
@@ -95,10 +95,11 @@ curl http://localhost:8080/club
|
||||
# Open your starter pack
|
||||
curl -X POST http://localhost:8080/packs/open/<pack_id>
|
||||
|
||||
# Submit a match win
|
||||
curl -X POST http://localhost:8080/matches/result \
|
||||
# Submit a match win. `match_identity` keys exactly-once economy: resubmitting the
|
||||
# same identity echoes the first result and grants nothing twice.
|
||||
curl -X POST http://localhost:8080/matches/complete \
|
||||
-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
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Durable economic idempotency for match completion.
|
||||
--
|
||||
-- One economic effect per (profile_id, match_identity), independent of any HTTP
|
||||
-- receipt idempotency the game host/adapter layers on top. A sequential replay,
|
||||
-- a restart replay, a concurrent duplicate, or a conflicting re-report of the
|
||||
-- same match all collide on this UNIQUE and are refused BEFORE any coins, XP,
|
||||
-- statistics, objectives, or achievements are applied — the first completion is
|
||||
-- the one canonical result, the rest are idempotent no-ops.
|
||||
--
|
||||
-- `match_identity` is opaque to Core: the game adapter/host derives a stable
|
||||
-- per-match token (e.g. the FIFA17 match-create id). Core never parses it.
|
||||
CREATE TABLE match_completions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
match_identity TEXT NOT NULL,
|
||||
result TEXT NOT NULL, -- canonical: win | draw | loss | dnf | no_contest
|
||||
coins_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
xp_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
match_id TEXT NOT NULL REFERENCES matches(id),
|
||||
completed_at TEXT NOT NULL,
|
||||
UNIQUE(profile_id, match_identity)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_match_completions_profile ON match_completions(profile_id);
|
||||
|
||||
-- W/D/L already live on `statistics`; add the DNF (abandon/quit) bucket so the
|
||||
-- four match outcomes are mutually-exclusive counters. A did-not-finish is
|
||||
-- economically a loss but is tallied here, not in `matches_lost`.
|
||||
ALTER TABLE statistics ADD COLUMN matches_dnf INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Squad manager assignment: an owned item assigned as a squad's manager.
|
||||
--
|
||||
-- Generic, game-neutral canonical state. Core does not know what a "manager"
|
||||
-- means to any game; it only records that one owned item (`owned_card_id`) is
|
||||
-- assigned to a squad in the manager role. The FIFA 17 adapter owns the wire
|
||||
-- meaning (itemType "manager", contract, chemistry) exactly as it owns player
|
||||
-- item shaping — Core just persists the ownership-backed assignment durably and
|
||||
-- atomically, so a manager survives squad save / reload / server restart.
|
||||
--
|
||||
-- One manager per squad: `squad_id` is the primary key, so a re-assignment
|
||||
-- REPLACEs rather than accumulating (no duplicate-manager rows).
|
||||
--
|
||||
-- `owned_card_id` references `owned_cards(id)` with ON DELETE CASCADE: quick
|
||||
-- selling / discarding the manager card (a DELETE on owned_cards) removes the
|
||||
-- assignment automatically, so a sold manager is never resurrected on the next
|
||||
-- squad read. Reads additionally re-check the manager still belongs to the club
|
||||
-- (see `club::get_squad_manager`), defending against a stale row left by a
|
||||
-- market transfer (which UPDATEs owner rather than deleting).
|
||||
CREATE TABLE IF NOT EXISTS squad_managers (
|
||||
squad_id TEXT PRIMARY KEY NOT NULL REFERENCES squads(id) ON DELETE CASCADE,
|
||||
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_squad_managers_owned ON squad_managers(owned_card_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Active home/away kits for a club. Ownership remains the generic owned_cards
|
||||
-- inventory; this table only records which owned instances occupy the two kit
|
||||
-- roles. FIFA-specific resource ids and wire shapes stay in the FIFA17 adapter.
|
||||
CREATE TABLE IF NOT EXISTS club_kit_assignments (
|
||||
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
slot TEXT NOT NULL CHECK (slot IN ('home', 'away')),
|
||||
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (club_id, slot)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_kit_assignments_owned
|
||||
ON club_kit_assignments(owned_card_id);
|
||||
|
||||
-- Market transfers change owned_cards.club_id by UPDATE rather than DELETE.
|
||||
-- Remove any old-club active designation before ownership moves so a stale row
|
||||
-- cannot hide or block the item for its new owner.
|
||||
CREATE TRIGGER IF NOT EXISTS clear_club_kit_assignment_before_transfer
|
||||
BEFORE UPDATE OF club_id ON owned_cards
|
||||
WHEN OLD.club_id <> NEW.club_id
|
||||
BEGIN
|
||||
DELETE FROM club_kit_assignments WHERE owned_card_id = OLD.id;
|
||||
END;
|
||||
@@ -169,6 +169,11 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/club/checkin", get(routes::club::get_checkin_status))
|
||||
.route("/club/checkin", post(routes::club::post_checkin))
|
||||
.route("/club/milestones", get(routes::club::get_milestones))
|
||||
// ClubB: squad manager assignment (append-only; own lines).
|
||||
.route("/club/manager", get(routes::club::get_squad_manager))
|
||||
.route("/club/manager", put(routes::club::put_squad_manager))
|
||||
.route("/club/kits", get(routes::club::get_active_kits))
|
||||
.route("/club/kits", put(routes::club::put_active_kits))
|
||||
.route("/cards", get(routes::cards::get_cards))
|
||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||
.route("/collection", get(routes::cards::get_collection))
|
||||
@@ -250,6 +255,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/matches", get(routes::matches::get_matches))
|
||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||
.route("/matches/result", post(routes::matches::post_match_result))
|
||||
.route(
|
||||
"/matches/complete",
|
||||
post(routes::matches::post_match_complete),
|
||||
)
|
||||
.route("/sbc", get(routes::sbc::get_sbcs))
|
||||
.route("/sbc/status", get(routes::sbc::get_sbc_status))
|
||||
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
||||
|
||||
+92
-16
@@ -11,14 +11,43 @@ pub enum MatchOutcome {
|
||||
Loss,
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
/// Canonical, game-independent economic result of a completed match. The game
|
||||
/// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
|
||||
/// never sees a game-specific reason string.
|
||||
///
|
||||
/// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
|
||||
/// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
|
||||
/// its own statistics bucket and never in `matches_lost`.
|
||||
/// * `NoContest` — a voided match. Zero economic effect: no coins, XP, or
|
||||
/// W/D/L/DNF change; recorded only for history + idempotency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MatchResultKind {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
Dnf,
|
||||
NoContest,
|
||||
}
|
||||
|
||||
impl MatchResultKind {
|
||||
/// The canonical lowercase token persisted in `matches.outcome` and
|
||||
/// `match_completions.result`.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MatchResultKind::Win => "win",
|
||||
MatchResultKind::Draw => "draw",
|
||||
MatchResultKind::Loss => "loss",
|
||||
MatchResultKind::Dnf => "dnf",
|
||||
MatchResultKind::NoContest => "no_contest",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this result applies any economic effect (coins / XP / statistics /
|
||||
/// objectives / achievements). `NoContest` is the only non-economic result.
|
||||
pub fn is_economic(self) -> bool {
|
||||
!matches!(self, MatchResultKind::NoContest)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@@ -72,18 +101,65 @@ impl Match {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// game adapter derived from its wire; `goals_for`/`goals_against` are recorded
|
||||
/// for history and statistics (0-0 is normal for a DNF/no-contest).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CompleteMatchRequest {
|
||||
pub match_identity: String,
|
||||
pub result: MatchResultKind,
|
||||
pub squad_id: String,
|
||||
pub opponent_name: String,
|
||||
pub goals_for: i64,
|
||||
pub goals_against: i64,
|
||||
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`].
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchRewardResult {
|
||||
pub match_record: Match,
|
||||
pub struct MatchCompletionResult {
|
||||
/// `true` when THIS call applied the economic effect; `false` on an
|
||||
/// idempotent replay of an already-completed match (the persisted canonical
|
||||
/// result is echoed unchanged).
|
||||
pub applied: bool,
|
||||
pub match_identity: String,
|
||||
pub result: MatchResultKind,
|
||||
pub coins_awarded: i64,
|
||||
pub xp_awarded: i64,
|
||||
/// Club balance after completion — echoed so the host can render the wire
|
||||
/// reward body without a second round-trip.
|
||||
pub coins_balance: i64,
|
||||
/// Objectives completed by this match (empty on a replay).
|
||||
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.
|
||||
/// Level-ups gained from this match's XP (empty on a replay).
|
||||
pub level_ups: Vec<LevelUpEvent>,
|
||||
/// Achievements unlocked as a result of this match.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,19 +1,19 @@
|
||||
pub mod achievement;
|
||||
pub mod card;
|
||||
pub mod chemistry_style;
|
||||
pub mod notification;
|
||||
pub mod club;
|
||||
pub mod draft;
|
||||
pub mod event;
|
||||
pub mod fut_champs;
|
||||
pub mod game_ext;
|
||||
pub mod event;
|
||||
pub mod season;
|
||||
pub mod market;
|
||||
pub mod match_result;
|
||||
pub mod notification;
|
||||
pub mod objective;
|
||||
pub mod pack;
|
||||
pub mod profile;
|
||||
pub mod reward;
|
||||
pub mod sbc;
|
||||
pub mod season;
|
||||
pub mod squad;
|
||||
pub mod statistics;
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct Statistics {
|
||||
pub matches_won: i64,
|
||||
pub matches_drawn: i64,
|
||||
pub matches_lost: i64,
|
||||
pub matches_dnf: i64,
|
||||
pub goals_scored: i64,
|
||||
pub goals_conceded: i64,
|
||||
pub packs_opened: i64,
|
||||
@@ -25,6 +26,7 @@ impl Statistics {
|
||||
matches_won: 0,
|
||||
matches_drawn: 0,
|
||||
matches_lost: 0,
|
||||
matches_dnf: 0,
|
||||
goals_scored: 0,
|
||||
goals_conceded: 0,
|
||||
packs_opened: 0,
|
||||
|
||||
@@ -8,12 +8,19 @@ use crate::{
|
||||
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_achievements(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
|
||||
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id)
|
||||
.await;
|
||||
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
|
||||
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
|
||||
let earned = achievements
|
||||
.iter()
|
||||
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
|
||||
.count();
|
||||
Ok(Json(json!({
|
||||
"achievements": achievements,
|
||||
"earned": earned,
|
||||
|
||||
+9
-1
@@ -34,7 +34,10 @@ pub async fn post_auth_local(
|
||||
|
||||
/// GET /auth/status — lightweight check: does a profile exist?
|
||||
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
||||
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_auth_status(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||
.bind(game.as_str())
|
||||
.fetch_one(&state.pool)
|
||||
@@ -99,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 [
|
||||
"match_completions",
|
||||
"fut_champs_sessions",
|
||||
"sbc_submissions",
|
||||
"objective_progress",
|
||||
|
||||
+19
-14
@@ -11,8 +11,7 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::card::OwnedCard,
|
||||
services::{
|
||||
club as club_svc,
|
||||
economy as economy_svc,
|
||||
club as club_svc, economy as economy_svc,
|
||||
inventory::{self, OwnedItemQuery, OwnedItemView},
|
||||
profile as profile_svc,
|
||||
},
|
||||
@@ -20,11 +19,17 @@ use crate::{
|
||||
|
||||
/// Quick-sell value for a card based on overall rating.
|
||||
fn quick_sell_coins(overall: u8) -> i64 {
|
||||
if overall >= 85 { 1500 }
|
||||
else if overall >= 80 { 900 }
|
||||
else if overall >= 75 { 600 }
|
||||
else if overall >= 65 { 300 }
|
||||
else { 150 }
|
||||
if overall >= 85 {
|
||||
1500
|
||||
} else if overall >= 80 {
|
||||
900
|
||||
} else if overall >= 75 {
|
||||
600
|
||||
} else if overall >= 65 {
|
||||
300
|
||||
} else {
|
||||
150
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -96,7 +101,9 @@ pub async fn get_cards(
|
||||
cards.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
|
||||
Ok(Json(
|
||||
json!({ "cards": cards, "total": total, "returned": cards.len() }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_collection(
|
||||
@@ -119,8 +126,7 @@ pub async fn get_collection(
|
||||
.filter_map(|o| {
|
||||
state.card_db.get(&o.card_id).map(|def| {
|
||||
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||
let effective_position =
|
||||
o.position_override.as_deref().unwrap_or(&def.position);
|
||||
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
|
||||
let body = json!({
|
||||
"owned_card_id": o.id,
|
||||
"is_loan": o.is_loan,
|
||||
@@ -178,10 +184,9 @@ pub async fn delete_owned_card(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
|
||||
|
||||
let card = state
|
||||
.card_db
|
||||
.get(&owned.card_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
|
||||
let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
|
||||
AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
|
||||
})?;
|
||||
|
||||
let coins = quick_sell_coins(card.overall);
|
||||
|
||||
|
||||
+90
-14
@@ -3,7 +3,9 @@ use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::club::Club,
|
||||
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
|
||||
services::{
|
||||
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
|
||||
},
|
||||
};
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
@@ -38,7 +40,10 @@ pub async fn put_club(
|
||||
Ok(Json(json!({ "club": updated })))
|
||||
}
|
||||
|
||||
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_checkin_status(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||
Ok(Json(json!({
|
||||
@@ -67,9 +72,8 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
let seasons_completed: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
|
||||
)
|
||||
let seasons_completed: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
|
||||
.bind(&profile.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
@@ -83,25 +87,21 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
|
||||
.await
|
||||
.unwrap_or(10);
|
||||
|
||||
let cards_owned: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
||||
)
|
||||
let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||
.bind(&club.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let sbcs_completed: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
|
||||
)
|
||||
let sbcs_completed: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
|
||||
.bind(&club.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_checkins: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
|
||||
)
|
||||
let total_checkins: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
|
||||
.bind(&profile.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
@@ -124,3 +124,79 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
|
||||
"club_level": club.level,
|
||||
})))
|
||||
}
|
||||
|
||||
/// The owned card assigned as the active squad's manager, or `null`. Generic:
|
||||
/// Core returns the ownership-backed assignment; the FIFA 17 adapter shapes the
|
||||
/// manager wire item from it (itemType/contract/chemistry are adapter concerns).
|
||||
pub async fn get_squad_manager(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "manager": manager })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetManagerRequest {
|
||||
/// The owned card to assign as manager, or `null`/absent to clear it.
|
||||
pub owned_card_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Assign (or, with a null/absent `owned_card_id`, clear) the active squad's
|
||||
/// manager. Fail-closed: the card must be owned by this club and the club must
|
||||
/// have a squad. Returns the resulting assignment.
|
||||
pub async fn put_squad_manager(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SetManagerRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
match req.owned_card_id {
|
||||
Some(owned_card_id) => {
|
||||
club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await?
|
||||
}
|
||||
None => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
|
||||
}
|
||||
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "manager": manager })))
|
||||
}
|
||||
|
||||
/// Return the club's ownership-backed active home/away kit assignments.
|
||||
pub async fn get_active_kits(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "home": kits.home, "away": kits.away })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetActiveKitsRequest {
|
||||
pub home_owned_card_id: Option<String>,
|
||||
pub away_owned_card_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Atomically replace both active kit assignments. Core enforces ownership and
|
||||
/// distinct instances; game adapters enforce their own definition taxonomy.
|
||||
pub async fn put_active_kits(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SetActiveKitsRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
club_svc::set_active_club_kits(
|
||||
&state.pool,
|
||||
&club.id,
|
||||
req.home_owned_card_id.as_deref(),
|
||||
req.away_owned_card_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "home": kits.home, "away": kits.away })))
|
||||
}
|
||||
|
||||
+33
-9
@@ -37,13 +37,19 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_division_history(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||
}
|
||||
|
||||
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_division_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
@@ -53,11 +59,26 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
|
||||
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
|
||||
|
||||
const NPC_NAMES: &[&str] = &[
|
||||
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
|
||||
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
|
||||
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
|
||||
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
|
||||
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
|
||||
"Riverside FC",
|
||||
"City Athletic",
|
||||
"County United",
|
||||
"Valley Rangers",
|
||||
"Harbor Town FC",
|
||||
"Mountside City",
|
||||
"Lakewood Athletic",
|
||||
"Eastbrook United",
|
||||
"Westfield Rovers",
|
||||
"Northgate FC",
|
||||
"Southport Athletic",
|
||||
"Ironbridge City",
|
||||
"Milldale United",
|
||||
"Hillcrest Rangers",
|
||||
"Bayside FC",
|
||||
"Thornfield Athletic",
|
||||
"Greenhill United",
|
||||
"Coldwater City",
|
||||
"Redbury Rangers",
|
||||
"Ashdown FC",
|
||||
];
|
||||
|
||||
// Pick 9 NPC names without repetition using the seeded RNG
|
||||
@@ -75,8 +96,11 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
|
||||
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
||||
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
|
||||
let expected_win_rate = 0.2 + quality * 0.6; // 0.2–0.8
|
||||
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||
let wins =
|
||||
(npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||
let losses = (npc_matches as f64
|
||||
* (1.0 - expected_win_rate)
|
||||
* (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||
let draws = (npc_matches - wins - losses).max(0);
|
||||
let pts = wins * 3 + draws;
|
||||
json!({
|
||||
|
||||
+4
-2
@@ -45,7 +45,8 @@ pub async fn post_draft_start(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
||||
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
||||
let session =
|
||||
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
@@ -56,7 +57,8 @@ pub async fn get_draft_session(
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
||||
let session =
|
||||
draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
|
||||
+17
-10
@@ -9,7 +9,9 @@ use serde_json::{json, Value};
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc},
|
||||
services::{
|
||||
club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc,
|
||||
},
|
||||
};
|
||||
|
||||
/// GET /fut-champs — current active session, or null if none.
|
||||
@@ -24,7 +26,10 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
|
||||
}
|
||||
|
||||
/// POST /fut-champs/start — open a new FUT Champions week.
|
||||
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn post_start_fut_champs(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -96,7 +101,10 @@ pub async fn post_claim_champs_rewards(
|
||||
}
|
||||
|
||||
/// GET /fut-champs/history — past sessions, newest first.
|
||||
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_champs_history(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -107,19 +115,18 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
|
||||
}
|
||||
|
||||
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
||||
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn post_claim_rivals_reward(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// Ensure a season row exists
|
||||
season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = champs_svc::claim_rivals_reward(
|
||||
&state.pool,
|
||||
&profile.id,
|
||||
&club.id,
|
||||
&state.pack_defs,
|
||||
)
|
||||
let result =
|
||||
champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
|
||||
.await?;
|
||||
|
||||
Ok(Json(result))
|
||||
|
||||
+13
-5
@@ -13,14 +13,16 @@ use crate::{
|
||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_trade_history(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarketQuery {
|
||||
pub min_overall: Option<u8>,
|
||||
@@ -86,11 +88,17 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
|
||||
}
|
||||
|
||||
/// Return all active market listings posted by the current player's club.
|
||||
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_my_listings(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
||||
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
|
||||
let listings =
|
||||
market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
||||
Ok(Json(
|
||||
json!({ "listings": listings, "total": listings.len() }),
|
||||
))
|
||||
}
|
||||
|
||||
/// Cancel a player-posted listing and return the card to the collection.
|
||||
|
||||
+116
-8
@@ -8,9 +8,9 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||
services::{club as club_svc, match_service, profile as profile_svc},
|
||||
error::{AppError, AppResult},
|
||||
models::match_result::{CompleteMatchRequest, Match, MatchCompletionResult},
|
||||
services::{club as club_svc, match_service, notification, profile as profile_svc},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -63,17 +63,125 @@ pub async fn get_opponent(
|
||||
Ok(Json(opponent))
|
||||
}
|
||||
|
||||
pub async fn post_match_result(
|
||||
/// `POST /matches/result` — REMOVED as an economy path, and deliberately kept as
|
||||
/// an explicit rejection rather than a 404.
|
||||
///
|
||||
/// It used to grant coins, XP, level-ups, statistics, objectives, loan expiry,
|
||||
/// season progression and achievements across a dozen separate writes with NO
|
||||
/// transaction and NO idempotency key, which made it a second economy authority
|
||||
/// 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
|
||||
/// this request shape does not carry and cannot derive (a body fingerprint would
|
||||
/// collapse two legitimate matches with the same scoreline into one).
|
||||
///
|
||||
/// Callers submit to `/matches/complete` with a `match_identity`; the loan and
|
||||
/// season behaviour this route used to trigger is available there via
|
||||
/// `expire_loans` / `advance_season`.
|
||||
pub async fn post_match_result() -> AppResult<Json<Value>> {
|
||||
Err(AppError::BadRequest(
|
||||
"POST /matches/result is no longer an economy path: it had no transaction \
|
||||
and no idempotency key. Submit to POST /matches/complete with a \
|
||||
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
|
||||
/// economy entry point (the game host routes a finished match here). Idempotent
|
||||
/// on `(profile, match_identity)`: a replay returns the persisted canonical
|
||||
/// result with `applied = false` and grants nothing twice.
|
||||
pub async fn post_match_complete(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SubmitMatchRequest>,
|
||||
) -> AppResult<Json<MatchRewardResult>> {
|
||||
Json(req): Json<CompleteMatchRequest>,
|
||||
) -> AppResult<Json<MatchCompletionResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result =
|
||||
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
|
||||
let result = match_service::complete_match(
|
||||
&state.pool,
|
||||
&profile.id,
|
||||
&club.id,
|
||||
&req,
|
||||
&state.obj_defs,
|
||||
&state.achievement_defs,
|
||||
)
|
||||
.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))
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,8 +5,8 @@ pub mod club;
|
||||
pub mod division;
|
||||
pub mod draft;
|
||||
pub mod economy;
|
||||
pub mod fut_champs;
|
||||
pub mod events;
|
||||
pub mod fut_champs;
|
||||
pub mod health;
|
||||
pub mod market;
|
||||
pub mod matches;
|
||||
|
||||
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::{AppError, AppResult},
|
||||
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
|
||||
services::{
|
||||
club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc,
|
||||
},
|
||||
};
|
||||
|
||||
/// GET /notifications
|
||||
@@ -17,7 +19,10 @@ use crate::{
|
||||
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||
/// have `id: null` and are always considered unread.
|
||||
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_notifications(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -93,14 +98,16 @@ pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> A
|
||||
// Use "type" key for compatibility with dashboard and existing tests.
|
||||
let all: Vec<Value> = persistent
|
||||
.iter()
|
||||
.map(|n| json!({
|
||||
.map(|n| {
|
||||
json!({
|
||||
"id": n.id,
|
||||
"type": n.kind,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"is_read": n.is_read,
|
||||
"created_at": n.created_at,
|
||||
}))
|
||||
})
|
||||
})
|
||||
.chain(dynamic.iter().cloned())
|
||||
.collect();
|
||||
|
||||
@@ -125,9 +132,7 @@ pub async fn mark_notification_read(
|
||||
}
|
||||
|
||||
/// POST /notifications/read-all
|
||||
pub async fn mark_all_notifications_read(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let count = notif_svc::mark_all_read(&state.pool).await?;
|
||||
Ok(Json(json!({ "marked_read": count })))
|
||||
}
|
||||
|
||||
+10
-3
@@ -79,7 +79,10 @@ pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult
|
||||
}
|
||||
|
||||
/// Return recently opened packs with the card IDs they contained.
|
||||
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
pub async fn get_pack_history(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -143,8 +146,12 @@ pub async fn post_open_pack(
|
||||
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
||||
.await?;
|
||||
let _ = crate::services::achievement::check_and_unlock(
|
||||
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
||||
).await;
|
||||
&state.pool,
|
||||
&state.achievement_defs,
|
||||
&profile.id,
|
||||
&club.id,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -72,13 +72,8 @@ pub async fn post_change_position(
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated = upgrade_svc::change_position(
|
||||
&state.pool,
|
||||
&club.id,
|
||||
&owned_card_id,
|
||||
&req.position,
|
||||
)
|
||||
.await?;
|
||||
let updated =
|
||||
upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
|
||||
|
||||
let card_def = state.card_db.get(&updated.card_id);
|
||||
|
||||
|
||||
+210
-35
@@ -1,10 +1,12 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
error::{AppError, AppResult},
|
||||
models::achievement::{AchievementDefinition, PlayerAchievement},
|
||||
services::{club as club_svc, notification},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -29,58 +31,62 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
|
||||
}
|
||||
|
||||
/// Query the current value for the given trigger metric.
|
||||
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> {
|
||||
let v: i64 = match trigger {
|
||||
"matches_played" => sqlx::query_scalar(
|
||||
"SELECT matches_played FROM statistics WHERE profile_id = ?",
|
||||
)
|
||||
async fn metric_value(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
trigger: &str,
|
||||
) -> AppResult<i64> {
|
||||
let v: i64 =
|
||||
match trigger {
|
||||
"matches_played" => {
|
||||
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.unwrap_or(0),
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
"matches_won" => sqlx::query_scalar(
|
||||
"SELECT matches_won FROM statistics WHERE profile_id = ?",
|
||||
)
|
||||
"matches_won" => {
|
||||
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.unwrap_or(0),
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
"goals_scored" => sqlx::query_scalar(
|
||||
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
|
||||
)
|
||||
"goals_scored" => {
|
||||
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.unwrap_or(0),
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
"packs_opened" => sqlx::query_scalar(
|
||||
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
|
||||
)
|
||||
"packs_opened" => {
|
||||
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.unwrap_or(0),
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
"sbcs_completed" => sqlx::query_scalar(
|
||||
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
|
||||
)
|
||||
"sbcs_completed" => {
|
||||
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.unwrap_or(0),
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
"cards_owned" => sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
||||
)
|
||||
"cards_owned" => {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?,
|
||||
.await?
|
||||
}
|
||||
|
||||
"level" => sqlx::query_scalar(
|
||||
"SELECT level FROM profiles WHERE id = ?",
|
||||
)
|
||||
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
@@ -165,11 +171,180 @@ pub async fn check_and_unlock(
|
||||
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
|
||||
}
|
||||
|
||||
let body = format!(
|
||||
"{} Reward: {} coins.",
|
||||
def.description, def.reward_coins
|
||||
);
|
||||
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
|
||||
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
|
||||
let _ = notification::create(
|
||||
pool,
|
||||
"achievement",
|
||||
&format!("Achievement: {}", def.title),
|
||||
&body,
|
||||
)
|
||||
.await;
|
||||
|
||||
newly_unlocked.push(def.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(newly_unlocked)
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`metric_value`] — identical reads, run inside the
|
||||
/// caller's transaction so achievement checks see the same uncommitted state the
|
||||
/// rest of the match-completion transaction just wrote.
|
||||
async fn metric_value_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
trigger: &str,
|
||||
) -> AppResult<i64> {
|
||||
let v: i64 =
|
||||
match trigger {
|
||||
"matches_played" => {
|
||||
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"matches_won" => {
|
||||
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"goals_scored" => {
|
||||
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"packs_opened" => {
|
||||
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"sbcs_completed" => {
|
||||
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
}
|
||||
"cards_owned" => {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(1),
|
||||
"objectives_completed" => sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?,
|
||||
"drafts_completed" => sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`check_and_unlock`] for the atomic match-completion path.
|
||||
/// Unlocks are inserted, coins credited, and notifications written inside the
|
||||
/// caller's transaction (mirroring the inline economy writes elsewhere), so a
|
||||
/// later failure rolls back the whole match — no half-granted achievement.
|
||||
pub async fn check_and_unlock_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
defs: &[AchievementDefinition],
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
now: &str,
|
||||
) -> AppResult<Vec<AchievementDefinition>> {
|
||||
if defs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let unlocked_ids: Vec<String> =
|
||||
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let unlocked_set: HashSet<&str> = unlocked_ids.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let candidates: Vec<&AchievementDefinition> = defs
|
||||
.iter()
|
||||
.filter(|d| !unlocked_set.contains(d.id.as_str()))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut trigger_cache: HashMap<String, i64> = Default::default();
|
||||
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
|
||||
|
||||
for def in candidates {
|
||||
let value = match trigger_cache.get(&def.trigger) {
|
||||
Some(&v) => v,
|
||||
None => {
|
||||
let v = metric_value_tx(tx, profile_id, club_id, &def.trigger).await?;
|
||||
trigger_cache.insert(def.trigger.clone(), v);
|
||||
v
|
||||
}
|
||||
};
|
||||
|
||||
if value >= def.threshold {
|
||||
if def.reward_coins < 0 {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"achievement {} has a negative reward",
|
||||
def.id
|
||||
)));
|
||||
}
|
||||
let inserted = sqlx::query(
|
||||
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&def.id)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if inserted.rows_affected() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if def.reward_coins > 0 {
|
||||
let credited =
|
||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||
.bind(def.reward_coins)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if credited.rows_affected() != 1 {
|
||||
return Err(AppError::NotFound("club not found".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
|
||||
sqlx::query(
|
||||
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) VALUES (?, 'achievement', ?, ?, 0, ?)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(format!("Achievement: {}", def.title))
|
||||
.bind(body)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
newly_unlocked.push(def.clone());
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,4 +1,8 @@
|
||||
use crate::{db::Pool, error::AppResult, services::{club, pack}};
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
services::{club, pack},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
|
||||
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn claim(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
) -> AppResult<CheckinResult> {
|
||||
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
|
||||
let row: Option<(i64, String)> = sqlx::query_as(
|
||||
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
||||
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
|
||||
@@ -82,7 +82,10 @@ pub async fn claim(
|
||||
}
|
||||
}
|
||||
|
||||
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
|
||||
let last_streak = row
|
||||
.as_ref()
|
||||
.map(|(s, last_at)| compute_next_streak(*s, last_at))
|
||||
.unwrap_or(1);
|
||||
let idx = (last_streak - 1).rem_euclid(7) as usize;
|
||||
let coins = STREAK_COINS[idx];
|
||||
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
||||
|
||||
+431
-1
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::club::Club,
|
||||
models::{card::OwnedCard, club::Club},
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -126,3 +126,433 @@ pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i
|
||||
.await?;
|
||||
Ok(new_balance)
|
||||
}
|
||||
|
||||
// ─────────────────────── squad manager assignment ───────────────────────────
|
||||
//
|
||||
// Generic, ownership-backed canonical state: one owned item assigned as a
|
||||
// squad's manager (migration 0023 `squad_managers`). Core stores the assignment
|
||||
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
|
||||
// meaning of "manager" (itemType/contract/chemistry), never Core.
|
||||
|
||||
const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, \
|
||||
acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards";
|
||||
|
||||
/// The club's most-recently-updated squad id (its "active" squad), matching the
|
||||
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
|
||||
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
|
||||
Ok(sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// The owned card assigned as the manager of `club_id`'s active squad, if any.
|
||||
pub async fn get_squad_manager(pool: &Pool, club_id: &str) -> AppResult<Option<OwnedCard>> {
|
||||
let Some(squad_id) = active_squad_id(pool, club_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
get_squad_manager_for_squad(pool, &squad_id, club_id).await
|
||||
}
|
||||
|
||||
/// The owned card assigned as `squad_id`'s manager, re-validated to still belong
|
||||
/// to `club_id`. The club-ownership re-check means a stale assignment left by a
|
||||
/// market transfer (which moves ownership by UPDATE, bypassing ON DELETE
|
||||
/// CASCADE) never surfaces a manager the club no longer owns.
|
||||
pub async fn get_squad_manager_for_squad(
|
||||
pool: &Pool,
|
||||
squad_id: &str,
|
||||
club_id: &str,
|
||||
) -> AppResult<Option<OwnedCard>> {
|
||||
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_SELECT} WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
|
||||
AND club_id = ?"
|
||||
))
|
||||
.bind(squad_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Assign `owned_card_id` as the manager of `club_id`'s active squad, replacing
|
||||
/// any existing assignment. Fail-closed: both the squad and the owned card MUST
|
||||
/// belong to `club_id`, so a client can neither manage another club's squad nor
|
||||
/// assign a card it does not own. One manager per squad (the PK REPLACE), so a
|
||||
/// re-assignment never accumulates duplicate rows.
|
||||
pub async fn set_squad_manager(pool: &Pool, club_id: &str, owned_card_id: &str) -> AppResult<()> {
|
||||
let squad_id = active_squad_id(pool, club_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
|
||||
set_squad_manager_for_squad(pool, club_id, &squad_id, owned_card_id).await
|
||||
}
|
||||
|
||||
/// Squad-scoped variant of [`set_squad_manager`].
|
||||
pub async fn set_squad_manager_for_squad(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
squad_id: &str,
|
||||
owned_card_id: &str,
|
||||
) -> AppResult<()> {
|
||||
let squad_ok =
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
|
||||
.bind(squad_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if squad_ok.is_none() {
|
||||
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
|
||||
}
|
||||
let card_ok =
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||
.bind(owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if card_ok.is_none() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"owned card '{owned_card_id}' not found"
|
||||
)));
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, updated_at) \
|
||||
VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(squad_id)
|
||||
.bind(owned_card_id)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the manager assignment from `club_id`'s active squad (idempotent — a
|
||||
/// club with no squad or no manager is a successful no-op).
|
||||
pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM squad_managers WHERE squad_id IN \
|
||||
(SELECT id FROM squads WHERE club_id = ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ───────────────────────── active club kits ────────────────────────────────
|
||||
|
||||
/// The ownership-backed home and away kit assignments for one club.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ActiveClubKits {
|
||||
pub home: Option<OwnedCard>,
|
||||
pub away: Option<OwnedCard>,
|
||||
}
|
||||
|
||||
async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult<Option<OwnedCard>> {
|
||||
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_SELECT} WHERE id = ( \
|
||||
SELECT owned_card_id FROM club_kit_assignments WHERE club_id = ? AND slot = ? \
|
||||
) AND club_id = ?"
|
||||
))
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Read both active kit roles. Each assignment is revalidated against current
|
||||
/// ownership, so a stale/corrupt row never surfaces another club's item.
|
||||
pub async fn get_active_club_kits(pool: &Pool, club_id: &str) -> AppResult<ActiveClubKits> {
|
||||
Ok(ActiveClubKits {
|
||||
home: get_club_kit_slot(pool, club_id, "home").await?,
|
||||
away: get_club_kit_slot(pool, club_id, "away").await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically replace both active kit roles. Core enforces generic ownership and
|
||||
/// distinct-instance invariants; the game adapter validates that each definition
|
||||
/// is a kit before asking Core to assign it.
|
||||
pub async fn set_active_club_kits(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
home_owned_card_id: Option<&str>,
|
||||
away_owned_card_id: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
if home_owned_card_id.is_some() && home_owned_card_id == away_owned_card_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"home and away kits must be different owned items".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
for owned_card_id in [home_owned_card_id, away_owned_card_id]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let owned = sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if owned.is_none() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"owned card '{owned_card_id}' not found"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM club_kit_assignments WHERE club_id = ?")
|
||||
.bind(club_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for (slot, owned_card_id) in [("home", home_owned_card_id), ("away", away_owned_card_id)] {
|
||||
if let Some(owned_card_id) = owned_card_id {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments \
|
||||
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(owned_card_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
|
||||
const TS: &str = "2026-01-01T00:00:00Z";
|
||||
|
||||
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs.
|
||||
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let url = format!("sqlite://{}", dir.path().join("core.db").display());
|
||||
let pool = db::init_pool(&url, 5).await.expect("init pool");
|
||||
db::run_migrations(&pool).await.expect("migrations");
|
||||
|
||||
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(profile)
|
||||
.bind(profile)
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("profile");
|
||||
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
|
||||
.execute(&pool).await.expect("club");
|
||||
}
|
||||
for (id, club, definition) in [
|
||||
("mgr", "club-a", "def-mgr"),
|
||||
("mgr2", "club-a", "def-mgr"),
|
||||
("player", "club-a", "def-player"),
|
||||
("kit-home", "club-a", "def-kit-home"),
|
||||
("kit-away", "club-a", "def-kit-away"),
|
||||
("kit-away-2", "club-a", "def-kit-away-2"),
|
||||
("foreign", "club-b", "def-kit-foreign"),
|
||||
] {
|
||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||
.bind(id).bind(club).bind(definition).bind(TS)
|
||||
.execute(&pool).await.expect("owned card");
|
||||
}
|
||||
// club-a has one squad.
|
||||
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)")
|
||||
.bind(TS).bind(TS).execute(&pool).await.expect("squad");
|
||||
(dir, url, pool)
|
||||
}
|
||||
|
||||
async fn manager_rows(pool: &db::Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_managers")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn kit_rows(pool: &db::Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_kit_assignments")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_persists_across_reload_and_restart() {
|
||||
let (dir, url, pool) = fixture().await;
|
||||
|
||||
// SAVE.
|
||||
set_squad_manager(&pool, "club-a", "mgr")
|
||||
.await
|
||||
.expect("assign");
|
||||
// RELOAD (same pool).
|
||||
let got = get_squad_manager(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr"));
|
||||
|
||||
// RESTART: close the pool and reopen the same DB file.
|
||||
pool.close().await;
|
||||
let reopened = db::init_pool(&url, 5).await.expect("reopen");
|
||||
db::run_migrations(&reopened).await.expect("migrations");
|
||||
let after = get_squad_manager(&reopened, "club-a").await.unwrap();
|
||||
assert_eq!(
|
||||
after.as_ref().map(|c| c.id.as_str()),
|
||||
Some("mgr"),
|
||||
"manager assignment must survive a server restart"
|
||||
);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reassignment_replaces_and_never_duplicates() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
|
||||
set_squad_manager(&pool, "club-a", "mgr2").await.unwrap();
|
||||
assert_eq!(manager_rows(&pool).await, 1, "one manager per squad");
|
||||
let got = get_squad_manager(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(got.map(|c| c.id), Some("mgr2".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_removes_and_no_resurrection() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
|
||||
clear_squad_manager(&pool, "club-a").await.unwrap();
|
||||
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
|
||||
assert_eq!(manager_rows(&pool).await, 0);
|
||||
// Clearing again is an idempotent no-op.
|
||||
clear_squad_manager(&pool, "club-a").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_card_the_club_does_not_own() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
let err = set_squad_manager(&pool, "club-a", "foreign").await;
|
||||
assert!(err.is_err(), "cannot assign a card owned by another club");
|
||||
assert_eq!(manager_rows(&pool).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quick_sell_of_manager_cascades_the_assignment_away() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
|
||||
// A quick-sell/discard DELETEs the owned row; ON DELETE CASCADE must
|
||||
// remove the assignment so the sold manager is never resurrected.
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = 'mgr'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete owned card");
|
||||
assert_eq!(manager_rows(&pool).await, 0);
|
||||
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_manager_when_none_assigned() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn kits_persist_across_reload_and_restart() {
|
||||
let (dir, url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.expect("assign kits");
|
||||
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(
|
||||
current.home.as_ref().map(|item| item.id.as_str()),
|
||||
Some("kit-home")
|
||||
);
|
||||
assert_eq!(
|
||||
current.away.as_ref().map(|item| item.id.as_str()),
|
||||
Some("kit-away")
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
let reopened = db::init_pool(&url, 5).await.expect("reopen");
|
||||
db::run_migrations(&reopened).await.expect("migrations");
|
||||
let persisted = get_active_club_kits(&reopened, "club-a").await.unwrap();
|
||||
assert_eq!(persisted.home.map(|item| item.id), Some("kit-home".into()));
|
||||
assert_eq!(persisted.away.map(|item| item.id), Some("kit-away".into()));
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_replace_clear_and_never_duplicate() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away-2"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kit_rows(&pool).await, 2);
|
||||
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(current.away.map(|item| item.id), Some("kit-away-2".into()));
|
||||
|
||||
set_active_club_kits(&pool, "club-a", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kit_rows(&pool).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_reject_invalid_references_atomically() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(set_active_club_kits(&pool, "club-a", Some("foreign"), None)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-home"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let unchanged = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(unchanged.home.map(|item| item.id), Some("kit-home".into()));
|
||||
assert_eq!(unchanged.away.map(|item| item.id), Some("kit-away".into()));
|
||||
assert_eq!(kit_rows(&pool).await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kit_delete_and_transfer_clear_active_designations() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = 'kit-home'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("quick sell kit");
|
||||
let after_delete = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert!(after_delete.home.is_none());
|
||||
assert_eq!(
|
||||
after_delete.away.map(|item| item.id),
|
||||
Some("kit-away".into())
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("transfer kit");
|
||||
assert_eq!(kit_rows(&pool).await, 0);
|
||||
let after_transfer = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert!(after_transfer.home.is_none() && after_transfer.away.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -184,15 +184,23 @@ pub async fn pick_card(
|
||||
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
|
||||
if all_filled {
|
||||
let (coins, pack, avg) = compute_reward(card_db, &picks);
|
||||
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339()))
|
||||
(
|
||||
None,
|
||||
"completed".to_string(),
|
||||
coins,
|
||||
pack,
|
||||
avg,
|
||||
Some(chrono::Utc::now().to_rfc3339()),
|
||||
)
|
||||
} else {
|
||||
let min_overall = difficulty_min_overall(&session.difficulty);
|
||||
let next_pos = &pick_order[next_index];
|
||||
let next_candidates =
|
||||
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
||||
{
|
||||
let candidates_json = serde_json::to_string(&next_candidates)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
let candidates_json = serde_json::to_string(&next_candidates).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("serialization failed: {e}"))
|
||||
})?;
|
||||
(
|
||||
Some(candidates_json),
|
||||
"active".to_string(),
|
||||
@@ -294,8 +302,7 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe
|
||||
}
|
||||
|
||||
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
|
||||
let pick_order: Vec<String> =
|
||||
serde_json::from_str(&session.pick_order).unwrap_or_default();
|
||||
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
|
||||
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
|
||||
let candidates: Vec<String> = session
|
||||
.current_candidates
|
||||
|
||||
@@ -26,7 +26,11 @@ pub async fn get_active_session(
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> {
|
||||
pub async fn get_session(
|
||||
pool: &Pool,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
) -> AppResult<FutChampsSession> {
|
||||
sqlx::query_as::<_, FutChampsSession>(&format!(
|
||||
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
|
||||
))
|
||||
|
||||
+10
-5
@@ -149,7 +149,9 @@ pub async fn buy_listing(
|
||||
.await?
|
||||
.rows_affected();
|
||||
if claimed == 0 {
|
||||
return Err(AppError::NotFound("listing not found or already sold".into()));
|
||||
return Err(AppError::NotFound(
|
||||
"listing not found or already sold".into(),
|
||||
));
|
||||
}
|
||||
if let Err(e) = club::spend_coins(pool, club_id, listing.price).await {
|
||||
let _ = sqlx::query("UPDATE market_listings SET sold = 0 WHERE id = ?")
|
||||
@@ -308,9 +310,10 @@ pub async fn get_listings_by_seller(
|
||||
let with_cards = listings
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
card_db
|
||||
.get(&l.card_id)
|
||||
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
|
||||
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
|
||||
listing: l,
|
||||
card: card.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(with_cards)
|
||||
@@ -326,7 +329,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("listing '{listing_id}' not found or already sold"))
|
||||
})?;
|
||||
|
||||
sqlx::query("DELETE FROM market_listings WHERE id = ?")
|
||||
.bind(listing_id)
|
||||
|
||||
+886
-149
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -67,10 +68,7 @@ pub async fn increment_metric(
|
||||
) -> AppResult<Vec<String>> {
|
||||
let mut completed_ids = Vec::new();
|
||||
|
||||
for def in defs
|
||||
.iter()
|
||||
.filter(|d| d.metric.as_str() == metric)
|
||||
{
|
||||
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
)
|
||||
@@ -123,6 +121,72 @@ pub async fn increment_metric(
|
||||
Ok(completed_ids)
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`increment_metric`] for the atomic match-completion path.
|
||||
/// Same semantics, but every read/write runs inside the caller's transaction so
|
||||
/// objective progress commits (or rolls back) together with the coins, XP, and
|
||||
/// statistics of the same match. `now` is threaded so one match stamps a single
|
||||
/// timestamp.
|
||||
pub async fn increment_metric_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
defs: &[ObjectiveDefinition],
|
||||
metric: &str,
|
||||
amount: i64,
|
||||
now: &str,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let mut completed_ids = Vec::new();
|
||||
|
||||
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if let Some(prog) = existing {
|
||||
if prog.completed {
|
||||
continue;
|
||||
}
|
||||
let new_val = (prog.current + amount).min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
sqlx::query(
|
||||
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
|
||||
)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(now)
|
||||
.bind(&prog.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
} else {
|
||||
let new_val = amount.min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(completed_ids)
|
||||
}
|
||||
|
||||
pub async fn claim_objective(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
|
||||
@@ -73,9 +73,8 @@ pub async fn open_pack(
|
||||
|
||||
// Atomically claim the pack before minting any cards: only one concurrent opener
|
||||
// flips opened 0->1, so a double-open cannot mint the reward twice (duplication).
|
||||
let claimed = sqlx::query(
|
||||
"UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0",
|
||||
)
|
||||
let claimed =
|
||||
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0")
|
||||
.bind(pack_id)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
@@ -134,8 +133,8 @@ pub async fn open_pack(
|
||||
}
|
||||
}
|
||||
|
||||
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let card_ids_json =
|
||||
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||
|
||||
@@ -100,7 +100,11 @@ pub async fn add_xp_with_levelup(
|
||||
}
|
||||
|
||||
tracing::info!(profile_id, new_level = lvl, coins, "level up");
|
||||
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
|
||||
events.push(LevelUpEvent {
|
||||
new_level: lvl,
|
||||
coins_granted: coins,
|
||||
pack_granted: pack,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
|
||||
+71
-37
@@ -2,8 +2,8 @@ use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||
services::{club, pack},
|
||||
};
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Get the current season for a profile, creating it if it doesn't exist.
|
||||
@@ -20,9 +20,11 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
fetch(pool, profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing immediately after insert")))
|
||||
fetch(pool, profile_id).await?.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"season row missing immediately after insert"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
||||
@@ -36,21 +38,34 @@ async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
||||
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.
|
||||
pub async fn record_match(
|
||||
pool: &Pool,
|
||||
/// Mirrors [`record_match`] but every write — the season row, the rollover, the
|
||||
/// end-of-season coin and pack award, and the history entry — commits or rolls
|
||||
/// 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,
|
||||
profile_id: &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 {
|
||||
"win" => 3,
|
||||
"draw" => 1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE seasons SET \
|
||||
season_points = season_points + ?, \
|
||||
@@ -65,30 +80,28 @@ pub async fn record_match(
|
||||
.bind(outcome)
|
||||
.bind(outcome)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let season = fetch(pool, profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after record_match update")))?;
|
||||
|
||||
let season = fetch_tx(tx, profile_id).await?.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"season row missing after record_match_tx update"
|
||||
))
|
||||
})?;
|
||||
if !season.is_complete() {
|
||||
return Ok((season, None));
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Season complete — calculate result and start next
|
||||
let result = season.end_result();
|
||||
let old_div = season.division;
|
||||
let coins = season.season_reward_coins();
|
||||
let pack_id = season.season_reward_pack();
|
||||
|
||||
let new_div = match result {
|
||||
SeasonResult::Promoted => (old_div - 1).max(1),
|
||||
SeasonResult::Relegated => (old_div + 1).min(10),
|
||||
SeasonResult::Maintained => old_div,
|
||||
};
|
||||
let new_season = season.season_number + 1;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
|
||||
@@ -97,30 +110,46 @@ pub async fn record_match(
|
||||
)
|
||||
.bind(new_div)
|
||||
.bind(new_season)
|
||||
.bind(&now)
|
||||
.bind(now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Grant rewards
|
||||
club::add_coins(pool, club_id, coins).await?;
|
||||
if coins > 0 {
|
||||
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 {
|
||||
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 {
|
||||
SeasonResult::Promoted => "promoted",
|
||||
SeasonResult::Maintained => "maintained",
|
||||
SeasonResult::Relegated => "relegated",
|
||||
};
|
||||
let history_id = Uuid::new_v4().to_string();
|
||||
let _ = sqlx::query(
|
||||
sqlx::query(
|
||||
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
|
||||
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&history_id)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(profile_id)
|
||||
.bind(season.season_number)
|
||||
.bind(old_div)
|
||||
@@ -132,23 +161,28 @@ pub async fn record_match(
|
||||
.bind(new_div)
|
||||
.bind(coins)
|
||||
.bind(pack_id)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await;
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let summary = SeasonEndSummary {
|
||||
Ok(Some(SeasonEndSummary {
|
||||
result,
|
||||
old_division: old_div,
|
||||
new_division: new_div,
|
||||
new_season_number: new_season,
|
||||
coins_awarded: coins,
|
||||
pack_awarded: pack_id.map(String::from),
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
let updated = fetch(pool, profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
|
||||
Ok((updated, Some(summary)))
|
||||
async fn fetch_tx(tx: &mut Transaction<'_, Sqlite>, profile_id: &str) -> AppResult<Option<Season>> {
|
||||
Ok(sqlx::query_as::<_, Season>(
|
||||
"SELECT profile_id, division, season_number, season_points, matches_played, \
|
||||
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).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
|
||||
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
|
||||
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, matches_dnf, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
|
||||
|
||||
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
|
||||
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
|
||||
@@ -77,6 +78,77 @@ pub async fn record_match(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a completed match within an existing transaction (the atomic
|
||||
/// match-completion path). `outcome` is `win` | `draw` | `loss` | `dnf`. A DNF
|
||||
/// (abandon/quit) increments its own bucket — never `matches_lost` — and, like a
|
||||
/// loss, resets the win streak. All-or-nothing with the caller's transaction; it
|
||||
/// never commits on its own, so a later failure rolls this back with everything
|
||||
/// else.
|
||||
pub async fn record_match_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
outcome: &str,
|
||||
goals_for: i64,
|
||||
goals_against: i64,
|
||||
coins: i64,
|
||||
now: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query("INSERT OR IGNORE INTO statistics (profile_id, updated_at) VALUES (?, ?)")
|
||||
.bind(profile_id)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let current_streak: i64 =
|
||||
sqlx::query_scalar("SELECT win_streak FROM statistics WHERE profile_id = ?")
|
||||
.bind(profile_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let (w, d, l, dnf) = match outcome {
|
||||
"win" => (1i64, 0i64, 0i64, 0i64),
|
||||
"draw" => (0, 1, 0, 0),
|
||||
"dnf" => (0, 0, 0, 1),
|
||||
_ => (0, 0, 1, 0),
|
||||
};
|
||||
let new_streak = if outcome == "win" {
|
||||
current_streak + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE statistics SET
|
||||
matches_played = matches_played + 1,
|
||||
matches_won = matches_won + ?,
|
||||
matches_drawn = matches_drawn + ?,
|
||||
matches_lost = matches_lost + ?,
|
||||
matches_dnf = matches_dnf + ?,
|
||||
goals_scored = goals_scored + ?,
|
||||
goals_conceded = goals_conceded + ?,
|
||||
total_coins_earned = total_coins_earned + ?,
|
||||
win_streak = ?,
|
||||
best_win_streak = MAX(best_win_streak, ?),
|
||||
updated_at = ?
|
||||
WHERE profile_id = ?",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(d)
|
||||
.bind(l)
|
||||
.bind(dnf)
|
||||
.bind(goals_for)
|
||||
.bind(goals_against)
|
||||
.bind(coins)
|
||||
.bind(new_streak)
|
||||
.bind(new_streak)
|
||||
.bind(now)
|
||||
.bind(profile_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -121,6 +193,26 @@ pub async fn record_position_goals(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transaction-scoped [`record_position_goals`] for the atomic match-completion
|
||||
/// path.
|
||||
pub async fn record_position_goals_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
profile_id: &str,
|
||||
positions: &[String],
|
||||
) -> AppResult<()> {
|
||||
for position in positions {
|
||||
sqlx::query(
|
||||
"INSERT INTO position_goals (profile_id, position, goals) VALUES (?, ?, 1) \
|
||||
ON CONFLICT(profile_id, position) DO UPDATE SET goals = goals + 1",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(position)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> {
|
||||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
|
||||
|
||||
@@ -16,9 +16,7 @@ pub const MAX_TRAINING_BONUS: i64 = 3;
|
||||
pub const POSITION_CHANGE_COST: i64 = 500;
|
||||
|
||||
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
||||
sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
||||
))
|
||||
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
|
||||
.bind(owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
@@ -64,8 +62,8 @@ pub async fn change_position(
|
||||
new_position: &str,
|
||||
) -> AppResult<OwnedCard> {
|
||||
let valid_positions = [
|
||||
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
|
||||
"CF", "ST",
|
||||
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
|
||||
"ST",
|
||||
];
|
||||
if !valid_positions.contains(&new_position) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
|
||||
+228
-41
@@ -78,6 +78,199 @@ async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode
|
||||
(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 ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
@@ -125,7 +318,7 @@ async fn test_match_result_awards_coins() {
|
||||
"goals_against": 1,
|
||||
"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!(json["match_record"]["outcome"], "win");
|
||||
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
||||
@@ -154,7 +347,7 @@ async fn test_match_with_goal_positions_tracks_stats() {
|
||||
"mode": "squad_battles",
|
||||
"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);
|
||||
|
||||
let (status, stats) = json_get(&app, "/statistics").await;
|
||||
@@ -294,7 +487,12 @@ async fn test_sbc_rejects_duplicate_cards() {
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||||
let (s, _) = json_post(
|
||||
&app,
|
||||
&format!("/packs/open/{pack_id}"),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
let (_, coll) = json_get(&app, "/collection").await;
|
||||
@@ -312,9 +510,16 @@ async fn test_sbc_rejects_duplicate_cards() {
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "duplicate submission must be rejected: {result}");
|
||||
assert_eq!(
|
||||
s,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"duplicate submission must be rejected: {result}"
|
||||
);
|
||||
assert!(
|
||||
result["error"].as_str().unwrap_or_default().contains("duplicate"),
|
||||
result["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("duplicate"),
|
||||
"expected a duplicate-card error, got: {result}"
|
||||
);
|
||||
}
|
||||
@@ -412,9 +617,8 @@ async fn test_win_streak_tracking() {
|
||||
auth(&app, "StreakPlayer").await;
|
||||
|
||||
for _ in 0..3 {
|
||||
let (s, _) = json_post(
|
||||
let (s, _) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy",
|
||||
"opponent_name": "Bot",
|
||||
@@ -968,9 +1172,8 @@ async fn test_division_updates_after_wins() {
|
||||
|
||||
// Play 3 wins
|
||||
for _ in 0..3 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -992,9 +1195,8 @@ async fn test_season_ends_after_10_matches_and_promotes() {
|
||||
|
||||
// Win all 10 matches of the season
|
||||
for _ in 0..10 {
|
||||
let (s, result) = json_post(
|
||||
let (s, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1099,9 +1301,8 @@ async fn test_notifications_include_completed_objectives() {
|
||||
auth(&app, "ObjNotifPlayer").await;
|
||||
|
||||
// Play enough matches to complete the "daily_play_1_match" objective
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1124,9 +1325,8 @@ async fn test_match_result_returns_season_info() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "SeasonMatchPlayer").await;
|
||||
|
||||
let (s, result) = json_post(
|
||||
let (s, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 1, "mode": "squad_battles"
|
||||
@@ -1567,9 +1767,8 @@ async fn test_rivals_weekly_reward_claim() {
|
||||
auth(&app, "RivalsClaimPlayer").await;
|
||||
|
||||
// Play a match to create the season row
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1589,9 +1788,8 @@ async fn test_rivals_reward_increments_week_counter() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "RivalsWeekCounter").await;
|
||||
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1718,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).
|
||||
let mut level_ups_seen = false;
|
||||
for _ in 0..5 {
|
||||
let (status, json) = json_post(
|
||||
let (status, json) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1779,9 +1976,8 @@ async fn test_level_up_creates_persistent_notification() {
|
||||
|
||||
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
|
||||
for _ in 0..5 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1807,9 +2003,8 @@ async fn test_mark_all_notifications_read() {
|
||||
|
||||
// Generate a notification via level-up
|
||||
for _ in 0..5 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1831,9 +2026,8 @@ async fn test_mark_single_notification_read() {
|
||||
|
||||
// Generate level-up notifications
|
||||
for _ in 0..5 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1889,9 +2083,8 @@ async fn test_first_match_achievement_unlocks() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "FirstMatchAchPlayer").await;
|
||||
|
||||
let (_, result) = json_post(
|
||||
let (_, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1912,9 +2105,8 @@ async fn test_first_win_achievement_unlocks_on_win() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "FirstWinAchPlayer").await;
|
||||
|
||||
let (_, result) = json_post(
|
||||
let (_, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1936,9 +2128,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
|
||||
auth(&app, "NoDupAchPlayer").await;
|
||||
|
||||
// First match — first_match unlocks
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1947,9 +2138,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
|
||||
.await;
|
||||
|
||||
// Second match — first_match must NOT appear again
|
||||
let (_, result) = json_post(
|
||||
let (_, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1971,9 +2161,8 @@ async fn test_achievement_grants_coins() {
|
||||
let coins_before = club_before["coins"].as_i64().unwrap_or(0);
|
||||
|
||||
// first_match achievement grants 500 coins on top of match reward
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -2015,9 +2204,8 @@ async fn test_auth_reset_clears_profile() {
|
||||
auth(&app, "ResetPlayer").await;
|
||||
|
||||
// Play a match to generate some state
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -2090,9 +2278,8 @@ async fn test_division_history_records_after_season_end() {
|
||||
|
||||
// Win all 10 matches to complete and promote
|
||||
for _ in 0..10 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
|
||||
Reference in New Issue
Block a user