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,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
@@ -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(
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
_ => 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 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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user