diff --git a/data/events/seasonal_events.json b/data/events/seasonal_events.json new file mode 100644 index 0000000..8a1c60d --- /dev/null +++ b/data/events/seasonal_events.json @@ -0,0 +1,44 @@ +[ + { + "id": "spring_festival", + "name": "Spring Festival", + "description": "Celebrate the season! Special Hero players appear in the market. Activate manually to begin.", + "event_type": "seasonal", + "starts_at": null, + "expires_at": null, + "is_manual": true, + "effects": { + "pack_weight_boosts": [], + "bonus_market_cards": [ + "card_hero_001", + "card_hero_002", + "card_hero_003" + ], + "unlocks_sbc_ids": [], + "unlocks_objective_ids": [] + } + }, + { + "id": "icon_weekend", + "name": "Icon Weekend", + "description": "Legendary Icons are available on the market. Activate manually to begin.", + "event_type": "special", + "starts_at": null, + "expires_at": null, + "is_manual": true, + "effects": { + "pack_weight_boosts": [ + { "rarity": "icon", "multiplier": 5.0 } + ], + "bonus_market_cards": [ + "card_icon_001", + "card_icon_002", + "card_icon_003", + "card_icon_004", + "card_icon_005" + ], + "unlocks_sbc_ids": [], + "unlocks_objective_ids": [] + } + } +] diff --git a/data/events/totw_week1.json b/data/events/totw_week1.json new file mode 100644 index 0000000..a1f2d0d --- /dev/null +++ b/data/events/totw_week1.json @@ -0,0 +1,25 @@ +[ + { + "id": "totw_week1", + "name": "Team of the Week 1", + "description": "This week's TOTW is live! TOTW players appear in the transfer market at a premium and pack weights are boosted.", + "event_type": "totw", + "starts_at": "2024-01-01T00:00:00Z", + "expires_at": "2030-12-31T23:59:59Z", + "is_manual": false, + "effects": { + "pack_weight_boosts": [ + { "rarity": "totw", "multiplier": 3.0 } + ], + "bonus_market_cards": [ + "card_totw_001", + "card_totw_002", + "card_totw_003", + "card_totw_004", + "card_totw_005" + ], + "unlocks_sbc_ids": [], + "unlocks_objective_ids": [] + } + } +] diff --git a/migrations/0004_events.sql b/migrations/0004_events.sql new file mode 100644 index 0000000..35f9fa8 --- /dev/null +++ b/migrations/0004_events.sql @@ -0,0 +1,8 @@ +-- Manual override table for event activation/deactivation. +-- is_active_override: NULL = use date range, 1 = force active, 0 = force inactive +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + is_active_override INTEGER, + activated_at TEXT, + deactivated_at TEXT +); diff --git a/src/app.rs b/src/app.rs index 58ca30d..3c8d84d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,13 +2,15 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid}; use crate::{ config::Config, db::Pool, + models::event::EventDefinition, 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, + card_db::CardDb, event::load_event_definitions, market, + objective::load_objective_definitions, pack::load_pack_definitions, + sbc::load_sbc_definitions, }, }; use anyhow::Result; @@ -32,6 +34,7 @@ pub struct AppState { pub pack_defs: Arc>, pub obj_defs: Arc>, pub sbc_defs: Arc>, + pub event_defs: Arc>, } pub async fn build(pool: Pool, cfg: Config) -> Result { @@ -39,13 +42,15 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { 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)?); tracing::info!( - "Loaded {} cards, {} packs, {} objectives, {} SBCs", + "Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events", card_db.cards.len(), pack_defs.len(), obj_defs.len(), sbc_defs.len(), + event_defs.len(), ); let state = AppState { @@ -54,19 +59,22 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { pack_defs, obj_defs: obj_defs.clone(), sbc_defs, + event_defs: event_defs.clone(), }; // 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).await { + 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).await { + if let Err(e) = market::refresh_npc_listings(&pool, &card_db, &event_defs_bg).await + { tracing::warn!("market refresh failed: {e}"); } } @@ -148,6 +156,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) .route("/draft/squad", get(routes::draft::get_draft_squad)) + .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()) diff --git a/src/models/event.rs b/src/models/event.rs new file mode 100644 index 0000000..61cab15 --- /dev/null +++ b/src/models/event.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventType { + Totw, + Seasonal, + Special, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackWeightBoost { + pub rarity: String, + pub multiplier: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventEffects { + pub pack_weight_boosts: Vec, + pub bonus_market_cards: Vec, + pub unlocks_sbc_ids: Vec, + pub unlocks_objective_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventDefinition { + pub id: String, + pub name: String, + pub description: String, + pub event_type: EventType, + pub starts_at: Option, + pub expires_at: Option, + pub is_manual: bool, + pub effects: EventEffects, +} + +/// Event definition bundled with its current runtime active status. +#[derive(Debug, Clone, Serialize)] +pub struct EventWithStatus { + #[serde(flatten)] + pub definition: EventDefinition, + pub is_active: bool, + pub activated_at: Option, + pub deactivated_at: Option, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index ecc804b..64e4747 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod card; pub mod club; +pub mod event; pub mod market; pub mod match_result; pub mod objective; diff --git a/src/routes/events.rs b/src/routes/events.rs new file mode 100644 index 0000000..7d083f1 --- /dev/null +++ b/src/routes/events.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::{AppError, AppResult}, + services::event as event_svc, +}; + +pub async fn get_events(State(state): State) -> AppResult> { + let events = event_svc::get_all_with_status(&state.pool, &state.event_defs).await?; + let active_count = events.iter().filter(|e| e.is_active).count(); + Ok(Json(json!({ + "events": events, + "total": events.len(), + "active_count": active_count, + }))) +} + +pub async fn get_event( + State(state): State, + Path(event_id): Path, +) -> AppResult> { + let def = state + .event_defs + .iter() + .find(|d| d.id == event_id) + .ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?; + + let is_active = event_svc::is_event_active(&state.pool, def).await?; + Ok(Json(json!({ + "event": def, + "is_active": is_active, + }))) +} + +pub async fn post_activate_event( + State(state): State, + Path(event_id): Path, +) -> AppResult> { + event_svc::activate_event(&state.pool, &event_id, &state.event_defs).await?; + Ok(Json(json!({ "activated": event_id, "is_active": true }))) +} + +pub async fn post_deactivate_event( + State(state): State, + Path(event_id): Path, +) -> AppResult> { + event_svc::deactivate_event(&state.pool, &event_id, &state.event_defs).await?; + Ok(Json(json!({ "deactivated": event_id, "is_active": false }))) +} diff --git a/src/routes/market.rs b/src/routes/market.rs index 4e9e553..0687054 100644 --- a/src/routes/market.rs +++ b/src/routes/market.rs @@ -69,6 +69,7 @@ pub async fn post_market_sell( } pub async fn post_market_refresh(State(state): State) -> AppResult> { - let count = market_svc::refresh_npc_listings(&state.pool, &state.card_db).await?; + let count = + market_svc::refresh_npc_listings(&state.pool, &state.card_db, &state.event_defs).await?; Ok(Json(json!({ "listings_generated": count }))) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 3adb35b..8c720aa 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod cards; pub mod club; pub mod draft; +pub mod events; pub mod health; pub mod market; pub mod matches; diff --git a/src/services/event.rs b/src/services/event.rs new file mode 100644 index 0000000..a2eb9b0 --- /dev/null +++ b/src/services/event.rs @@ -0,0 +1,172 @@ +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::event::{EventDefinition, EventWithStatus}, +}; +use anyhow::Context; +use std::{collections::HashMap, path::Path}; + +pub fn load_event_definitions(data_dir: &str) -> anyhow::Result> { + let dir = Path::new(data_dir).join("events"); + let mut defs = Vec::new(); + if !dir.exists() { + return Ok(defs); + } + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + let content = + std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; + defs.extend(batch); + } + } + Ok(defs) +} + +fn compute_is_active(def: &EventDefinition, override_val: Option) -> bool { + match override_val { + Some(1) => return true, + Some(0) => return false, + _ => {} + } + if def.is_manual { + return false; + } + let now = chrono::Utc::now(); + let after_start = def + .starts_at + .as_ref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .is_none_or(|dt| now >= dt); + let before_end = def + .expires_at + .as_ref() + .and_then(|e| chrono::DateTime::parse_from_rfc3339(e).ok()) + .is_none_or(|dt| now <= dt); + after_start && before_end +} + +async fn fetch_overrides(pool: &Pool) -> AppResult>> { + let rows: Vec<(String, Option)> = + sqlx::query_as("SELECT id, is_active_override FROM events") + .fetch_all(pool) + .await?; + Ok(rows.into_iter().collect()) +} + +pub async fn is_event_active(pool: &Pool, def: &EventDefinition) -> AppResult { + let row: Option<(Option,)> = + sqlx::query_as("SELECT is_active_override FROM events WHERE id = ?") + .bind(&def.id) + .fetch_optional(pool) + .await?; + let override_val = row.and_then(|(v,)| v); + Ok(compute_is_active(def, override_val)) +} + +pub async fn get_active_events( + pool: &Pool, + defs: &[EventDefinition], +) -> AppResult> { + let overrides = fetch_overrides(pool).await?; + Ok(defs + .iter() + .filter(|def| { + let ov = overrides.get(&def.id).copied().flatten(); + compute_is_active(def, ov) + }) + .cloned() + .collect()) +} + +type EventRow = (String, Option, Option, Option); +type EventOverrideMap = HashMap, Option, Option)>; + +pub async fn get_all_with_status( + pool: &Pool, + defs: &[EventDefinition], +) -> AppResult> { + let rows: Vec = + sqlx::query_as("SELECT id, is_active_override, activated_at, deactivated_at FROM events") + .fetch_all(pool) + .await?; + + let db_map: EventOverrideMap = rows + .into_iter() + .map(|(id, ov, at, dt)| (id, (ov, at, dt))) + .collect(); + + let result = defs + .iter() + .map(|def| { + let (override_val, activated_at, deactivated_at) = db_map + .get(&def.id) + .map(|(ov, at, dt)| (*ov, at.clone(), dt.clone())) + .unwrap_or((None, None, None)); + + EventWithStatus { + is_active: compute_is_active(def, override_val), + definition: def.clone(), + activated_at, + deactivated_at, + } + }) + .collect(); + + Ok(result) +} + +pub async fn activate_event( + pool: &Pool, + event_id: &str, + defs: &[EventDefinition], +) -> AppResult<()> { + defs.iter() + .find(|d| d.id == event_id) + .ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?; + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO events (id, is_active_override, activated_at, deactivated_at) + VALUES (?, 1, ?, NULL) + ON CONFLICT(id) DO UPDATE SET + is_active_override = 1, + activated_at = excluded.activated_at, + deactivated_at = NULL", + ) + .bind(event_id) + .bind(&now) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn deactivate_event( + pool: &Pool, + event_id: &str, + defs: &[EventDefinition], +) -> AppResult<()> { + defs.iter() + .find(|d| d.id == event_id) + .ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?; + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO events (id, is_active_override, activated_at, deactivated_at) + VALUES (?, 0, NULL, ?) + ON CONFLICT(id) DO UPDATE SET + is_active_override = 0, + deactivated_at = excluded.deactivated_at, + activated_at = NULL", + ) + .bind(event_id) + .bind(&now) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/services/market.rs b/src/services/market.rs index 3c57a8b..d0cb401 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -3,9 +3,10 @@ use crate::{ error::{AppError, AppResult}, models::{ card::CardDefinition, + event::EventDefinition, market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest}, }, - services::{card_db::CardDb, club}, + services::{card_db::CardDb, club, event as event_svc}, }; use rand::Rng; use uuid::Uuid; @@ -15,7 +16,9 @@ pub async fn get_active_listings( card_db: &CardDb, ) -> AppResult> { let listings = sqlx::query_as::<_, MarketListing>( - "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') ORDER BY listed_at DESC LIMIT 50" + "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \ + FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') \ + ORDER BY listed_at DESC LIMIT 50", ) .fetch_all(pool) .await?; @@ -34,8 +37,13 @@ pub async fn get_active_listings( } /// Refresh NPC market with random listings from the card pool. -pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult { - // Clean up expired listings first +/// Also injects bonus cards from currently active events. +pub async fn refresh_npc_listings( + pool: &Pool, + card_db: &CardDb, + event_defs: &[EventDefinition], +) -> AppResult { + // Clean up expired and unsold listings sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')") .execute(pool) .await?; @@ -43,7 +51,11 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult = { let all_cards: Vec<&CardDefinition> = card_db.all(); if all_cards.is_empty() { @@ -59,22 +71,38 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult = all_cards .iter() .take(count) .map(|card| { - let base_price = price_for_card(card.overall); - let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100); + let base = price_for_card(card.overall); + let price = rng.gen_range((base / 2)..=(base * 2)).max(100); let seller = npc_names[rng.gen_range(0..npc_names.len())]; MarketListing::new(&card.id, seller, price) }) - .collect() - }; // all_cards refs and ThreadRng both dropped here + .collect(); + + // Inject event bonus cards as special NPC listings + for event in &active_events { + for card_id in &event.effects.bonus_market_cards { + if let Some(card) = card_db.get(card_id) { + let price = price_for_card(card.overall) * 2; // premium pricing + let label = format!("[EVENT] {}", event.name); + listings.push(MarketListing::new(card_id.as_str(), label.as_str(), price)); + } + } + } + + listings + }; // rng + card refs + active_events all dropped here let mut inserted = 0; for listing in &listings_to_insert { sqlx::query( - "INSERT INTO market_listings (id, card_id, seller_name, price, listed_at, expires_at, sold) VALUES (?, ?, ?, ?, ?, ?, 0)" + "INSERT INTO market_listings \ + (id, card_id, seller_name, price, listed_at, expires_at, sold) \ + VALUES (?, ?, ?, ?, ?, ?, 0)", ) .bind(&listing.id) .bind(&listing.card_id) @@ -98,7 +126,8 @@ pub async fn buy_listing( req: &BuyListingRequest, ) -> AppResult { let listing = sqlx::query_as::<_, MarketListing>( - "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE id = ? AND sold = 0" + "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \ + FROM market_listings WHERE id = ? AND sold = 0", ) .bind(&req.listing_id) .fetch_optional(pool) @@ -114,7 +143,9 @@ pub async fn buy_listing( let owned_id = Uuid::new_v4().to_string(); sqlx::query( - "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)" + "INSERT INTO owned_cards \ + (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \ + VALUES (?, ?, ?, 0, NULL, ?)", ) .bind(&owned_id) .bind(club_id) @@ -131,7 +162,8 @@ pub async fn buy_listing( pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult { let _owned = 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 \ + FROM owned_cards WHERE id = ? AND club_id = ?", ) .bind(&req.owned_card_id) .bind(club_id) @@ -144,7 +176,6 @@ pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> App .execute(pool) .await?; - // Quick-sell price: 40% of requested price let coins = (req.price as f64 * 0.4) as i64; let new_balance = club::add_coins(pool, club_id, coins).await?; Ok(new_balance) diff --git a/src/services/mod.rs b/src/services/mod.rs index af03235..f51f2b3 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod card_db; pub mod club; +pub mod event; pub mod market; pub mod match_service; pub mod objective; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index de3ba6c..f0bb33b 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -382,3 +382,103 @@ async fn test_win_streak_tracking() { assert_eq!(stats["win_streak"], 3); assert_eq!(stats["best_win_streak"], 3); } + +// ── Phase 5: Events ────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_events_list() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/events").await; + assert_eq!(status, StatusCode::OK); + assert!(json["events"].is_array()); + // TOTW Week 1 is date-range active (2024–2030) + let active = json["active_count"].as_u64().unwrap_or(0); + assert!(active >= 1, "expected at least 1 active event (totw_week1)"); +} + +#[tokio::test] +async fn test_event_get_single() { + let app = build_test_app().await; + let (status, json) = json_get(&app, "/events/totw_week1").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["event"]["id"], "totw_week1"); + assert_eq!(json["is_active"], true); +} + +#[tokio::test] +async fn test_event_activate_deactivate() { + let app = build_test_app().await; + + // spring_festival is manual-only — should start inactive + let (s, json) = json_get(&app, "/events/spring_festival").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["is_active"], false); + + // Activate it + let (s, json) = json_post( + &app, + "/events/spring_festival/activate", + serde_json::json!({}), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["is_active"], true); + + // Confirm it's now active + let (s, json) = json_get(&app, "/events/spring_festival").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["is_active"], true); + + // Deactivate it + let (s, json) = json_post( + &app, + "/events/spring_festival/deactivate", + serde_json::json!({}), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["is_active"], false); + + // Confirm inactive again + let (s, json) = json_get(&app, "/events/spring_festival").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["is_active"], false); +} + +#[tokio::test] +async fn test_event_unknown_returns_404() { + let app = build_test_app().await; + let (status, _) = json_get(&app, "/events/no_such_event").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_event_market_refresh_injects_totw_cards() { + let app = build_test_app().await; + auth(&app, "EventMarketPlayer").await; + + // Trigger a market refresh (TOTW event is active by date range) + let (s, json) = json_post(&app, "/market/refresh", serde_json::json!({})).await; + assert_eq!(s, StatusCode::OK); + // totw_week1 has 5 bonus cards + up to 20 NPC listings + let count = json["listings_generated"].as_u64().unwrap_or(0); + assert!( + count >= 5, + "expected at least 5 listings (event bonus cards)" + ); + + // Check market contains at least one [EVENT] listing + let (s, market) = json_get(&app, "/market").await; + assert_eq!(s, StatusCode::OK); + let listings = market["listings"].as_array().expect("listings array"); + let event_listing = listings.iter().any(|l| { + l["seller_name"] + .as_str() + .map(|n| n.starts_with("[EVENT]")) + .unwrap_or(false) + }); + assert!( + event_listing, + "expected at least one [EVENT] market listing" + ); +}