Adds a data-driven event system (#47 schema, #48 activation, #49 TOTW): - EventDefinition JSON schema (event_type, effects, date range, is_manual) - data/events/totw_week1.json — TOTW event active by date range (2024–2030), injects 5 TOTW cards into the market as [EVENT] listings - data/events/seasonal_events.json — Spring Festival and Icon Weekend, manual-activation-only example events - migrations/0004_events.sql — events override table (NULL/1/0 per event) - services/event — load_event_definitions, compute_is_active (date range + manual override), get_active_events, get_all_with_status, activate/deactivate - routes/events — GET /events, GET /events/:id, POST .../activate, .../deactivate - services/market::refresh_npc_listings now accepts event_defs, injects bonus market cards from active events at 2× premium price - 5 new integration tests (events list, single, activate/deactivate cycle, 404 on unknown, market injection verification); 19/19 passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
);
|
||||||
+23
-5
@@ -2,13 +2,15 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
db::Pool,
|
db::Pool,
|
||||||
|
models::event::EventDefinition,
|
||||||
models::objective::{ObjectiveDefinition, ObjectiveType},
|
models::objective::{ObjectiveDefinition, ObjectiveType},
|
||||||
models::pack::PackDefinition,
|
models::pack::PackDefinition,
|
||||||
models::sbc::SbcDefinition,
|
models::sbc::SbcDefinition,
|
||||||
routes,
|
routes,
|
||||||
services::{
|
services::{
|
||||||
card_db::CardDb, market, objective::load_objective_definitions,
|
card_db::CardDb, event::load_event_definitions, market,
|
||||||
pack::load_pack_definitions, sbc::load_sbc_definitions,
|
objective::load_objective_definitions, pack::load_pack_definitions,
|
||||||
|
sbc::load_sbc_definitions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -32,6 +34,7 @@ pub struct AppState {
|
|||||||
pub pack_defs: Arc<Vec<PackDefinition>>,
|
pub pack_defs: Arc<Vec<PackDefinition>>,
|
||||||
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
||||||
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
||||||
|
pub event_defs: Arc<Vec<EventDefinition>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||||
@@ -39,13 +42,15 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
let pack_defs = Arc::new(load_pack_definitions(&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 obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
||||||
let sbc_defs = Arc::new(load_sbc_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!(
|
tracing::info!(
|
||||||
"Loaded {} cards, {} packs, {} objectives, {} SBCs",
|
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events",
|
||||||
card_db.cards.len(),
|
card_db.cards.len(),
|
||||||
pack_defs.len(),
|
pack_defs.len(),
|
||||||
obj_defs.len(),
|
obj_defs.len(),
|
||||||
sbc_defs.len(),
|
sbc_defs.len(),
|
||||||
|
event_defs.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -54,19 +59,22 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
pack_defs,
|
pack_defs,
|
||||||
obj_defs: obj_defs.clone(),
|
obj_defs: obj_defs.clone(),
|
||||||
sbc_defs,
|
sbc_defs,
|
||||||
|
event_defs: event_defs.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background task: refresh NPC market at startup and every 24 hours
|
// Background task: refresh NPC market at startup and every 24 hours
|
||||||
{
|
{
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let card_db = card_db.clone();
|
let card_db = card_db.clone();
|
||||||
|
let event_defs_bg = event_defs.clone();
|
||||||
tokio::spawn(async move {
|
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}");
|
tracing::warn!("initial market refresh failed: {e}");
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await;
|
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}");
|
tracing::warn!("market refresh failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +156,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", get(routes::settings::get_settings))
|
.route("/settings", get(routes::settings::get_settings))
|
||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/draft/squad", get(routes::draft::get_draft_squad))
|
.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).
|
// Layers run outermost-first (last added = outermost).
|
||||||
// CORS → body limit → concurrency limit → request ID → trace
|
// CORS → body limit → concurrency limit → request ID → trace
|
||||||
.layer(PropagateRequestIdLayer::x_request_id())
|
.layer(PropagateRequestIdLayer::x_request_id())
|
||||||
|
|||||||
@@ -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<PackWeightBoost>,
|
||||||
|
pub bonus_market_cards: Vec<String>,
|
||||||
|
pub unlocks_sbc_ids: Vec<String>,
|
||||||
|
pub unlocks_objective_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
pub deactivated_at: Option<String>,
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod event;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_result;
|
pub mod match_result;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
|
|||||||
@@ -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<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(event_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(event_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(event_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
event_svc::deactivate_event(&state.pool, &event_id, &state.event_defs).await?;
|
||||||
|
Ok(Json(json!({ "deactivated": event_id, "is_active": false })))
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ pub async fn post_market_sell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
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 })))
|
Ok(Json(json!({ "listings_generated": count })))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod auth;
|
|||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
|
|||||||
@@ -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<Vec<EventDefinition>> {
|
||||||
|
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<EventDefinition> =
|
||||||
|
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||||
|
defs.extend(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(defs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_is_active(def: &EventDefinition, override_val: Option<i64>) -> 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<HashMap<String, Option<i64>>> {
|
||||||
|
let rows: Vec<(String, Option<i64>)> =
|
||||||
|
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<bool> {
|
||||||
|
let row: Option<(Option<i64>,)> =
|
||||||
|
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<Vec<EventDefinition>> {
|
||||||
|
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<i64>, Option<String>, Option<String>);
|
||||||
|
type EventOverrideMap = HashMap<String, (Option<i64>, Option<String>, Option<String>)>;
|
||||||
|
|
||||||
|
pub async fn get_all_with_status(
|
||||||
|
pool: &Pool,
|
||||||
|
defs: &[EventDefinition],
|
||||||
|
) -> AppResult<Vec<EventWithStatus>> {
|
||||||
|
let rows: Vec<EventRow> =
|
||||||
|
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(())
|
||||||
|
}
|
||||||
+46
-15
@@ -3,9 +3,10 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
card::CardDefinition,
|
card::CardDefinition,
|
||||||
|
event::EventDefinition,
|
||||||
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
|
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
|
||||||
},
|
},
|
||||||
services::{card_db::CardDb, club},
|
services::{card_db::CardDb, club, event as event_svc},
|
||||||
};
|
};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -15,7 +16,9 @@ pub async fn get_active_listings(
|
|||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
) -> AppResult<Vec<MarketListingWithCard>> {
|
) -> AppResult<Vec<MarketListingWithCard>> {
|
||||||
let listings = sqlx::query_as::<_, MarketListing>(
|
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)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -34,8 +37,13 @@ pub async fn get_active_listings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh NPC market with random listings from the card pool.
|
/// Refresh NPC market with random listings from the card pool.
|
||||||
pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<usize> {
|
/// Also injects bonus cards from currently active events.
|
||||||
// Clean up expired listings first
|
pub async fn refresh_npc_listings(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
event_defs: &[EventDefinition],
|
||||||
|
) -> AppResult<usize> {
|
||||||
|
// Clean up expired and unsold listings
|
||||||
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -43,7 +51,11 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<us
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Build all listings synchronously before any await — drops &CardDefinition refs before first .await
|
// Resolve active events before entering the sync block (no await inside)
|
||||||
|
let active_events = event_svc::get_active_events(pool, event_defs).await?;
|
||||||
|
|
||||||
|
// Build all listings synchronously — ThreadRng and &CardDefinition refs
|
||||||
|
// are both dropped before the first .await below.
|
||||||
let listings_to_insert: Vec<MarketListing> = {
|
let listings_to_insert: Vec<MarketListing> = {
|
||||||
let all_cards: Vec<&CardDefinition> = card_db.all();
|
let all_cards: Vec<&CardDefinition> = card_db.all();
|
||||||
if all_cards.is_empty() {
|
if all_cards.is_empty() {
|
||||||
@@ -59,22 +71,38 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<us
|
|||||||
"City AI Club",
|
"City AI Club",
|
||||||
];
|
];
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
all_cards
|
|
||||||
|
let mut listings: Vec<MarketListing> = all_cards
|
||||||
.iter()
|
.iter()
|
||||||
.take(count)
|
.take(count)
|
||||||
.map(|card| {
|
.map(|card| {
|
||||||
let base_price = price_for_card(card.overall);
|
let base = price_for_card(card.overall);
|
||||||
let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100);
|
let price = rng.gen_range((base / 2)..=(base * 2)).max(100);
|
||||||
let seller = npc_names[rng.gen_range(0..npc_names.len())];
|
let seller = npc_names[rng.gen_range(0..npc_names.len())];
|
||||||
MarketListing::new(&card.id, seller, price)
|
MarketListing::new(&card.id, seller, price)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
}; // all_cards refs and ThreadRng both dropped here
|
|
||||||
|
// 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;
|
let mut inserted = 0;
|
||||||
for listing in &listings_to_insert {
|
for listing in &listings_to_insert {
|
||||||
sqlx::query(
|
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.id)
|
||||||
.bind(&listing.card_id)
|
.bind(&listing.card_id)
|
||||||
@@ -98,7 +126,8 @@ pub async fn buy_listing(
|
|||||||
req: &BuyListingRequest,
|
req: &BuyListingRequest,
|
||||||
) -> AppResult<CardDefinition> {
|
) -> AppResult<CardDefinition> {
|
||||||
let listing = sqlx::query_as::<_, MarketListing>(
|
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)
|
.bind(&req.listing_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -114,7 +143,9 @@ pub async fn buy_listing(
|
|||||||
|
|
||||||
let owned_id = Uuid::new_v4().to_string();
|
let owned_id = Uuid::new_v4().to_string();
|
||||||
sqlx::query(
|
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(&owned_id)
|
||||||
.bind(club_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<i64> {
|
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
|
||||||
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
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(&req.owned_card_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
@@ -144,7 +176,6 @@ pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> App
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Quick-sell price: 40% of requested price
|
|
||||||
let coins = (req.price as f64 * 0.4) as i64;
|
let coins = (req.price as f64 * 0.4) as i64;
|
||||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
||||||
Ok(new_balance)
|
Ok(new_balance)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod event;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
|
|||||||
@@ -382,3 +382,103 @@ async fn test_win_streak_tracking() {
|
|||||||
assert_eq!(stats["win_streak"], 3);
|
assert_eq!(stats["win_streak"], 3);
|
||||||
assert_eq!(stats["best_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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user