Phase 10: card upgrade system (chemistry styles, position change, training)
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:
funman300
2026-06-25 17:02:44 -07:00
parent a749fba93c
commit 19c7b9989d
15 changed files with 617 additions and 6 deletions
+22 -1
View File
@@ -2,6 +2,7 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid};
use crate::{
config::Config,
db::Pool,
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
models::event::EventDefinition,
models::objective::{ObjectiveDefinition, ObjectiveType},
models::pack::PackDefinition,
@@ -35,6 +36,7 @@ pub struct AppState {
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
pub sbc_defs: Arc<Vec<SbcDefinition>>,
pub event_defs: Arc<Vec<EventDefinition>>,
pub chem_styles: Arc<Vec<ChemistryStyle>>,
}
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 sbc_defs = Arc::new(load_sbc_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!(
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events",
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles",
card_db.cards.len(),
pack_defs.len(),
obj_defs.len(),
sbc_defs.len(),
event_defs.len(),
chem_styles.len(),
);
let state = AppState {
@@ -60,6 +64,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
obj_defs: obj_defs.clone(),
sbc_defs,
event_defs: event_defs.clone(),
chem_styles,
};
// 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",
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/history", get(routes::packs::get_pack_history))
.route("/packs/buy", post(routes::packs::post_buy_pack))
+3
View File
@@ -42,4 +42,7 @@ pub struct OwnedCard {
pub is_loan: bool,
pub loan_matches_remaining: Option<i64>,
pub acquired_at: String,
pub chemistry_style: String,
pub position_override: Option<String>,
pub training_bonus: i64,
}
+26
View File
@@ -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)?)
}
+2
View File
@@ -1,7 +1,9 @@
pub mod card;
pub mod chemistry_style;
pub mod club;
pub mod draft;
pub mod event;
pub mod season;
pub mod market;
pub mod match_result;
pub mod objective;
+11 -2
View File
@@ -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 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)
.fetch_all(&state.pool)
@@ -108,11 +108,19 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
.iter()
.filter_map(|o| {
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!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
"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,
})
})
@@ -133,7 +141,8 @@ pub async fn delete_owned_card(
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 \
"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_card_id)
+1
View File
@@ -15,3 +15,4 @@ pub mod sbc;
pub mod settings;
pub mod squad;
pub mod statistics;
pub mod upgrades;
+120
View File
@@ -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 13. 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,
})))
}
+2 -1
View File
@@ -162,7 +162,8 @@ pub async fn buy_listing(
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
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 = ?",
)
.bind(&req.owned_card_id)
+2
View File
@@ -2,6 +2,7 @@ pub mod card_db;
pub mod club;
pub mod draft;
pub mod event;
pub mod season;
pub mod market;
pub mod match_service;
pub mod objective;
@@ -11,3 +12,4 @@ pub mod sbc;
pub mod settings;
pub mod squad;
pub mod statistics;
pub mod upgrades;
+1 -1
View File
@@ -50,7 +50,7 @@ pub async fn submit_sbc(
let mut cards: Vec<CardDefinition> = Vec::new();
for owned_id in &req.owned_card_ids {
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(club_id)
+1 -1
View File
@@ -77,7 +77,7 @@ pub async fn validate_formation(
let mut gk_count = 0usize;
for sp in &starters {
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)
.fetch_optional(pool)
+125
View File
@@ -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 (13).
/// 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
}