1ef2c436ab
CI / Build, lint & test (push) Successful in 2m5s
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>
173 lines
5.0 KiB
Rust
173 lines
5.0 KiB
Rust
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(())
|
|
}
|