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
+16
View File
@@ -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
);
+24
View File
@@ -125,6 +125,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/cards", get(routes::cards::get_cards)) .route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card)) .route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection)) .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", get(routes::packs::get_packs))
.route("/packs/buy", post(routes::packs::post_buy_pack)) .route("/packs/buy", post(routes::packs::post_buy_pack))
.route("/packs/open/:pack_id", post(routes::packs::post_open_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<Router> {
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
.route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/squads/:squad_id", delete(routes::squad::delete_squad))
.route("/objectives", get(routes::objectives::get_objectives)) .route("/objectives", get(routes::objectives::get_objectives))
.route("/objectives/:objective_id", get(routes::objectives::get_objective))
.route( .route(
"/objectives/claim", "/objectives/claim",
post(routes::objectives::post_claim_objective), 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", get(routes::matches::get_matches))
.route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/opponent", get(routes::matches::get_opponent))
.route("/matches/result", post(routes::matches::post_match_result)) .route("/matches/result", post(routes::matches::post_match_result))
@@ -148,6 +157,11 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/market/buy", post(routes::market::post_market_buy)) .route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell)) .route("/market/sell", post(routes::market::post_market_sell))
.route("/market/refresh", post(routes::market::post_market_refresh)) .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", get(routes::statistics::get_statistics))
.route( .route(
"/statistics/history", "/statistics/history",
@@ -156,6 +170,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", get(routes::settings::get_settings)) .route("/settings", get(routes::settings::get_settings))
.route("/settings", put(routes::settings::put_settings)) .route("/settings", put(routes::settings::put_settings))
.route("/draft/squad", get(routes::draft::get_draft_squad)) .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", get(routes::events::get_events))
.route("/events/:event_id", get(routes::events::get_event)) .route("/events/:event_id", get(routes::events::get_event))
.route( .route(
+26
View File
@@ -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<String> — position labels in pick order
pub pick_order: String,
/// JSON-encoded Vec<String> — card_ids picked so far
pub picks: String,
/// JSON-encoded Vec<String> — card_ids offered for the current slot (None if complete/abandoned)
pub current_candidates: Option<String>,
pub status: String,
pub reward_coins: i64,
pub reward_pack_id: Option<String>,
pub squad_rating: i64,
pub started_at: String,
pub completed_at: Option<String>,
}
/// 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",
];
+1
View File
@@ -1,5 +1,6 @@
pub mod card; pub mod card;
pub mod club; pub mod club;
pub mod draft;
pub mod event; pub mod event;
pub mod market; pub mod market;
pub mod match_result; pub mod match_result;
+48
View File
@@ -12,6 +12,15 @@ use crate::{
services::{club as club_svc, profile as profile_svc}, 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)] #[derive(Debug, Deserialize)]
pub struct CardQuery { pub struct CardQuery {
pub rarity: Option<String>, pub rarity: Option<String>,
@@ -114,3 +123,42 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
json!({ "collection": with_defs, "total": with_defs.len() }), json!({ "collection": with_defs, "total": with_defs.len() }),
)) ))
} }
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
pub async fn delete_owned_card(
State(state): State<AppState>,
Path(owned_card_id): Path<String>,
) -> AppResult<Json<Value>> {
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,
})))
}
+71 -6
View File
@@ -1,27 +1,28 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Path, Query, State},
Json, Json,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; 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)] #[derive(Deserialize)]
pub struct DraftQuery { pub struct DraftQuery {
pub difficulty: Option<String>, pub difficulty: Option<String>,
} }
/// Returns a randomly generated draft squad from the card pool. /// Legacy endpoint: returns a pre-built random draft squad (no session state).
/// Difficulty controls minimum card overall. The front-end is responsible
/// for presenting per-position pick choices; this endpoint provides the pool.
pub async fn get_draft_squad( pub async fn get_draft_squad(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<DraftQuery>, Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let difficulty = query.difficulty.as_deref().unwrap_or("beginner"); let difficulty = query.difficulty.as_deref().unwrap_or("beginner");
let generated = match_service::generate_opponent(&state.card_db, difficulty); let generated = match_service::generate_opponent(&state.card_db, difficulty);
Ok(Json(json!({ Ok(Json(json!({
"mode": "draft", "mode": "draft",
"difficulty": difficulty, "difficulty": difficulty,
@@ -30,3 +31,67 @@ pub async fn get_draft_squad(
"cards": generated["cards"], "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<AppState>,
Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> {
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<AppState>,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
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<AppState>,
Path(session_id): Path<String>,
Json(req): Json<PickRequest>,
) -> AppResult<Json<Value>> {
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<AppState>,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
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))
}
+21 -1
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Path, Query, State},
Json, Json,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -12,6 +12,7 @@ use crate::{
services::{club as club_svc, market as market_svc, profile as profile_svc}, services::{club as club_svc, market as market_svc, profile as profile_svc},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct MarketQuery { pub struct MarketQuery {
pub min_overall: Option<u8>, pub min_overall: Option<u8>,
@@ -73,3 +74,22 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
market_svc::refresh_npc_listings(&state.pool, &state.card_db, &state.event_defs).await?; market_svc::refresh_npc_listings(&state.pool, &state.card_db, &state.event_defs).await?;
Ok(Json(json!({ "listings_generated": count }))) Ok(Json(json!({ "listings_generated": count })))
} }
/// Return all active market listings posted by the current player's club.
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
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<AppState>,
Path(listing_id): Path<String>,
) -> AppResult<Json<Value>> {
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 })))
}
+36 -2
View File
@@ -1,10 +1,13 @@
use axum::{extract::State, Json}; use axum::{
extract::{Path, State},
Json,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
services::{club as club_svc, objective as obj_svc, profile as profile_svc}, 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<AppState>) -> AppResult<Json<Val
Ok(Json(json!({ "objectives": objectives }))) Ok(Json(json!({ "objectives": objectives })))
} }
pub async fn get_objective(
State(state): State<AppState>,
Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> {
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<AppState>,
Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> {
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)] #[derive(Deserialize)]
pub struct ClaimRequest { pub struct ClaimRequest {
pub objective_id: String, pub objective_id: String,
+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, _ => 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 card_db;
pub mod club; pub mod club;
pub mod draft;
pub mod event; pub mod event;
pub mod market; pub mod market;
pub mod match_service; pub mod match_service;
+197
View File
@@ -633,3 +633,200 @@ async fn test_chemistry_response_has_breakdown_structure() {
assert_eq!(chem["max"], 100); assert_eq!(chem["max"], 100);
assert!(chem["player_chemistries"].is_array()); 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());
}