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> { 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 = 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 { 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 { 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 = Vec::new(); for slot in &def.slots { let pool_cards: Vec = 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 = { 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::>()) .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 { 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> { 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) }