Phase 10: card upgrade system (chemistry styles, position change, training)
CI / Build, lint & test (push) Failing after 1m37s
CI / Build, lint & test (push) Failing after 1m37s
- GET /chemistry-styles — lists 18 FUT-style chemistry styles with stat boost breakdowns - POST /collection/:id/chemistry-style — apply a style (Shadow, Hunter, Anchor, etc.) - POST /collection/:id/position — override a player's position for 500 coins - POST /collection/:id/training — add +1/+2/+3 OVR training bonus (max +3 total) - GET /collection now includes chemistry_style, position_override, training_bonus, effective_overall and effective_position fields per owned card - Migration 0007 adds three columns to owned_cards with safe defaults - 10 new integration tests — all 55 pass, clippy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "basic",
|
||||||
|
"name": "Basic",
|
||||||
|
"description": "Small boosts across all attributes",
|
||||||
|
"boosts": { "pace": 3, "shooting": 3, "passing": 3, "dribbling": 3, "defending": 3, "physical": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shadow",
|
||||||
|
"name": "Shadow",
|
||||||
|
"description": "Maximises pace and defending — ideal for full-backs",
|
||||||
|
"boosts": { "pace": 10, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anchor",
|
||||||
|
"name": "Anchor",
|
||||||
|
"description": "Boosts defending and physicality — built for centre-backs",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hunter",
|
||||||
|
"name": "Hunter",
|
||||||
|
"description": "Pace and shooting — the striker's best friend",
|
||||||
|
"boosts": { "pace": 10, "shooting": 10, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "catalyst",
|
||||||
|
"name": "Catalyst",
|
||||||
|
"description": "Pace and passing for box-to-box and wide midfielders",
|
||||||
|
"boosts": { "pace": 10, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "engine",
|
||||||
|
"name": "Engine",
|
||||||
|
"description": "Pace, passing and dribbling for creative midfielders",
|
||||||
|
"boosts": { "pace": 10, "shooting": 0, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hawk",
|
||||||
|
"name": "Hawk",
|
||||||
|
"description": "Balanced pace, shooting and physicality",
|
||||||
|
"boosts": { "pace": 5, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 0, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "marksman",
|
||||||
|
"name": "Marksman",
|
||||||
|
"description": "Shooting, dribbling and physicality for physical strikers",
|
||||||
|
"boosts": { "pace": 0, "shooting": 10, "passing": 0, "dribbling": 5, "defending": 0, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deadeye",
|
||||||
|
"name": "Deadeye",
|
||||||
|
"description": "Shooting and passing — classic number 10 style",
|
||||||
|
"boosts": { "pace": 0, "shooting": 10, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "artist",
|
||||||
|
"name": "Artist",
|
||||||
|
"description": "Passing and dribbling for technical playmakers",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 10, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sniper",
|
||||||
|
"name": "Sniper",
|
||||||
|
"description": "Shooting and dribbling for agile forwards",
|
||||||
|
"boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 5, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "finisher",
|
||||||
|
"name": "Finisher",
|
||||||
|
"description": "Shooting and dribbling — precision over power",
|
||||||
|
"boosts": { "pace": 0, "shooting": 8, "passing": 0, "dribbling": 7, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "guardian",
|
||||||
|
"name": "Guardian",
|
||||||
|
"description": "Dribbling and defending for defensive midfielders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 5, "defending": 10, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backbone",
|
||||||
|
"name": "Backbone",
|
||||||
|
"description": "Passing, defending and physicality for the defensive shield",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 0, "defending": 5, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sentinel",
|
||||||
|
"name": "Sentinel",
|
||||||
|
"description": "Defending and physicality for no-nonsense defenders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 5, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "powerhouse",
|
||||||
|
"name": "Powerhouse",
|
||||||
|
"description": "Passing and defending for dominant central midfielders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 10, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "maestro",
|
||||||
|
"name": "Maestro",
|
||||||
|
"description": "Shooting, passing and dribbling — the complete midfielder",
|
||||||
|
"boosts": { "pace": 0, "shooting": 5, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gladiator",
|
||||||
|
"name": "Gladiator",
|
||||||
|
"description": "Shooting and defending for all-action midfielders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 5, "physical": 0 }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Phase 10: card upgrade system
|
||||||
|
-- chemistry_style: applied cosmetic/stat modifier (default "basic")
|
||||||
|
-- position_override: player moved to a different position from their card default
|
||||||
|
-- training_bonus: extra OVR points from training cards (capped at +3)
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN chemistry_style TEXT NOT NULL DEFAULT 'basic';
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN position_override TEXT;
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN training_bonus INTEGER NOT NULL DEFAULT 0;
|
||||||
+22
-1
@@ -2,6 +2,7 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
db::Pool,
|
db::Pool,
|
||||||
|
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
|
||||||
models::event::EventDefinition,
|
models::event::EventDefinition,
|
||||||
models::objective::{ObjectiveDefinition, ObjectiveType},
|
models::objective::{ObjectiveDefinition, ObjectiveType},
|
||||||
models::pack::PackDefinition,
|
models::pack::PackDefinition,
|
||||||
@@ -35,6 +36,7 @@ pub struct AppState {
|
|||||||
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
||||||
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
||||||
pub event_defs: Arc<Vec<EventDefinition>>,
|
pub event_defs: Arc<Vec<EventDefinition>>,
|
||||||
|
pub chem_styles: Arc<Vec<ChemistryStyle>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||||
@@ -43,14 +45,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
||||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||||
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
|
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
|
||||||
|
let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events",
|
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles",
|
||||||
card_db.cards.len(),
|
card_db.cards.len(),
|
||||||
pack_defs.len(),
|
pack_defs.len(),
|
||||||
obj_defs.len(),
|
obj_defs.len(),
|
||||||
sbc_defs.len(),
|
sbc_defs.len(),
|
||||||
event_defs.len(),
|
event_defs.len(),
|
||||||
|
chem_styles.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -60,6 +64,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
obj_defs: obj_defs.clone(),
|
obj_defs: obj_defs.clone(),
|
||||||
sbc_defs,
|
sbc_defs,
|
||||||
event_defs: event_defs.clone(),
|
event_defs: event_defs.clone(),
|
||||||
|
chem_styles,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background task: refresh NPC market at startup and every 24 hours
|
// Background task: refresh NPC market at startup and every 24 hours
|
||||||
@@ -130,6 +135,22 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
"/collection/:owned_card_id",
|
"/collection/:owned_card_id",
|
||||||
delete(routes::cards::delete_owned_card),
|
delete(routes::cards::delete_owned_card),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id/chemistry-style",
|
||||||
|
post(routes::upgrades::post_apply_chemistry_style),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id/position",
|
||||||
|
post(routes::upgrades::post_change_position),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id/training",
|
||||||
|
post(routes::upgrades::post_apply_training),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/chemistry-styles",
|
||||||
|
get(routes::upgrades::get_chemistry_styles),
|
||||||
|
)
|
||||||
.route("/packs", get(routes::packs::get_packs))
|
.route("/packs", get(routes::packs::get_packs))
|
||||||
.route("/packs/history", get(routes::packs::get_pack_history))
|
.route("/packs/history", get(routes::packs::get_pack_history))
|
||||||
.route("/packs/buy", post(routes::packs::post_buy_pack))
|
.route("/packs/buy", post(routes::packs::post_buy_pack))
|
||||||
|
|||||||
@@ -42,4 +42,7 @@ pub struct OwnedCard {
|
|||||||
pub is_loan: bool,
|
pub is_loan: bool,
|
||||||
pub loan_matches_remaining: Option<i64>,
|
pub loan_matches_remaining: Option<i64>,
|
||||||
pub acquired_at: String,
|
pub acquired_at: String,
|
||||||
|
pub chemistry_style: String,
|
||||||
|
pub position_override: Option<String>,
|
||||||
|
pub training_bonus: i64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StatBoosts {
|
||||||
|
pub pace: i32,
|
||||||
|
pub shooting: i32,
|
||||||
|
pub passing: i32,
|
||||||
|
pub dribbling: i32,
|
||||||
|
pub defending: i32,
|
||||||
|
pub physical: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChemistryStyle {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub boosts: StatBoosts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_chemistry_styles(data_dir: &str) -> anyhow::Result<Vec<ChemistryStyle>> {
|
||||||
|
let path = format!("{data_dir}/chemistry_styles.json");
|
||||||
|
let raw = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read {path}: {e}"))?;
|
||||||
|
Ok(serde_json::from_str(&raw)?)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
|
pub mod chemistry_style;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_result;
|
pub mod match_result;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
|
|||||||
+11
-2
@@ -98,7 +98,7 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
|
|||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE club_id = ?"
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE club_id = ?"
|
||||||
)
|
)
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -108,11 +108,19 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|o| {
|
.filter_map(|o| {
|
||||||
state.card_db.get(&o.card_id).map(|def| {
|
state.card_db.get(&o.card_id).map(|def| {
|
||||||
|
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||||
|
let effective_position =
|
||||||
|
o.position_override.as_deref().unwrap_or(&def.position);
|
||||||
json!({
|
json!({
|
||||||
"owned_card_id": o.id,
|
"owned_card_id": o.id,
|
||||||
"is_loan": o.is_loan,
|
"is_loan": o.is_loan,
|
||||||
"loan_matches_remaining": o.loan_matches_remaining,
|
"loan_matches_remaining": o.loan_matches_remaining,
|
||||||
"acquired_at": o.acquired_at,
|
"acquired_at": o.acquired_at,
|
||||||
|
"chemistry_style": o.chemistry_style,
|
||||||
|
"position_override": o.position_override,
|
||||||
|
"training_bonus": o.training_bonus,
|
||||||
|
"effective_overall": effective_overall,
|
||||||
|
"effective_position": effective_position,
|
||||||
"card": def,
|
"card": def,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -133,7 +141,8 @@ pub async fn delete_owned_card(
|
|||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
chemistry_style, position_override, training_bonus \
|
||||||
FROM owned_cards WHERE id = ? AND club_id = ?",
|
FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||||
)
|
)
|
||||||
.bind(&owned_card_id)
|
.bind(&owned_card_id)
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ pub mod sbc;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
pub mod upgrades;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
services::{club as club_svc, profile as profile_svc, upgrades as upgrade_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GET /chemistry-styles — list all available styles with their stat boosts.
|
||||||
|
pub async fn get_chemistry_styles(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(json!({
|
||||||
|
"chemistry_styles": *state.chem_styles,
|
||||||
|
"total": state.chem_styles.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ApplyChemStyleRequest {
|
||||||
|
pub style_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /collection/:owned_card_id/chemistry-style
|
||||||
|
pub async fn post_apply_chemistry_style(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
Json(req): Json<ApplyChemStyleRequest>,
|
||||||
|
) -> 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 updated = upgrade_svc::apply_chemistry_style(
|
||||||
|
&state.pool,
|
||||||
|
&club.id,
|
||||||
|
&owned_card_id,
|
||||||
|
&req.style_id,
|
||||||
|
&state.chem_styles,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let style = state
|
||||||
|
.chem_styles
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == updated.chemistry_style);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"owned_card_id": updated.id,
|
||||||
|
"card_id": updated.card_id,
|
||||||
|
"chemistry_style": updated.chemistry_style,
|
||||||
|
"style_details": style,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChangePositionRequest {
|
||||||
|
pub position: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
||||||
|
pub async fn post_change_position(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
Json(req): Json<ChangePositionRequest>,
|
||||||
|
) -> 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 updated = upgrade_svc::change_position(
|
||||||
|
&state.pool,
|
||||||
|
&club.id,
|
||||||
|
&owned_card_id,
|
||||||
|
&req.position,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let card_def = state.card_db.get(&updated.card_id);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"owned_card_id": updated.id,
|
||||||
|
"card_id": updated.card_id,
|
||||||
|
"original_position": card_def.map(|c| &c.position),
|
||||||
|
"position_override": updated.position_override,
|
||||||
|
"cost_coins": upgrade_svc::POSITION_CHANGE_COST,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ApplyTrainingRequest {
|
||||||
|
/// OVR points to add. Must be 1–3. Total capped at +3.
|
||||||
|
pub boost: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
||||||
|
pub async fn post_apply_training(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
Json(req): Json<ApplyTrainingRequest>,
|
||||||
|
) -> 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 updated =
|
||||||
|
upgrade_svc::apply_training(&state.pool, &club.id, &owned_card_id, req.boost).await?;
|
||||||
|
|
||||||
|
let card_def = state.card_db.get(&updated.card_id);
|
||||||
|
let base_overall = card_def.map(|c| c.overall as i64).unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"owned_card_id": updated.id,
|
||||||
|
"card_id": updated.card_id,
|
||||||
|
"base_overall": base_overall,
|
||||||
|
"training_bonus": updated.training_bonus,
|
||||||
|
"effective_overall": base_overall + updated.training_bonus,
|
||||||
|
"max_bonus": upgrade_svc::MAX_TRAINING_BONUS,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -162,7 +162,8 @@ pub async fn buy_listing(
|
|||||||
|
|
||||||
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
|
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
|
||||||
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
chemistry_style, position_override, training_bonus \
|
||||||
FROM owned_cards WHERE id = ? AND club_id = ?",
|
FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||||
)
|
)
|
||||||
.bind(&req.owned_card_id)
|
.bind(&req.owned_card_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod card_db;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
@@ -11,3 +12,4 @@ pub mod sbc;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
pub mod upgrades;
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ pub async fn submit_sbc(
|
|||||||
let mut cards: Vec<CardDefinition> = Vec::new();
|
let mut cards: Vec<CardDefinition> = Vec::new();
|
||||||
for owned_id in &req.owned_card_ids {
|
for owned_id in &req.owned_card_ids {
|
||||||
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?"
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?"
|
||||||
)
|
)
|
||||||
.bind(owned_id)
|
.bind(owned_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ pub async fn validate_formation(
|
|||||||
let mut gk_count = 0usize;
|
let mut gk_count = 0usize;
|
||||||
for sp in &starters {
|
for sp in &starters {
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?",
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&sp.owned_card_id)
|
.bind(&sp.owned_card_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::card::OwnedCard,
|
||||||
|
models::chemistry_style::ChemistryStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OWNED_CARD_SELECT: &str =
|
||||||
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
chemistry_style, position_override, training_bonus \
|
||||||
|
FROM owned_cards";
|
||||||
|
|
||||||
|
pub const MAX_TRAINING_BONUS: i64 = 3;
|
||||||
|
|
||||||
|
/// Cost in coins to change a player's position.
|
||||||
|
pub const POSITION_CHANGE_COST: i64 = 500;
|
||||||
|
|
||||||
|
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
||||||
|
sqlx::query_as::<_, OwnedCard>(&format!(
|
||||||
|
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
||||||
|
))
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a chemistry style to an owned card.
|
||||||
|
///
|
||||||
|
/// The style is validated against the loaded definitions. The card is not
|
||||||
|
/// mutated in memory — callers should re-fetch if they need the updated state.
|
||||||
|
pub async fn apply_chemistry_style(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
owned_card_id: &str,
|
||||||
|
style_id: &str,
|
||||||
|
styles: &[ChemistryStyle],
|
||||||
|
) -> AppResult<OwnedCard> {
|
||||||
|
// Validate style exists
|
||||||
|
if !styles.iter().any(|s| s.id == style_id) {
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"chemistry style '{style_id}' not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE owned_cards SET chemistry_style = ? WHERE id = ?")
|
||||||
|
.bind(style_id)
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override a player's position. Costs POSITION_CHANGE_COST coins.
|
||||||
|
pub async fn change_position(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
owned_card_id: &str,
|
||||||
|
new_position: &str,
|
||||||
|
) -> AppResult<OwnedCard> {
|
||||||
|
let valid_positions = [
|
||||||
|
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
|
||||||
|
"CF", "ST",
|
||||||
|
];
|
||||||
|
if !valid_positions.contains(&new_position) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unknown position '{new_position}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await?;
|
||||||
|
|
||||||
|
// Deduct coins
|
||||||
|
crate::services::club::spend_coins(pool, club_id, POSITION_CHANGE_COST).await?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE owned_cards SET position_override = ? WHERE id = ?")
|
||||||
|
.bind(new_position)
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a training boost to an owned card.
|
||||||
|
///
|
||||||
|
/// `boost` is the number of OVR points to add (1–3).
|
||||||
|
/// The total training_bonus is capped at MAX_TRAINING_BONUS.
|
||||||
|
/// Training is free — the "cost" is consuming a training card item, which is
|
||||||
|
/// handled at the route layer (future: deduct a training_card from inventory).
|
||||||
|
pub async fn apply_training(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
owned_card_id: &str,
|
||||||
|
boost: i64,
|
||||||
|
) -> AppResult<OwnedCard> {
|
||||||
|
if !(1..=3).contains(&boost) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"training boost must be between 1 and 3".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let card = fetch_owned(pool, owned_card_id, club_id).await?;
|
||||||
|
|
||||||
|
let new_bonus = (card.training_bonus + boost).min(MAX_TRAINING_BONUS);
|
||||||
|
if new_bonus == card.training_bonus {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"card has already reached the maximum training bonus of +{MAX_TRAINING_BONUS}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE owned_cards SET training_bonus = ? WHERE id = ?")
|
||||||
|
.bind(new_bonus)
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await
|
||||||
|
}
|
||||||
@@ -984,3 +984,187 @@ async fn test_match_result_returns_season_info() {
|
|||||||
// season_end is None for non-final match
|
// season_end is None for non-final match
|
||||||
assert!(result["season_end"].is_null());
|
assert!(result["season_end"].is_null());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 10: Card Upgrades ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn get_first_owned_card_id(app: &axum::Router) -> String {
|
||||||
|
// Open the starter pack to get a card
|
||||||
|
let (_, packs) = json_get(app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||||||
|
let (_, coll) = json_get(app, "/collection").await;
|
||||||
|
coll["collection"][0]["owned_card_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chemistry_styles_list() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
let (s, json) = json_get(&app, "/chemistry-styles").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(json["chemistry_styles"].is_array());
|
||||||
|
assert!(json["total"].as_u64().unwrap() >= 18, "expect 18 built-in styles");
|
||||||
|
// "basic" must always be present
|
||||||
|
let styles = json["chemistry_styles"].as_array().unwrap();
|
||||||
|
assert!(styles.iter().any(|s| s["id"] == "basic"));
|
||||||
|
assert!(styles.iter().any(|s| s["id"] == "shadow"));
|
||||||
|
assert!(styles.iter().any(|s| s["id"] == "hunter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_apply_chemistry_style() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "ChemPlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
let (s, json) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/chemistry-style"),
|
||||||
|
serde_json::json!({ "style_id": "hunter" }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{json}");
|
||||||
|
assert_eq!(json["chemistry_style"], "hunter");
|
||||||
|
assert_eq!(json["style_details"]["name"], "Hunter");
|
||||||
|
assert!(json["style_details"]["boosts"]["pace"].as_i64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_apply_invalid_chemistry_style_returns_404() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "BadChemPlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
let (s, _) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/chemistry-style"),
|
||||||
|
serde_json::json!({ "style_id": "nonexistent_style" }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_collection_includes_upgrade_fields() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "UpgradeFieldPlayer").await;
|
||||||
|
get_first_owned_card_id(&app).await; // opens starter pack
|
||||||
|
|
||||||
|
let (s, coll) = json_get(&app, "/collection").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let card = &coll["collection"][0];
|
||||||
|
assert!(card["chemistry_style"].is_string());
|
||||||
|
assert!(card["training_bonus"].is_number());
|
||||||
|
assert!(card["effective_overall"].is_number());
|
||||||
|
assert!(card["effective_position"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_position_change_deducts_coins() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "PosChangePlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
let (_, club_before) = json_get(&app, "/club").await;
|
||||||
|
let coins_before = club_before["coins"].as_i64().unwrap();
|
||||||
|
|
||||||
|
let (s, json) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/position"),
|
||||||
|
serde_json::json!({ "position": "CM" }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{json}");
|
||||||
|
assert_eq!(json["position_override"], "CM");
|
||||||
|
assert_eq!(json["cost_coins"], 500);
|
||||||
|
|
||||||
|
let (_, club_after) = json_get(&app, "/club").await;
|
||||||
|
assert_eq!(
|
||||||
|
club_after["coins"].as_i64().unwrap(),
|
||||||
|
coins_before - 500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_position_change_invalid_position() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "BadPosPlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
let (s, _) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/position"),
|
||||||
|
serde_json::json!({ "position": "STRIKER" }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_training_boost_increases_effective_overall() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "TrainingPlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
let (s, json) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/training"),
|
||||||
|
serde_json::json!({ "boost": 2 }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{json}");
|
||||||
|
assert_eq!(json["training_bonus"], 2);
|
||||||
|
assert_eq!(
|
||||||
|
json["effective_overall"].as_i64().unwrap(),
|
||||||
|
json["base_overall"].as_i64().unwrap() + 2
|
||||||
|
);
|
||||||
|
assert_eq!(json["max_bonus"], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_training_capped_at_max() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "CapTrainPlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
// Apply +3 (the max)
|
||||||
|
let (s, _) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/training"),
|
||||||
|
serde_json::json!({ "boost": 3 }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
|
||||||
|
// Trying to add more should fail
|
||||||
|
let (s, json) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/training"),
|
||||||
|
serde_json::json!({ "boost": 1 }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST, "{json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_training_invalid_boost_value() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "BadTrainPlayer").await;
|
||||||
|
let owned_id = get_first_owned_card_id(&app).await;
|
||||||
|
|
||||||
|
let (s, _) = json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/collection/{owned_id}/training"),
|
||||||
|
serde_json::json!({ "boost": 5 }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_upgrades_on_nonexistent_card_return_404() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "OwnerAlpha").await;
|
||||||
|
|
||||||
|
// Use a made-up owned_card_id that doesn't belong to this club
|
||||||
|
let (s, _) = json_post(
|
||||||
|
&app,
|
||||||
|
"/collection/nonexistent-owned-card-id/chemistry-style",
|
||||||
|
serde_json::json!({ "style_id": "basic" }),
|
||||||
|
).await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user