Phase 8 (Core): stateful draft, quick-sell, objectives by ID, market listings
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:
funman300
2026-06-25 16:41:55 -07:00
parent 8fa125cfd6
commit bfd6de6896
12 changed files with 828 additions and 9 deletions
+327
View File
@@ -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,
})
}
+60
View File
@@ -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
View File
@@ -1,5 +1,6 @@
pub mod card_db;
pub mod club;
pub mod draft;
pub mod event;
pub mod market;
pub mod match_service;