feat: Phase 2 — game feel complete
Chemistry & squads: - Chemistry calculation on GET /squad (club/league/nation links, max 100) - Formation validation on POST /squad (exactly 11 starters, exactly 1 GK) Objectives: - Weekly objectives JSON (4 objectives: warrior, goals, dedicated, SBC) - Daily objectives auto-reset at midnight UTC (background task) SBC validation expanded: - max_overall per-player enforcement - required_clubs validation - min_players_from_same_nation validation - min_players_from_same_club validation Market: - GET /market?min_overall=X&position=Y filtering - Expiry cleanup runs before every NPC refresh - NPC market auto-refresh every 24h (background task, runs at startup) Matches: - GET /matches/opponent?difficulty=beginner|professional|world_class|legendary generates a random AI opponent squad from the card pool Statistics: - win_streak and best_win_streak tracking (migration 0002) - GET /statistics/history?limit=N — last N matches with summary stats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+68
-8
@@ -1,11 +1,13 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
db::Pool,
|
||||
models::{objective::ObjectiveDefinition, pack::PackDefinition, sbc::SbcDefinition},
|
||||
models::objective::{ObjectiveDefinition, ObjectiveType},
|
||||
models::pack::PackDefinition,
|
||||
models::sbc::SbcDefinition,
|
||||
routes,
|
||||
services::{
|
||||
card_db::CardDb, objective::load_objective_definitions, pack::load_pack_definitions,
|
||||
sbc::load_sbc_definitions,
|
||||
card_db::CardDb, market, objective::load_objective_definitions,
|
||||
pack::load_pack_definitions, sbc::load_sbc_definitions,
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -32,20 +34,73 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||
|
||||
tracing::info!(
|
||||
"Loaded {} packs, {} objectives, {} SBCs",
|
||||
"Loaded {} cards, {} packs, {} objectives, {} SBCs",
|
||||
card_db.cards.len(),
|
||||
pack_defs.len(),
|
||||
obj_defs.len(),
|
||||
sbc_defs.len()
|
||||
sbc_defs.len(),
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
card_db,
|
||||
pool: pool.clone(),
|
||||
card_db: card_db.clone(),
|
||||
pack_defs,
|
||||
obj_defs,
|
||||
obj_defs: obj_defs.clone(),
|
||||
sbc_defs,
|
||||
};
|
||||
|
||||
// Background task: refresh NPC market at startup and every 24 hours
|
||||
{
|
||||
let pool = pool.clone();
|
||||
let card_db = card_db.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await {
|
||||
tracing::warn!("initial market refresh failed: {e}");
|
||||
}
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await;
|
||||
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await {
|
||||
tracing::warn!("market refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: reset daily objective progress at midnight UTC
|
||||
{
|
||||
let pool = pool.clone();
|
||||
let daily_ids: Vec<String> = obj_defs
|
||||
.iter()
|
||||
.filter(|d| matches!(d.objective_type, ObjectiveType::Daily))
|
||||
.map(|d| d.id.clone())
|
||||
.collect();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let now = chrono::Utc::now();
|
||||
let tomorrow = now.date_naive() + chrono::Days::new(1);
|
||||
let midnight = tomorrow
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|dt| dt.and_utc())
|
||||
.unwrap_or_else(|| now + chrono::Duration::hours(24));
|
||||
let secs = (midnight - now).num_seconds().max(1) as u64;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await;
|
||||
|
||||
for id in &daily_ids {
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE objective_progress SET current = 0, completed = 0, claimed = 0 WHERE objective_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("daily reset failed for {id}: {e}");
|
||||
}
|
||||
}
|
||||
tracing::info!("Daily objectives reset ({} objectives)", daily_ids.len());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let router = Router::new()
|
||||
.route("/health", get(routes::health::get_health))
|
||||
.route("/auth/local", post(routes::auth::post_auth_local))
|
||||
@@ -65,6 +120,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
post(routes::objectives::post_claim_objective),
|
||||
)
|
||||
.route("/matches", get(routes::matches::get_matches))
|
||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||
.route("/matches/result", post(routes::matches::post_match_result))
|
||||
.route("/sbc", get(routes::sbc::get_sbcs))
|
||||
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
||||
@@ -74,6 +130,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/market/sell", post(routes::market::post_market_sell))
|
||||
.route("/market/refresh", post(routes::market::post_market_refresh))
|
||||
.route("/statistics", get(routes::statistics::get_statistics))
|
||||
.route(
|
||||
"/statistics/history",
|
||||
get(routes::statistics::get_statistics_history),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
Reference in New Issue
Block a user