bfd6de6896
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>
328 lines
10 KiB
Rust
328 lines
10 KiB
Rust
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,
|
|
})
|
|
}
|