wip: checkpoint multi-game core work

This commit is contained in:
funman300
2026-08-07 12:03:21 -07:00
parent 11a811db6a
commit 8c8a4116bf
41 changed files with 405 additions and 180 deletions
-11
View File
@@ -42,17 +42,6 @@ impl CardDb {
self.cards.get(id)
}
#[allow(dead_code)]
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
self.cards
.values()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
r == rarity || rarity == "any"
})
.collect()
}
pub fn all(&self) -> Vec<&CardDefinition> {
self.cards.values().collect()
}
+14 -4
View File
@@ -88,13 +88,18 @@ pub async fn start_draft(
let first_position = &pick_order[0];
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
let pick_order_json = serde_json::to_string(&pick_order)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
let candidates_json = serde_json::to_string(&candidates)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
let session = DraftSession {
id: Uuid::new_v4().to_string(),
profile_id: profile_id.to_string(),
difficulty: difficulty.to_string(),
pick_order: serde_json::to_string(&pick_order).unwrap(),
pick_order: pick_order_json,
picks: "[]".to_string(),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
current_candidates: Some(candidates_json),
status: "active".to_string(),
reward_coins: 0,
reward_pack_id: None,
@@ -185,17 +190,22 @@ pub async fn pick_card(
let next_pos = &pick_order[next_index];
let next_candidates =
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
{
let candidates_json = serde_json::to_string(&next_candidates)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
(
Some(serde_json::to_string(&next_candidates).unwrap()),
Some(candidates_json),
"active".to_string(),
0,
None,
0,
None,
)
}
};
let picks_json = serde_json::to_string(&picks).unwrap();
let picks_json = serde_json::to_string(&picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
+21 -5
View File
@@ -239,16 +239,30 @@ pub async fn claim_rivals_reward(
pack_defs: &[PackDefinition],
) -> AppResult<serde_json::Value> {
// Fetch current season row (must exist)
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let (division, week_claimed, total_pts) =
let (division, week_claimed, total_pts, last_claimed_at) =
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
// Enforce 24-hour cooldown between weekly reward claims
if let Some(ref last_claimed) = last_claimed_at {
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
if elapsed < chrono::Duration::hours(24) {
let hours_remaining = 24 - elapsed.num_hours();
return Err(AppError::Conflict(format!(
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
)));
}
}
}
let next_week = week_claimed + 1;
let coins = rivals_weekly_coins(division);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
@@ -271,11 +285,13 @@ pub async fn claim_rivals_reward(
None
};
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
WHERE profile_id = ?",
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
rivals_last_claimed_at = ? WHERE profile_id = ?",
)
.bind(next_week)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
+9 -4
View File
@@ -43,11 +43,12 @@ pub async fn refresh_npc_listings(
card_db: &CardDb,
event_defs: &[EventDefinition],
) -> AppResult<usize> {
// Clean up expired and unsold listings
// Clean up expired listings and previous NPC listings.
// Player-posted listings (is_npc = 0) are intentionally preserved.
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool)
.await?;
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
.execute(pool)
.await?;
@@ -107,8 +108,8 @@ pub async fn refresh_npc_listings(
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)",
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
)
.bind(&listing.id)
.bind(&listing.card_id)
@@ -190,6 +191,10 @@ pub async fn sell_card(
club_id: &str,
req: &SellCardRequest,
) -> AppResult<i64> {
if req.price < 0 {
return Err(AppError::BadRequest("price must be non-negative".into()));
}
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
+13 -3
View File
@@ -94,6 +94,12 @@ pub async fn process_match(
obj_defs: &[ObjectiveDefinition],
ach_defs: &[AchievementDefinition],
) -> AppResult<MatchRewardResult> {
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
return Err(crate::error::AppError::BadRequest(
"goals_for and goals_against must each be between 0 and 99".into(),
));
}
let outcome = if req.goals_for > req.goals_against {
"win"
} else if req.goals_for == req.goals_against {
@@ -184,9 +190,13 @@ pub async fn process_match(
objectives_updated.append(&mut c);
for obj_id in &objectives_updated {
let title = "Objective complete!";
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
let _ = notification::create(pool, "objective_complete", title, &body).await;
let display_name = obj_defs
.iter()
.find(|d| &d.id == obj_id)
.map(|d| d.title.as_str())
.unwrap_or(obj_id.as_str());
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
}
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn increment_metric(
for def in defs
.iter()
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
.filter(|d| d.metric.as_str() == metric)
{
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
+2 -2
View File
@@ -90,8 +90,8 @@ pub async fn open_pack(
.all()
.into_iter()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
rarities.contains(&r)
let r = c.rarity.as_str();
rarities.contains(&r.to_string())
})
.cloned()
.collect()
+22 -8
View File
@@ -5,33 +5,41 @@ use crate::{
};
use chrono::Utc;
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
/// one profile row per `game_id`, so we take the earliest for that game.
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
"SELECT id, username, level, xp, game_id, created_at, updated_at \
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
)
.bind(game_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
}
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
// Single-player per game: one profile per game_id, not one globally.
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game_id)
.fetch_one(pool)
.await?;
if existing > 0 {
return Err(AppError::Conflict(
"a profile already exists; OpenFUT is single-player only".into(),
"a profile already exists for this game; OpenFUT is single-player per game".into(),
));
}
let profile = Profile::new(username);
let profile = Profile::new(username, game_id);
sqlx::query(
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&profile.id)
.bind(&profile.username)
.bind(profile.level)
.bind(profile.xp)
.bind(&profile.game_id)
.bind(profile.created_at)
.bind(profile.updated_at)
.execute(pool)
@@ -59,7 +67,13 @@ pub async fn add_xp_with_levelup(
club_id: &str,
xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> {
let profile = get_active_profile(pool).await?;
let profile = sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
let old_level = level_for_xp(profile.xp);
let new_total_xp = profile.xp + xp_to_add;
let new_level = level_for_xp(new_total_xp);
-3
View File
@@ -101,9 +101,6 @@ pub async fn record_match(
// Grant rewards
club::add_coins(pool, club_id, coins).await?;
if let Some(pack_def) = pack_id {
let dummy_pack_id = Uuid::new_v4().to_string();
// Grant via pack system so it shows in inventory
let _ = dummy_pack_id; // will use grant_pack instead
pack::grant_pack(pool, club_id, pack_def).await?;
}
+4 -2
View File
@@ -63,6 +63,7 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
pub async fn validate_formation(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
players: &[SquadPlayerInput],
) -> AppResult<()> {
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
@@ -77,12 +78,13 @@ pub async fn validate_formation(
let mut gk_count = 0usize;
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(&sp.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
if let Some(card) = card_db.get(&owned.card_id) {
if card.position == "GK" {