Files
OpenFUT-Core/src/app.rs
T
funman300 0afce0dd59 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>
2026-06-25 15:33:09 -07:00

143 lines
5.3 KiB
Rust

use crate::{
config::Config,
db::Pool,
models::objective::{ObjectiveDefinition, ObjectiveType},
models::pack::PackDefinition,
models::sbc::SbcDefinition,
routes,
services::{
card_db::CardDb, market, objective::load_objective_definitions,
pack::load_pack_definitions, sbc::load_sbc_definitions,
},
};
use anyhow::Result;
use axum::{
routing::{get, post},
Router,
};
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
#[derive(Clone)]
pub struct AppState {
pub pool: Pool,
pub card_db: Arc<CardDb>,
pub pack_defs: Arc<Vec<PackDefinition>>,
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
pub sbc_defs: Arc<Vec<SbcDefinition>>,
}
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
let pack_defs = Arc::new(load_pack_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)?);
tracing::info!(
"Loaded {} cards, {} packs, {} objectives, {} SBCs",
card_db.cards.len(),
pack_defs.len(),
obj_defs.len(),
sbc_defs.len(),
);
let state = AppState {
pool: pool.clone(),
card_db: card_db.clone(),
pack_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))
.route("/profile", get(routes::profile::get_profile))
.route("/club", get(routes::club::get_club))
.route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection))
.route("/packs", get(routes::packs::get_packs))
.route("/packs/buy", post(routes::packs::post_buy_pack))
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
.route("/squad", get(routes::squad::get_squad))
.route("/squad", post(routes::squad::post_squad))
.route("/objectives", get(routes::objectives::get_objectives))
.route(
"/objectives/claim",
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))
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
.route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy))
.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);
Ok(router)
}