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,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},
|
||||
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<Vec<MarketListingWithCard>> {
|
||||
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<usize> {
|
||||
// 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<usize> {
|
||||
// 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<us
|
||||
.execute(pool)
|
||||
.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 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<us
|
||||
"City AI Club",
|
||||
];
|
||||
let mut rng = rand::thread_rng();
|
||||
all_cards
|
||||
|
||||
let mut listings: Vec<MarketListing> = 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<CardDefinition> {
|
||||
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<i64> {
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod card_db;
|
||||
pub mod club;
|
||||
pub mod event;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
pub mod objective;
|
||||
|
||||
Reference in New Issue
Block a user