This commit is contained in:
+60
-59
@@ -5,10 +5,10 @@ use crate::{
|
|||||||
services::{club as club_svc, notification},
|
services::{club as club_svc, notification},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use std::path::Path;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use sqlx::{Sqlite, Transaction};
|
use sqlx::{Sqlite, Transaction};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::Path;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<AchievementDefinition>> {
|
pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<AchievementDefinition>> {
|
||||||
let dir = Path::new(data_dir).join("achievements");
|
let dir = Path::new(data_dir).join("achievements");
|
||||||
@@ -196,67 +196,68 @@ async fn metric_value_tx(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
trigger: &str,
|
trigger: &str,
|
||||||
) -> AppResult<i64> {
|
) -> AppResult<i64> {
|
||||||
let v: i64 = match trigger {
|
let v: i64 =
|
||||||
"matches_played" => {
|
match trigger {
|
||||||
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
|
"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)
|
.bind(profile_id)
|
||||||
.fetch_optional(&mut **tx)
|
.fetch_optional(&mut **tx)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0)
|
.unwrap_or(1),
|
||||||
}
|
"objectives_completed" => sqlx::query_scalar(
|
||||||
"matches_won" => {
|
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
||||||
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)
|
.bind(profile_id)
|
||||||
.fetch_optional(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?
|
.await?,
|
||||||
.unwrap_or(1),
|
"drafts_completed" => sqlx::query_scalar(
|
||||||
"objectives_completed" => sqlx::query_scalar(
|
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
|
||||||
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
)
|
||||||
)
|
.bind(profile_id)
|
||||||
.bind(profile_id)
|
.fetch_one(&mut **tx)
|
||||||
.fetch_one(&mut **tx)
|
.await?,
|
||||||
.await?,
|
_ => 0,
|
||||||
"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)
|
Ok(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-13
@@ -140,14 +140,12 @@ const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_r
|
|||||||
/// The club's most-recently-updated squad id (its "active" squad), matching the
|
/// 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.
|
/// 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>> {
|
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
|
||||||
Ok(
|
Ok(sqlx::query_scalar::<_, String>(
|
||||||
sqlx::query_scalar::<_, String>(
|
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
|
||||||
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?,
|
|
||||||
)
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The owned card assigned as the manager of `club_id`'s active squad, if any.
|
/// The owned card assigned as the manager of `club_id`'s active squad, if any.
|
||||||
@@ -182,11 +180,7 @@ pub async fn get_squad_manager_for_squad(
|
|||||||
/// belong to `club_id`, so a client can neither manage another club's squad nor
|
/// 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
|
/// assign a card it does not own. One manager per squad (the PK REPLACE), so a
|
||||||
/// re-assignment never accumulates duplicate rows.
|
/// re-assignment never accumulates duplicate rows.
|
||||||
pub async fn set_squad_manager(
|
pub async fn set_squad_manager(pool: &Pool, club_id: &str, owned_card_id: &str) -> AppResult<()> {
|
||||||
pool: &Pool,
|
|
||||||
club_id: &str,
|
|
||||||
owned_card_id: &str,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let squad_id = active_squad_id(pool, club_id)
|
let squad_id = active_squad_id(pool, club_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
|
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
|
||||||
@@ -304,7 +298,9 @@ mod tests {
|
|||||||
let (dir, url, pool) = fixture().await;
|
let (dir, url, pool) = fixture().await;
|
||||||
|
|
||||||
// SAVE.
|
// SAVE.
|
||||||
set_squad_manager(&pool, "club-a", "mgr").await.expect("assign");
|
set_squad_manager(&pool, "club-a", "mgr")
|
||||||
|
.await
|
||||||
|
.expect("assign");
|
||||||
// RELOAD (same pool).
|
// RELOAD (same pool).
|
||||||
let got = get_squad_manager(&pool, "club-a").await.unwrap();
|
let got = get_squad_manager(&pool, "club-a").await.unwrap();
|
||||||
assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr"));
|
assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr"));
|
||||||
|
|||||||
@@ -438,7 +438,9 @@ async fn complete_match_inner(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if req.match_identity.trim().is_empty() {
|
if req.match_identity.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest("match_identity must not be empty".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"match_identity must not be empty".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = req.result;
|
let result = req.result;
|
||||||
@@ -905,7 +907,11 @@ mod match_completion_tests {
|
|||||||
assert_eq!(r.xp_awarded, XP_LOSS);
|
assert_eq!(r.xp_awarded, XP_LOSS);
|
||||||
assert_eq!(r.match_record.outcome, "dnf");
|
assert_eq!(r.match_record.outcome, "dnf");
|
||||||
assert_eq!(stat(&fx.pool, "matches_dnf").await, 1);
|
assert_eq!(stat(&fx.pool, "matches_dnf").await, 1);
|
||||||
assert_eq!(stat(&fx.pool, "matches_lost").await, 0, "DNF is not a loss row");
|
assert_eq!(
|
||||||
|
stat(&fx.pool, "matches_lost").await,
|
||||||
|
0,
|
||||||
|
"DNF is not a loss row"
|
||||||
|
);
|
||||||
assert_eq!(stat(&fx.pool, "matches_played").await, 1);
|
assert_eq!(stat(&fx.pool, "matches_played").await, 1);
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_LOSS);
|
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_LOSS);
|
||||||
}
|
}
|
||||||
@@ -922,7 +928,11 @@ mod match_completion_tests {
|
|||||||
assert_eq!(r.match_record.outcome, "no_contest");
|
assert_eq!(r.match_record.outcome, "no_contest");
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS, "no coins for a void");
|
assert_eq!(coins(&fx.pool).await, START_COINS, "no coins for a void");
|
||||||
assert_eq!(xp(&fx.pool).await, 0);
|
assert_eq!(xp(&fx.pool).await, 0);
|
||||||
assert_eq!(stat(&fx.pool, "matches_played").await, 0, "not counted as played");
|
assert_eq!(
|
||||||
|
stat(&fx.pool, "matches_played").await,
|
||||||
|
0,
|
||||||
|
"not counted as played"
|
||||||
|
);
|
||||||
// Still recorded for history + idempotency.
|
// Still recorded for history + idempotency.
|
||||||
assert_eq!(count(&fx.pool, "matches").await, 1);
|
assert_eq!(count(&fx.pool, "matches").await, 1);
|
||||||
assert_eq!(count(&fx.pool, "match_completions").await, 1);
|
assert_eq!(count(&fx.pool, "match_completions").await, 1);
|
||||||
@@ -1089,7 +1099,11 @@ mod match_completion_tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(retry.applied, "{fp:?} retry-after-fault must apply");
|
assert!(retry.applied, "{fp:?} retry-after-fault must apply");
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN, "{fp:?} retry");
|
assert_eq!(
|
||||||
|
coins(&fx.pool).await,
|
||||||
|
START_COINS + COINS_WIN,
|
||||||
|
"{fp:?} retry"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,9 +1143,16 @@ mod match_completion_tests {
|
|||||||
];
|
];
|
||||||
let achs = vec![ach("first_win", "matches_won", 1, 50)];
|
let achs = vec![ach("first_win", "matches_won", 1, 50)];
|
||||||
|
|
||||||
let first = complete_match(&fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 2, 0), &objs, &achs)
|
let first = complete_match(
|
||||||
.await
|
&fx.pool,
|
||||||
.unwrap();
|
PROFILE,
|
||||||
|
CLUB,
|
||||||
|
&req("m", MatchResultKind::Win, 2, 0),
|
||||||
|
&objs,
|
||||||
|
&achs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(first.applied);
|
assert!(first.applied);
|
||||||
assert_eq!(first.objectives_updated.len(), 2);
|
assert_eq!(first.objectives_updated.len(), 2);
|
||||||
assert_eq!(first.achievements_unlocked.len(), 1);
|
assert_eq!(first.achievements_unlocked.len(), 1);
|
||||||
@@ -1140,9 +1161,16 @@ mod match_completion_tests {
|
|||||||
assert_eq!(count(&fx.pool, "player_achievements").await, 1);
|
assert_eq!(count(&fx.pool, "player_achievements").await, 1);
|
||||||
|
|
||||||
// Replay: no double objectives/achievements/coins.
|
// Replay: no double objectives/achievements/coins.
|
||||||
let replay = complete_match(&fx.pool, PROFILE, CLUB, &req("m", MatchResultKind::Win, 2, 0), &objs, &achs)
|
let replay = complete_match(
|
||||||
.await
|
&fx.pool,
|
||||||
.unwrap();
|
PROFILE,
|
||||||
|
CLUB,
|
||||||
|
&req("m", MatchResultKind::Win, 2, 0),
|
||||||
|
&objs,
|
||||||
|
&achs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(!replay.applied);
|
assert!(!replay.applied);
|
||||||
assert!(replay.objectives_updated.is_empty());
|
assert!(replay.objectives_updated.is_empty());
|
||||||
assert!(replay.achievements_unlocked.is_empty());
|
assert!(replay.achievements_unlocked.is_empty());
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use sqlx::{Sqlite, Transaction};
|
|
||||||
|
|
||||||
pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result<Vec<ObjectiveDefinition>> {
|
pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result<Vec<ObjectiveDefinition>> {
|
||||||
let dir = Path::new(data_dir).join("objectives");
|
let dir = Path::new(data_dir).join("objectives");
|
||||||
|
|||||||
Reference in New Issue
Block a user