a749fba93c
CI / Build, lint & test (push) Failing after 1m42s
- GET /division returns live season stats (points, record, promotion threshold) - PUT /club allows updating club name and manager_name - GET /packs/history returns opened packs with full card definitions - GET /notifications dynamically surfaces completed objectives, expiring loans, season end - Club model gains manager_name column (migration 0006 already added it) - Pack model gains opened_cards and opened_at; pack SELECT queries updated - 9 new integration tests — all 45 pass, clippy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
5.2 KiB
Rust
170 lines
5.2 KiB
Rust
use crate::{
|
|
db::Pool,
|
|
error::{AppError, AppResult},
|
|
models::{
|
|
card::CardDefinition,
|
|
pack::{Pack, PackDefinition, PackOpenResult},
|
|
},
|
|
services::card_db::CardDb,
|
|
};
|
|
use anyhow::Context;
|
|
use rand::seq::SliceRandom;
|
|
use std::path::Path;
|
|
use uuid::Uuid;
|
|
|
|
pub fn load_pack_definitions(data_dir: &str) -> anyhow::Result<Vec<PackDefinition>> {
|
|
let dir = Path::new(data_dir).join("packs");
|
|
let mut defs = Vec::new();
|
|
if !dir.exists() {
|
|
return Ok(defs);
|
|
}
|
|
for entry in std::fs::read_dir(&dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
|
let content =
|
|
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
|
let batch: Vec<PackDefinition> =
|
|
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
|
defs.extend(batch);
|
|
}
|
|
}
|
|
Ok(defs)
|
|
}
|
|
|
|
pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppResult<Pack> {
|
|
let pack = Pack {
|
|
id: Uuid::new_v4().to_string(),
|
|
club_id: club_id.to_string(),
|
|
definition_id: definition_id.to_string(),
|
|
opened: false,
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
|
opened_cards: None,
|
|
opened_at: None,
|
|
};
|
|
sqlx::query(
|
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&pack.id)
|
|
.bind(&pack.club_id)
|
|
.bind(&pack.definition_id)
|
|
.bind(pack.opened)
|
|
.bind(&pack.created_at)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(pack)
|
|
}
|
|
|
|
pub async fn open_pack(
|
|
pool: &Pool,
|
|
card_db: &CardDb,
|
|
pack_defs: &[PackDefinition],
|
|
club_id: &str,
|
|
pack_id: &str,
|
|
) -> AppResult<PackOpenResult> {
|
|
let pack = sqlx::query_as::<_, Pack>(
|
|
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE id = ? AND club_id = ?"
|
|
)
|
|
.bind(pack_id)
|
|
.bind(club_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
|
|
|
|
if pack.opened {
|
|
return Err(AppError::BadRequest("pack already opened".into()));
|
|
}
|
|
|
|
let def = pack_defs
|
|
.iter()
|
|
.find(|d| d.id == pack.definition_id)
|
|
.ok_or_else(|| {
|
|
AppError::NotFound(format!("pack definition {} not found", pack.definition_id))
|
|
})?;
|
|
|
|
let mut cards: Vec<CardDefinition> = Vec::new();
|
|
|
|
for slot in &def.slots {
|
|
let pool_cards: Vec<CardDefinition> = if let Some(rarities) = &slot.rarity_filter {
|
|
card_db
|
|
.all()
|
|
.into_iter()
|
|
.filter(|c| {
|
|
let r = format!("{:?}", c.rarity).to_lowercase();
|
|
rarities.contains(&r)
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
} else if let Some(min) = slot.min_overall {
|
|
card_db.by_min_overall(min).into_iter().cloned().collect()
|
|
} else {
|
|
card_db.all().into_iter().cloned().collect()
|
|
};
|
|
|
|
// Choose cards synchronously before any awaits so ThreadRng is not held across .await
|
|
let chosen: Vec<CardDefinition> = {
|
|
let mut rng = rand::thread_rng();
|
|
(0..slot.count)
|
|
.filter_map(|_| pool_cards.choose(&mut rng).cloned())
|
|
.collect()
|
|
};
|
|
|
|
for card in chosen {
|
|
let owned_id = Uuid::new_v4().to_string();
|
|
sqlx::query(
|
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)"
|
|
)
|
|
.bind(&owned_id)
|
|
.bind(club_id)
|
|
.bind(&card.id)
|
|
.bind(chrono::Utc::now().to_rfc3339())
|
|
.execute(pool)
|
|
.await?;
|
|
cards.push(card);
|
|
}
|
|
}
|
|
|
|
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 = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
|
.bind(&card_ids_json)
|
|
.bind(&now)
|
|
.bind(pack_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(PackOpenResult {
|
|
pack_id: pack_id.to_string(),
|
|
cards,
|
|
})
|
|
}
|
|
|
|
pub async fn buy_pack(
|
|
pool: &Pool,
|
|
pack_defs: &[PackDefinition],
|
|
club_id: &str,
|
|
definition_id: &str,
|
|
) -> AppResult<Pack> {
|
|
let def = pack_defs
|
|
.iter()
|
|
.find(|d| d.id == definition_id)
|
|
.ok_or_else(|| {
|
|
AppError::NotFound(format!("pack definition '{definition_id}' not found"))
|
|
})?;
|
|
|
|
crate::services::club::spend_coins(pool, club_id, def.cost_coins).await?;
|
|
grant_pack(pool, club_id, definition_id).await
|
|
}
|
|
|
|
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
|
|
let packs = sqlx::query_as::<_, Pack>(
|
|
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE club_id = ? AND opened = 0"
|
|
)
|
|
.bind(club_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(packs)
|
|
}
|