352ad11bc4
Production real-profile content is loaded via an explicit path, NOT the dev-only OPENFUT_DEV_CONTENT_GAMES gate: - Config.content_packs from env OPENFUT_CONTENT_PACKS (comma-sep file paths). - CardDb::load_pack(path): merge an explicit CardDefinition[] production pack. - app::build loads dev packs then production packs. Preflight (app::build, always on): every owned_cards.card_id MUST resolve to a loaded CardDefinition. A real profile with owned players but even ONE missing definition fails LOUDLY instead of silently serving an empty /collection; an empty owned_cards table (fresh DB / tests) passes. 2 preflight integration tests (missing def fails, loaded def passes). clippy -D warnings clean; full suite 151 tests green.
322 lines
12 KiB
Rust
322 lines
12 KiB
Rust
use crate::middleware::{limit_concurrency, MakeRequestUuid};
|
|
use crate::{
|
|
config::Config,
|
|
db::Pool,
|
|
models::achievement::AchievementDefinition,
|
|
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
|
|
models::event::EventDefinition,
|
|
models::objective::{ObjectiveDefinition, ObjectiveType},
|
|
models::pack::PackDefinition,
|
|
models::sbc::SbcDefinition,
|
|
routes,
|
|
services::{
|
|
achievement::load_achievement_definitions, card_db::CardDb, event::load_event_definitions,
|
|
market, objective::load_objective_definitions, pack::load_pack_definitions,
|
|
sbc::load_sbc_definitions,
|
|
},
|
|
};
|
|
use anyhow::Result;
|
|
use axum::{
|
|
extract::DefaultBodyLimit,
|
|
middleware,
|
|
routing::{delete, get, patch, post, put},
|
|
Router,
|
|
};
|
|
use std::sync::Arc;
|
|
use tower_http::{
|
|
cors::CorsLayer,
|
|
request_id::{PropagateRequestIdLayer, SetRequestIdLayer},
|
|
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 event_defs: Arc<Vec<EventDefinition>>,
|
|
pub chem_styles: Arc<Vec<ChemistryStyle>>,
|
|
pub achievement_defs: Arc<Vec<AchievementDefinition>>,
|
|
}
|
|
|
|
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|
let mut card_db = CardDb::load(&cfg.data_dir)?;
|
|
for game in &cfg.dev_content_games {
|
|
card_db.load_game_dev(&cfg.data_dir, game)?;
|
|
}
|
|
for pack in &cfg.content_packs {
|
|
card_db.load_pack(pack)?;
|
|
}
|
|
let card_db = Arc::new(card_db);
|
|
|
|
// Content preflight: every owned card MUST reference a loaded CardDefinition.
|
|
// A real profile with owned players but missing definitions fails LOUDLY here
|
|
// rather than silently serving an empty /collection. Empty owned_cards (fresh
|
|
// DB, tests) passes. A SINGLE missing definition is caught, not only the
|
|
// zero-loaded case.
|
|
{
|
|
let referenced: Vec<String> =
|
|
sqlx::query_scalar("SELECT DISTINCT card_id FROM owned_cards")
|
|
.fetch_all(&pool)
|
|
.await?;
|
|
let missing: Vec<String> = referenced
|
|
.into_iter()
|
|
.filter(|id| card_db.get(id).is_none())
|
|
.collect();
|
|
if !missing.is_empty() {
|
|
let sample: Vec<&String> = missing.iter().take(5).collect();
|
|
anyhow::bail!(
|
|
"content preflight failed: {} owned card(s) reference CardDefinitionId(s) not loaded (e.g. {:?}). Load the production content pack via OPENFUT_CONTENT_PACKS.",
|
|
missing.len(),
|
|
sample
|
|
);
|
|
}
|
|
}
|
|
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)?);
|
|
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
|
|
let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?);
|
|
let achievement_defs = Arc::new(load_achievement_definitions(&cfg.data_dir)?);
|
|
|
|
tracing::info!(
|
|
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles, {} achievements",
|
|
card_db.cards.len(),
|
|
pack_defs.len(),
|
|
obj_defs.len(),
|
|
sbc_defs.len(),
|
|
event_defs.len(),
|
|
chem_styles.len(),
|
|
achievement_defs.len(),
|
|
);
|
|
|
|
let state = AppState {
|
|
pool: pool.clone(),
|
|
card_db: card_db.clone(),
|
|
pack_defs,
|
|
obj_defs: obj_defs.clone(),
|
|
sbc_defs,
|
|
event_defs: event_defs.clone(),
|
|
chem_styles,
|
|
achievement_defs,
|
|
};
|
|
|
|
// Background task: refresh NPC market at startup and every 24 hours
|
|
{
|
|
let pool = pool.clone();
|
|
let card_db = card_db.clone();
|
|
let event_defs_bg = event_defs.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = market::refresh_npc_listings(&pool, &card_db, &event_defs_bg).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, &event_defs_bg).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("/formations", get(routes::health::get_formations))
|
|
.route("/auth/local", post(routes::auth::post_auth_local))
|
|
.route("/auth/status", get(routes::auth::get_auth_status))
|
|
.route("/auth/reset", post(routes::auth::post_auth_reset))
|
|
.route("/profile", get(routes::profile::get_profile))
|
|
.route("/club", get(routes::club::get_club))
|
|
.route("/club", put(routes::club::put_club))
|
|
.route("/club/checkin", get(routes::club::get_checkin_status))
|
|
.route("/club/checkin", post(routes::club::post_checkin))
|
|
.route("/club/milestones", get(routes::club::get_milestones))
|
|
.route("/cards", get(routes::cards::get_cards))
|
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
|
.route("/collection", get(routes::cards::get_collection))
|
|
.route(
|
|
"/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/store", get(routes::packs::get_pack_store))
|
|
.route("/packs/history", get(routes::packs::get_pack_history))
|
|
.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("/squad/ext", get(routes::squad::get_squad_ext))
|
|
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
|
.route("/squads", get(routes::squad::get_squads))
|
|
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
|
.route("/objectives", get(routes::objectives::get_objectives))
|
|
.route(
|
|
"/objectives/:objective_id",
|
|
get(routes::objectives::get_objective),
|
|
)
|
|
.route(
|
|
"/objectives/claim",
|
|
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/opponent", get(routes::matches::get_opponent))
|
|
.route("/matches/result", post(routes::matches::post_match_result))
|
|
.route("/sbc", get(routes::sbc::get_sbcs))
|
|
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
|
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
|
.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/trade-history",
|
|
get(routes::market::get_trade_history),
|
|
)
|
|
.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/history",
|
|
get(routes::statistics::get_statistics_history),
|
|
)
|
|
.route("/settings", get(routes::settings::get_settings))
|
|
.route("/settings", put(routes::settings::put_settings))
|
|
.route("/division", get(routes::division::get_division))
|
|
.route(
|
|
"/division/history",
|
|
get(routes::division::get_division_history),
|
|
)
|
|
.route(
|
|
"/division/leaderboard",
|
|
get(routes::division::get_division_leaderboard),
|
|
)
|
|
.route("/achievements", get(routes::achievements::get_achievements))
|
|
.route(
|
|
"/notifications",
|
|
get(routes::notifications::get_notifications),
|
|
)
|
|
.route(
|
|
"/notifications/read-all",
|
|
post(routes::notifications::mark_all_notifications_read),
|
|
)
|
|
.route(
|
|
"/notifications/:id/read",
|
|
patch(routes::notifications::mark_notification_read),
|
|
)
|
|
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
|
|
.route(
|
|
"/fut-champs/start",
|
|
post(routes::fut_champs::post_start_fut_champs),
|
|
)
|
|
.route(
|
|
"/fut-champs/history",
|
|
get(routes::fut_champs::get_champs_history),
|
|
)
|
|
.route(
|
|
"/fut-champs/:session_id/result",
|
|
post(routes::fut_champs::post_champs_result),
|
|
)
|
|
.route(
|
|
"/fut-champs/:session_id/claim",
|
|
post(routes::fut_champs::post_claim_champs_rewards),
|
|
)
|
|
.route(
|
|
"/rivals/claim-weekly",
|
|
post(routes::fut_champs::post_claim_rivals_reward),
|
|
)
|
|
.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/:event_id", get(routes::events::get_event))
|
|
.route(
|
|
"/events/:event_id/activate",
|
|
post(routes::events::post_activate_event),
|
|
)
|
|
.route(
|
|
"/events/:event_id/deactivate",
|
|
post(routes::events::post_deactivate_event),
|
|
)
|
|
// Layers run outermost-first (last added = outermost).
|
|
// CORS → body limit → concurrency limit → request ID → trace
|
|
.layer(PropagateRequestIdLayer::x_request_id())
|
|
.layer(TraceLayer::new_for_http())
|
|
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
|
|
.layer(middleware::from_fn(limit_concurrency))
|
|
.layer(DefaultBodyLimit::max(256 * 1024))
|
|
.layer(CorsLayer::permissive())
|
|
.with_state(state);
|
|
|
|
Ok(router)
|
|
}
|