Phase 8 (Core): stateful draft, quick-sell, objectives by ID, market listings
CI / Build, lint & test (push) Failing after 1m21s
CI / Build, lint & test (push) Failing after 1m21s
Draft v2 — stateful FUT-style pick sessions:
- POST /draft/start?difficulty=<> — creates session, returns 5
candidates for GK slot (position order: GK RB CB CB LB CDM CM CAM RW ST LW)
- POST /draft/sessions/:id/pick { card_id } — validates candidate,
advances to next position; on last pick grants coins+pack reward
(avg OVR ≥84 → gold pack + 2000 coins, ≥78 → silver + 1000, else 400)
- GET /draft/sessions/:id — session state with per-pick cards
- POST /draft/sessions/:id/abandon — cancel without reward
- Migration 0005_draft_sessions.sql
Quick-sell:
- DELETE /collection/:owned_card_id — removes card, credits coins based
on overall (85+ → 1500, 80-84 → 900, 75-79 → 600, 65-74 → 300, <65 → 150)
Objectives:
- GET /objectives/:id — single objective with progress
- POST /objectives/:id/claim — claim reward by URL param (complement to
existing POST /objectives/claim body-param endpoint)
Market:
- GET /market/my-listings — active listings posted by current club
- DELETE /market/listings/:id — cancel a listing, returns card to collection
Tests: 9 new integration tests (37 total, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||
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<String>, i64) {
|
||||
if picks.is_empty() {
|
||||
return (0, None, 0);
|
||||
}
|
||||
let overalls: Vec<i64> = 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::<i64>() / 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<serde_json::Value> {
|
||||
let min_overall = difficulty_min_overall(difficulty);
|
||||
|
||||
let pick_order: Vec<String> = 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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<String> = 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<String> = serde_json::from_str(&session.pick_order)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad pick_order json: {e}")))?;
|
||||
let mut picks: Vec<String> = 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<serde_json::Value> {
|
||||
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<DraftSession> {
|
||||
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<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
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let picks_with_defs: Vec<serde_json::Value> = 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<serde_json::Value> = 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,
|
||||
})
|
||||
}
|
||||
@@ -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<Vec<MarketListingWithCard>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod card_db;
|
||||
pub mod club;
|
||||
pub mod draft;
|
||||
pub mod event;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
|
||||
Reference in New Issue
Block a user