diff --git a/migrations/0005_draft_sessions.sql b/migrations/0005_draft_sessions.sql new file mode 100644 index 0000000..c3cc121 --- /dev/null +++ b/migrations/0005_draft_sessions.sql @@ -0,0 +1,16 @@ +-- Stateful FUT-style draft sessions. +-- Each session tracks position order, per-position candidates, and picks made. +CREATE TABLE IF NOT EXISTS draft_sessions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + difficulty TEXT NOT NULL DEFAULT 'professional', + pick_order TEXT NOT NULL, -- JSON array of position strings, e.g. ["GK","RB",...] + picks TEXT NOT NULL DEFAULT '[]', -- JSON array of picked card_ids (len tracks progress) + current_candidates TEXT, -- JSON array of card_ids offered for the current pick + status TEXT NOT NULL DEFAULT 'active', -- active | completed | abandoned + reward_coins INTEGER DEFAULT 0, + reward_pack_id TEXT, + squad_rating INTEGER DEFAULT 0, + started_at TEXT NOT NULL, + completed_at TEXT +); diff --git a/src/app.rs b/src/app.rs index 3c8d84d..f453a1d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -125,6 +125,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/cards", get(routes::cards::get_cards)) .route("/cards/:card_id", get(routes::cards::get_card)) .route("/collection", get(routes::cards::get_collection)) + .route( + "/collection/:owned_card_id", + delete(routes::cards::delete_owned_card), + ) .route("/packs", get(routes::packs::get_packs)) .route("/packs/buy", post(routes::packs::post_buy_pack)) .route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) @@ -134,10 +138,15 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/objectives", get(routes::objectives::get_objectives)) + .route("/objectives/:objective_id", get(routes::objectives::get_objective)) .route( "/objectives/claim", post(routes::objectives::post_claim_objective), ) + .route( + "/objectives/:objective_id/claim", + post(routes::objectives::post_claim_objective_by_id), + ) .route("/matches", get(routes::matches::get_matches)) .route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/result", post(routes::matches::post_match_result)) @@ -148,6 +157,11 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/market/buy", post(routes::market::post_market_buy)) .route("/market/sell", post(routes::market::post_market_sell)) .route("/market/refresh", post(routes::market::post_market_refresh)) + .route("/market/my-listings", get(routes::market::get_my_listings)) + .route( + "/market/listings/:listing_id", + delete(routes::market::delete_market_listing), + ) .route("/statistics", get(routes::statistics::get_statistics)) .route( "/statistics/history", @@ -156,6 +170,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) .route("/draft/squad", get(routes::draft::get_draft_squad)) + .route("/draft/start", post(routes::draft::post_draft_start)) + .route("/draft/sessions/:id", get(routes::draft::get_draft_session)) + .route( + "/draft/sessions/:id/pick", + post(routes::draft::post_draft_pick), + ) + .route( + "/draft/sessions/:id/abandon", + post(routes::draft::post_draft_abandon), + ) .route("/events", get(routes::events::get_events)) .route("/events/:event_id", get(routes::events::get_event)) .route( diff --git a/src/models/draft.rs b/src/models/draft.rs new file mode 100644 index 0000000..4ec939f --- /dev/null +++ b/src/models/draft.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct DraftSession { + pub id: String, + pub profile_id: String, + pub difficulty: String, + /// JSON-encoded Vec — position labels in pick order + pub pick_order: String, + /// JSON-encoded Vec — card_ids picked so far + pub picks: String, + /// JSON-encoded Vec — card_ids offered for the current slot (None if complete/abandoned) + pub current_candidates: Option, + pub status: String, + pub reward_coins: i64, + pub reward_pack_id: Option, + pub squad_rating: i64, + pub started_at: String, + pub completed_at: Option, +} + +/// Default 4-3-3 position pick order for a draft. +pub const DRAFT_PICK_ORDER: &[&str] = &[ + "GK", "RB", "CB", "CB", "LB", "CDM", "CM", "CAM", "RW", "ST", "LW", +]; diff --git a/src/models/mod.rs b/src/models/mod.rs index 64e4747..395fce3 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod card; pub mod club; +pub mod draft; pub mod event; pub mod market; pub mod match_result; diff --git a/src/routes/cards.rs b/src/routes/cards.rs index 41553e8..a4ad7bb 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -12,6 +12,15 @@ use crate::{ services::{club as club_svc, profile as profile_svc}, }; +/// 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 } +} + #[derive(Debug, Deserialize)] pub struct CardQuery { pub rarity: Option, @@ -114,3 +123,42 @@ pub async fn get_collection(State(state): State) -> AppResult, + Path(owned_card_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + let owned = sqlx::query_as::<_, OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \ + FROM owned_cards WHERE id = ? AND club_id = ?", + ) + .bind(&owned_card_id) + .bind(&club.id) + .fetch_optional(&state.pool) + .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 coins = quick_sell_coins(card.overall); + + sqlx::query("DELETE FROM owned_cards WHERE id = ?") + .bind(&owned_card_id) + .execute(&state.pool) + .await?; + + club_svc::add_coins(&state.pool, &club.id, coins).await?; + + Ok(Json(json!({ + "quick_sold": owned_card_id, + "card_id": owned.card_id, + "coins_received": coins, + }))) +} diff --git a/src/routes/draft.rs b/src/routes/draft.rs index 9bb62d2..1c058c7 100644 --- a/src/routes/draft.rs +++ b/src/routes/draft.rs @@ -1,27 +1,28 @@ use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, Json, }; use serde::Deserialize; use serde_json::{json, Value}; -use crate::{app::AppState, error::AppResult, services::match_service}; +use crate::{ + app::AppState, + error::AppResult, + services::{club as club_svc, draft as draft_svc, match_service, profile as profile_svc}, +}; #[derive(Deserialize)] pub struct DraftQuery { pub difficulty: Option, } -/// Returns a randomly generated draft squad from the card pool. -/// Difficulty controls minimum card overall. The front-end is responsible -/// for presenting per-position pick choices; this endpoint provides the pool. +/// Legacy endpoint: returns a pre-built random draft squad (no session state). pub async fn get_draft_squad( State(state): State, Query(query): Query, ) -> AppResult> { let difficulty = query.difficulty.as_deref().unwrap_or("beginner"); let generated = match_service::generate_opponent(&state.card_db, difficulty); - Ok(Json(json!({ "mode": "draft", "difficulty": difficulty, @@ -30,3 +31,67 @@ pub async fn get_draft_squad( "cards": generated["cards"], }))) } + +/// Start a new stateful FUT-style draft session. +/// +/// Returns a session ID and the 5 candidates for the first position (GK). +/// The caller picks one candidate, then calls `POST /draft/sessions/:id/pick` +/// until all 11 slots are filled. +pub async fn post_draft_start( + State(state): State, + Query(query): Query, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).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?; + Ok(Json(session)) +} + +/// Get the current state of a draft session. +pub async fn get_draft_session( + State(state): State, + Path(session_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?; + Ok(Json(session)) +} + +#[derive(Deserialize)] +pub struct PickRequest { + pub card_id: String, +} + +/// Pick one card from the current candidates list. +/// +/// Advances the session to the next position. +/// When the last pick is made the session status changes to "completed" +/// and rewards (coins + optional pack) are granted automatically. +pub async fn post_draft_pick( + State(state): State, + Path(session_id): Path, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + let session = draft_svc::pick_card( + &state.pool, + &state.card_db, + &club.id, + &profile.id, + &session_id, + &req.card_id, + ) + .await?; + Ok(Json(session)) +} + +/// Abandon an active draft session. No rewards are granted. +pub async fn post_draft_abandon( + State(state): State, + Path(session_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?; + Ok(Json(result)) +} diff --git a/src/routes/market.rs b/src/routes/market.rs index 0687054..e75f4dc 100644 --- a/src/routes/market.rs +++ b/src/routes/market.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, Json, }; use serde::Deserialize; @@ -12,6 +12,7 @@ use crate::{ services::{club as club_svc, market as market_svc, profile as profile_svc}, }; + #[derive(Deserialize)] pub struct MarketQuery { pub min_overall: Option, @@ -73,3 +74,22 @@ pub async fn post_market_refresh(State(state): State) -> AppResult) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).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() }))) +} + +/// Cancel a player-posted listing and return the card to the collection. +pub async fn delete_market_listing( + State(state): State, + Path(listing_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?; + Ok(Json(json!({ "cancelled": listing_id }))) +} diff --git a/src/routes/objectives.rs b/src/routes/objectives.rs index c49cecc..ec6e215 100644 --- a/src/routes/objectives.rs +++ b/src/routes/objectives.rs @@ -1,10 +1,13 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::{Path, State}, + Json, +}; use serde::Deserialize; use serde_json::{json, Value}; use crate::{ app::AppState, - error::AppResult, + error::{AppError, AppResult}, services::{club as club_svc, objective as obj_svc, profile as profile_svc}, }; @@ -15,6 +18,37 @@ pub async fn get_objectives(State(state): State) -> AppResult, + Path(objective_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let all = + obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; + let obj = all + .into_iter() + .find(|o| o.definition.id == objective_id) + .ok_or_else(|| AppError::NotFound(format!("objective '{objective_id}' not found")))?; + Ok(Json(json!({ "objective": obj }))) +} + +pub async fn post_claim_objective_by_id( + State(state): State, + Path(objective_id): Path, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + let reward = obj_svc::claim_objective( + &state.pool, + &profile.id, + &club.id, + &state.obj_defs, + &objective_id, + ) + .await?; + Ok(Json(json!({ "claimed": true, "reward": reward }))) +} + #[derive(Deserialize)] pub struct ClaimRequest { pub objective_id: String, diff --git a/src/services/draft.rs b/src/services/draft.rs new file mode 100644 index 0000000..e3e6f0a --- /dev/null +++ b/src/services/draft.rs @@ -0,0 +1,327 @@ +use rand::seq::SliceRandom; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::draft::{DraftSession, DRAFT_PICK_ORDER}, + services::{card_db::CardDb, pack}, +}; + +const CANDIDATES_PER_SLOT: usize = 5; + +fn difficulty_min_overall(difficulty: &str) -> u8 { + match difficulty { + "world_class" => 78, + "legendary" => 83, + "ultimate" => 88, + "professional" => 70, + _ => 55, + } +} + +/// Pick up to N candidates for a given position slot. +/// Tries exact position match first, fills remainder from the general pool. +fn pick_candidates(card_db: &CardDb, position: &str, min_overall: u8, n: usize) -> Vec { + let mut rng = rand::thread_rng(); + let all = card_db.by_min_overall(min_overall); + + // CDM / CAM → also accept CM as fallback + let pos_match = |p: &str| -> bool { + p.eq_ignore_ascii_case(position) + || matches!( + (position.to_uppercase().as_str(), p.to_uppercase().as_str()), + ("CDM", "CM") | ("CAM", "CM") | ("CM", "CDM") | ("CM", "CAM") + ) + }; + + let mut exact: Vec<_> = all.iter().filter(|c| pos_match(&c.position)).collect(); + exact.shuffle(&mut rng); + + let mut pool: Vec<_> = all.iter().filter(|c| !pos_match(&c.position)).collect(); + pool.shuffle(&mut rng); + + exact + .into_iter() + .chain(pool) + .take(n) + .map(|c| c.id.clone()) + .collect() +} + +/// Compute reward tier based on average overall of picked cards. +fn compute_reward(card_db: &CardDb, picks: &[String]) -> (i64, Option, i64) { + if picks.is_empty() { + return (0, None, 0); + } + let overalls: Vec = picks + .iter() + .filter_map(|id| card_db.get(id).map(|c| c.overall as i64)) + .collect(); + let avg = if overalls.is_empty() { + 0 + } else { + overalls.iter().sum::() / overalls.len() as i64 + }; + + let (coins, pack_id) = if avg >= 84 { + (2000, Some("gold_pack".to_string())) + } else if avg >= 78 { + (1000, Some("silver_pack".to_string())) + } else { + (400, None) + }; + + (coins, pack_id, avg) +} + +pub async fn start_draft( + pool: &Pool, + card_db: &CardDb, + profile_id: &str, + difficulty: &str, +) -> AppResult { + let min_overall = difficulty_min_overall(difficulty); + + let pick_order: Vec = DRAFT_PICK_ORDER.iter().map(|s| s.to_string()).collect(); + let first_position = &pick_order[0]; + let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT); + + let session = DraftSession { + id: Uuid::new_v4().to_string(), + profile_id: profile_id.to_string(), + difficulty: difficulty.to_string(), + pick_order: serde_json::to_string(&pick_order).unwrap(), + picks: "[]".to_string(), + current_candidates: Some(serde_json::to_string(&candidates).unwrap()), + status: "active".to_string(), + reward_coins: 0, + reward_pack_id: None, + squad_rating: 0, + started_at: chrono::Utc::now().to_rfc3339(), + completed_at: None, + }; + + sqlx::query( + "INSERT INTO draft_sessions (id, profile_id, difficulty, pick_order, picks, \ + current_candidates, status, reward_coins, reward_pack_id, squad_rating, \ + started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&session.id) + .bind(&session.profile_id) + .bind(&session.difficulty) + .bind(&session.pick_order) + .bind(&session.picks) + .bind(&session.current_candidates) + .bind(&session.status) + .bind(session.reward_coins) + .bind(&session.reward_pack_id) + .bind(session.squad_rating) + .bind(&session.started_at) + .bind(&session.completed_at) + .execute(pool) + .await?; + + Ok(render_session(&session, card_db)) +} + +pub async fn get_draft( + pool: &Pool, + card_db: &CardDb, + profile_id: &str, + session_id: &str, +) -> AppResult { + let session = fetch_session(pool, profile_id, session_id).await?; + Ok(render_session(&session, card_db)) +} + +pub async fn pick_card( + pool: &Pool, + card_db: &CardDb, + club_id: &str, + profile_id: &str, + session_id: &str, + card_id: &str, +) -> AppResult { + let mut session = fetch_session(pool, profile_id, session_id).await?; + + if session.status != "active" { + return Err(AppError::BadRequest(format!( + "draft session is '{}'", + session.status + ))); + } + + // Validate the picked card is in the current candidates list + let candidates: Vec = session + .current_candidates + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + if !candidates.contains(&card_id.to_string()) { + return Err(AppError::BadRequest( + "card_id is not among the current candidates".into(), + )); + } + + let pick_order: Vec = serde_json::from_str(&session.pick_order) + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad pick_order json: {e}")))?; + let mut picks: Vec = serde_json::from_str(&session.picks) + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad picks json: {e}")))?; + + picks.push(card_id.to_string()); + + let next_index = picks.len(); + let all_filled = next_index >= pick_order.len(); + + 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())) + } 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); + ( + Some(serde_json::to_string(&next_candidates).unwrap()), + "active".to_string(), + 0, + None, + 0, + None, + ) + }; + + let picks_json = serde_json::to_string(&picks).unwrap(); + + sqlx::query( + "UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \ + reward_coins = ?, reward_pack_id = ?, squad_rating = ?, completed_at = ? WHERE id = ?", + ) + .bind(&picks_json) + .bind(&new_candidates) + .bind(&new_status) + .bind(reward_coins) + .bind(&reward_pack_id) + .bind(squad_rating) + .bind(&completed_at) + .bind(session_id) + .execute(pool) + .await?; + + session.picks = picks_json; + session.current_candidates = new_candidates; + session.status = new_status.clone(); + session.reward_coins = reward_coins; + session.reward_pack_id = reward_pack_id.clone(); + session.squad_rating = squad_rating; + session.completed_at = completed_at; + + // Grant reward when draft is completed + if new_status == "completed" { + if reward_coins > 0 { + crate::services::club::add_coins(pool, club_id, reward_coins).await?; + } + if let Some(ref pack_def_id) = reward_pack_id { + pack::grant_pack(pool, club_id, pack_def_id).await?; + } + tracing::info!( + session_id, + profile_id, + reward_coins, + squad_rating, + "draft completed" + ); + } + + let mut rendered = render_session(&session, card_db); + rendered["just_completed"] = json!(new_status == "completed"); + if new_status == "completed" { + rendered["reward"] = json!({ + "coins": reward_coins, + "pack_id": reward_pack_id, + "squad_rating": squad_rating, + }); + } + Ok(rendered) +} + +pub async fn abandon_draft( + pool: &Pool, + profile_id: &str, + session_id: &str, +) -> AppResult { + let session = fetch_session(pool, profile_id, session_id).await?; + + if session.status == "completed" { + return Err(AppError::BadRequest( + "cannot abandon a completed draft".into(), + )); + } + + sqlx::query("UPDATE draft_sessions SET status = 'abandoned' WHERE id = ?") + .bind(session_id) + .execute(pool) + .await?; + + Ok(json!({ "abandoned": session_id })) +} + +async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppResult { + sqlx::query_as::<_, DraftSession>( + "SELECT id, profile_id, difficulty, pick_order, picks, current_candidates, \ + status, reward_coins, reward_pack_id, squad_rating, started_at, completed_at \ + FROM draft_sessions WHERE id = ? AND profile_id = ?", + ) + .bind(session_id) + .bind(profile_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("draft session '{session_id}' not found"))) +} + +fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value { + let pick_order: Vec = + serde_json::from_str(&session.pick_order).unwrap_or_default(); + let picks: Vec = serde_json::from_str(&session.picks).unwrap_or_default(); + let candidates: Vec = session + .current_candidates + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + let picks_with_defs: Vec = picks + .iter() + .enumerate() + .map(|(i, card_id)| { + json!({ + "slot": i, + "position": pick_order.get(i).cloned().unwrap_or_default(), + "card_id": card_id, + "card": card_db.get(card_id), + }) + }) + .collect(); + + let current_position = pick_order.get(picks.len()).cloned(); + let candidates_with_defs: Vec = candidates + .iter() + .map(|id| json!({ "card_id": id, "card": card_db.get(id) })) + .collect(); + + json!({ + "session_id": session.id, + "difficulty": session.difficulty, + "status": session.status, + "progress": { "filled": picks.len(), "total": pick_order.len() }, + "current_position": current_position, + "candidates": candidates_with_defs, + "picks": picks_with_defs, + "squad_rating": session.squad_rating, + "started_at": session.started_at, + "completed_at": session.completed_at, + }) +} diff --git a/src/services/market.rs b/src/services/market.rs index d0cb401..7ca670c 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -191,3 +191,63 @@ fn price_for_card(overall: u8) -> i64 { _ => 200, } } + +/// Return all active listings posted by the given club (player listings only). +pub async fn get_listings_by_seller( + pool: &Pool, + card_db: &CardDb, + club_id: &str, +) -> AppResult> { + let listings = sqlx::query_as::<_, MarketListing>( + "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \ + FROM market_listings WHERE sold = 0 AND seller_name = ? \ + AND expires_at > datetime('now') ORDER BY listed_at DESC", + ) + .bind(club_id) + .fetch_all(pool) + .await?; + + let with_cards = listings + .into_iter() + .filter_map(|l| { + card_db + .get(&l.card_id) + .map(|card| MarketListingWithCard { listing: l, card: card.clone() }) + }) + .collect(); + Ok(with_cards) +} + +/// Cancel a player-posted listing and return the card to the owner's collection. +pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> AppResult<()> { + let listing = sqlx::query_as::<_, MarketListing>( + "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \ + FROM market_listings WHERE id = ? AND seller_name = ? AND sold = 0", + ) + .bind(listing_id) + .bind(club_id) + .fetch_optional(pool) + .await? + .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) + .execute(pool) + .await?; + + // Return the card to the collection + let owned_id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + 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(&listing.card_id) + .bind(&now) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index f51f2b3..20521aa 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod card_db; pub mod club; +pub mod draft; pub mod event; pub mod market; pub mod match_service; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2de1e2a..9d78ff1 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -633,3 +633,200 @@ async fn test_chemistry_response_has_breakdown_structure() { assert_eq!(chem["max"], 100); assert!(chem["player_chemistries"].is_array()); } + +// ── Phase 8: Draft v2, Quick-sell, Objectives, Market my-listings ──────────── + +#[tokio::test] +async fn test_draft_start_returns_session_with_candidates() { + let app = build_test_app().await; + auth(&app, "DraftStarter").await; + + let (status, json) = json_post(&app, "/draft/start?difficulty=professional", serde_json::json!({})).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["session_id"].is_string()); + assert_eq!(json["status"], "active"); + assert_eq!(json["current_position"], "GK"); + assert!(json["candidates"].is_array()); + let candidates = json["candidates"].as_array().unwrap(); + // Should have up to 5 candidates (may be less if card pool is small for GK) + assert!(!candidates.is_empty(), "should offer at least 1 candidate"); + assert_eq!(json["progress"]["filled"], 0); + assert_eq!(json["progress"]["total"], 11); +} + +#[tokio::test] +async fn test_draft_pick_advances_session() { + let app = build_test_app().await; + auth(&app, "DraftPicker").await; + + // Start a draft + let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK, "{start}"); + let session_id = start["session_id"].as_str().unwrap().to_string(); + + // Pick the first candidate (GK) + let first_candidate_id = start["candidates"][0]["card_id"].as_str().unwrap().to_string(); + let (s, pick1) = json_post( + &app, + &format!("/draft/sessions/{session_id}/pick"), + serde_json::json!({ "card_id": first_candidate_id }), + ).await; + assert_eq!(s, StatusCode::OK, "{pick1}"); + assert_eq!(pick1["status"], "active"); + assert_eq!(pick1["progress"]["filled"], 1); + assert_eq!(pick1["current_position"], "RB"); + assert!(pick1["candidates"].as_array().unwrap().len() >= 1); +} + +#[tokio::test] +async fn test_draft_pick_invalid_card_rejected() { + let app = build_test_app().await; + auth(&app, "DraftCheat").await; + + let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK); + let session_id = start["session_id"].as_str().unwrap().to_string(); + + // Attempt to pick a card not in candidates + let (s, _) = json_post( + &app, + &format!("/draft/sessions/{session_id}/pick"), + serde_json::json!({ "card_id": "not_a_real_card_id" }), + ).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_draft_complete_awards_coins() { + let app = build_test_app().await; + auth(&app, "DraftCompleter").await; + + // Get initial coin balance + let (_, club) = json_get(&app, "/club").await; + let coins_before = club["coins"].as_i64().unwrap(); + + // Start and complete a full draft (pick all 11 positions) + let (_, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await; + let mut session_id = start["session_id"].as_str().unwrap().to_string(); + let mut current = start; + + for _ in 0..11 { + if current["status"] == "completed" { break; } + let card_id = current["candidates"][0]["card_id"].as_str().unwrap().to_string(); + let (s, next) = json_post( + &app, + &format!("/draft/sessions/{session_id}/pick"), + serde_json::json!({ "card_id": card_id }), + ).await; + assert_eq!(s, StatusCode::OK, "pick failed: {next}"); + session_id = next["session_id"].as_str().unwrap_or(&session_id).to_string(); + current = next; + } + + assert_eq!(current["status"], "completed"); + assert!(current["reward"]["coins"].as_i64().unwrap_or(0) > 0); + + // Verify coins were granted + let (_, club_after) = json_get(&app, "/club").await; + let coins_after = club_after["coins"].as_i64().unwrap(); + assert!(coins_after > coins_before, "coins should increase after completing draft"); +} + +#[tokio::test] +async fn test_draft_abandon() { + let app = build_test_app().await; + auth(&app, "DraftAbandoner").await; + + let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK); + let session_id = start["session_id"].as_str().unwrap().to_string(); + + let (s, result) = json_post( + &app, + &format!("/draft/sessions/{session_id}/abandon"), + serde_json::json!({}), + ).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(result["abandoned"], session_id.as_str()); +} + +#[tokio::test] +async fn test_quick_sell_owned_card() { + let app = build_test_app().await; + auth(&app, "QuickSeller").await; + + // Open starter pack to get a card + let (_, packs) = json_get(&app, "/packs").await; + let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap(); + let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK); + + // Get the owned card ID + let (_, coll) = json_get(&app, "/collection").await; + let owned_card_id = coll["collection"][0]["owned_card_id"].as_str().unwrap().to_string(); + + // Get coins before + let (_, club) = json_get(&app, "/club").await; + let coins_before = club["coins"].as_i64().unwrap(); + + // Quick sell + let resp = app.clone().oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/collection/{owned_card_id}")) + .body(Body::empty()) + .unwrap() + ).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let sell_json: Value = serde_json::from_slice(&body).unwrap(); + let coins_received = sell_json["coins_received"].as_i64().unwrap(); + assert!(coins_received >= 150, "quick sell should give at least 150 coins"); + + // Card should be gone + let (_, coll_after) = json_get(&app, "/collection").await; + let still_owned = coll_after["collection"].as_array().unwrap() + .iter() + .any(|c| c["owned_card_id"].as_str().unwrap() == owned_card_id); + assert!(!still_owned, "card should be removed from collection after quick sell"); + + // Coins should have increased + let (_, club_after) = json_get(&app, "/club").await; + let coins_after = club_after["coins"].as_i64().unwrap(); + assert_eq!(coins_after, coins_before + coins_received); +} + +#[tokio::test] +async fn test_objective_get_by_id() { + let app = build_test_app().await; + auth(&app, "ObjectiveViewer").await; + + // Get all objectives and pick the first one + // ObjectiveWithProgress uses #[serde(flatten)] so definition fields are inline + let (s, all) = json_get(&app, "/objectives").await; + assert_eq!(s, StatusCode::OK); + let first_id = all["objectives"][0]["id"].as_str().unwrap().to_string(); + + let (s, single) = json_get(&app, &format!("/objectives/{first_id}")).await; + assert_eq!(s, StatusCode::OK, "{single}"); + assert_eq!(single["objective"]["id"], first_id.as_str()); +} + +#[tokio::test] +async fn test_objective_404_for_unknown() { + let app = build_test_app().await; + auth(&app, "ObjPlayer").await; + let (s, _) = json_get(&app, "/objectives/totally_fake_objective_xyz").await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_market_my_listings_initially_empty() { + let app = build_test_app().await; + auth(&app, "ListingPlayer").await; + + let (s, json) = json_get(&app, "/market/my-listings").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["total"], 0); + assert!(json["listings"].as_array().unwrap().is_empty()); +}