diff --git a/Cargo.lock b/Cargo.lock index 5c167f4..61cef31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "sw-plugin" -version = "1.0.3" +version = "1.0.4" dependencies = [ "async-trait", "chrono", @@ -2803,9 +2803,9 @@ dependencies = [ [[package]] name = "sw-plugin" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6e2c7dde21dd6ceabb108ca438bebe963c7d21abc9b778d6012ecedea6ba0f" +checksum = "b23d406eaaf5e2ecd092b48ced84e757b6c4fd605e93d726b64736fc3ffb491b" dependencies = [ "async-trait", "chrono", @@ -2836,7 +2836,7 @@ dependencies = [ "serde_json", "sqlx", "sw-domain 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "sw-plugin 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)", + "sw-plugin 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "sw_checkers", "sw_lexi_wars", "sw_ludo", @@ -2862,7 +2862,7 @@ dependencies = [ "serde", "serde_json", "sw-domain 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "sw-plugin 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)", + "sw-plugin 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "tokio", "tracing", "uuid", @@ -2881,7 +2881,7 @@ dependencies = [ "serde", "serde_json", "sw-domain 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "sw-plugin 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)", + "sw-plugin 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "tokio", "tracing", "uuid", @@ -2899,7 +2899,7 @@ dependencies = [ "serde", "serde_json", "sw-domain 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "sw-plugin 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)", + "sw-plugin 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "tokio", "tracing", "uuid", @@ -2917,7 +2917,7 @@ dependencies = [ "serde", "serde_json", "sw-domain 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "sw-plugin 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)", + "sw-plugin 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "tokio", "tracing", "uuid", diff --git a/Cargo.toml b/Cargo.toml index e071adc..53e135a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ repository = "https://github.com/stacks-wars/backend" [workspace.dependencies] sw-domain = "1.0.2" -sw-plugin = "1.0.3" +sw-plugin = "1.0.4" sw_checkers = "1.0.3" sw_lexi_wars = "1.0.1" sw_ludo = "1.0.1" diff --git a/crates/sw-plugin/Cargo.toml b/crates/sw-plugin/Cargo.toml index 34c09c9..c143b3b 100644 --- a/crates/sw-plugin/Cargo.toml +++ b/crates/sw-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sw-plugin" -version = "1.0.3" +version = "1.0.4" edition.workspace = true license-file = "../../LICENSE" authors.workspace = true diff --git a/crates/sw-plugin/README.md b/crates/sw-plugin/README.md index d619e98..7528a8b 100644 --- a/crates/sw-plugin/README.md +++ b/crates/sw-plugin/README.md @@ -6,7 +6,9 @@ Implement `GameFactory` / `GameEngine` and call into `GameHost` for broadcast, p Winner-take-all is the default: call `complete_match` with a named winner and the host issues one full-pot claim. To split a pot (or pay as ranks lock), call `issue_payout` yourself — it is a no-op on older hosts. Optional helpers `placement_share_pct` / `placement_prize` implement the first-party 70/30 and 50/30/20 splits; other games can ignore them. Finish those matches with `stats.settlement = "distributed"` so settle does not issue a second winner-take-all claim. +Match Wars Points: pass the engine winner flag into `save_player_result` / `calculate_wars_point_for` so draws do not get the win bonus. `calculate_wars_point` still treats rank 1 as a win for host-save fallbacks. + ```toml -sw-plugin = "1.0.3" +sw-plugin = "1.0.4" sw-domain = "1.0.2" ``` diff --git a/crates/sw-plugin/src/kit.rs b/crates/sw-plugin/src/kit.rs index 085b6fd..f0c6bed 100644 --- a/crates/sw-plugin/src/kit.rs +++ b/crates/sw-plugin/src/kit.rs @@ -318,9 +318,25 @@ pub struct WarsPointContext { pub token_contract_id: Option, } +/// Match Wars Points. `is_winner` is the engine's winner flag (draws get no win bonus). +pub fn calculate_wars_point_for(ctx: &WarsPointContext, is_winner: bool) -> i64 { + let rank = ctx.rank.max(1) as i64; + let participants = ctx.participants.max(1) as i64; + let mut points = 5; + points += (participants - rank).max(0) * 2; + if is_winner { + points += 8; + } + let paid = !ctx.is_sponsored && ctx.entry_amount.unwrap_or(0.0) > 0.0; + if paid { + points += 3; + } + points.clamp(0, 40) +} + +/// Fallback used by engines when the host save fails. Treats rank 1 as a win. pub fn calculate_wars_point(ctx: &WarsPointContext) -> i64 { - let base_points = (ctx.participants as i64 - ctx.rank as i64 + 1) * 2; - base_points.clamp(0, 50) + calculate_wars_point_for(ctx, ctx.rank == 1) } /// Optional first-party placement split. Other games can ignore this and @@ -437,4 +453,31 @@ mod tests { assert_eq!(paid_place_count(2), 2); assert_eq!(paid_place_count(5), 3); } + + fn ctx(rank: usize, participants: usize, paid: bool, sponsored: bool) -> WarsPointContext { + WarsPointContext { + user_id: Uuid::new_v4(), + game_id: None, + rank, + prize: None, + participants, + entry_amount: if paid { Some(1.0) } else { None }, + current_amount: None, + is_sponsored: sponsored, + creator_id: None, + active_players: participants, + token_symbol: None, + token_contract_id: None, + } + } + + #[test] + fn wars_points_heads_up_and_paid() { + assert_eq!(calculate_wars_point_for(&ctx(2, 2, false, false), false), 5); + assert_eq!(calculate_wars_point_for(&ctx(1, 2, false, false), true), 15); + assert_eq!(calculate_wars_point_for(&ctx(1, 2, true, false), true), 18); + assert_eq!(calculate_wars_point_for(&ctx(1, 4, false, false), true), 19); + assert_eq!(calculate_wars_point_for(&ctx(1, 2, true, true), true), 15); + assert_eq!(calculate_wars_point(&ctx(1, 2, false, false)), 15); + } } diff --git a/crates/sw-plugin/src/lib.rs b/crates/sw-plugin/src/lib.rs index c8a7ef7..14ac064 100644 --- a/crates/sw-plugin/src/lib.rs +++ b/crates/sw-plugin/src/lib.rs @@ -21,7 +21,8 @@ pub use factory::GameFactory; pub use game_error::GameError; pub use host::{GameHost, GameHostRef}; pub use kit::{ - calculate_wars_point, placement_prize, placement_share_pct, paid_place_count, ClockReading, + calculate_wars_point, calculate_wars_point_for, placement_prize, placement_share_pct, + paid_place_count, ClockReading, GameBootstrap, GamePlayerState, GameResults, GameStatus, GameSummary, PlayerClocks, PlayerRanking, PlayerResult, TurnRotation, WarsPointContext, }; diff --git a/crates/sw-plugin/src/nop_host.rs b/crates/sw-plugin/src/nop_host.rs index 32fecbd..c5ac910 100644 --- a/crates/sw-plugin/src/nop_host.rs +++ b/crates/sw-plugin/src/nop_host.rs @@ -3,7 +3,7 @@ use serde_json::Value; use sw_domain::UserId; use crate::dto::PlayerStateWire; -use crate::kit::{calculate_wars_point, PlayerResult, WarsPointContext}; +use crate::kit::{calculate_wars_point_for, PlayerResult, WarsPointContext}; use crate::{GameHost, MatchResult, PluginResult}; /// Placeholder host used between `GameFactory::create` and `GameEngine::start`. @@ -45,12 +45,12 @@ impl GameHost for NopHost { async fn save_player_result( &self, ctx: &WarsPointContext, - _is_winner: bool, + is_winner: bool, ) -> PluginResult { Ok(PlayerResult { rank: ctx.rank, prize: ctx.prize, - wars_point: calculate_wars_point(ctx), + wars_point: calculate_wars_point_for(ctx, is_winner), }) } } diff --git a/crates/sw-server/src/data/mod.rs b/crates/sw-server/src/data/mod.rs index 7362cc7..afa73fd 100644 --- a/crates/sw-server/src/data/mod.rs +++ b/crates/sw-server/src/data/mod.rs @@ -9,6 +9,7 @@ pub mod lobby_runtime; pub mod lobby_status; pub mod matches; pub mod push; +pub mod quest_claims; pub mod seasons; pub mod seat_holds; pub mod stats; diff --git a/crates/sw-server/src/data/quest_claims.rs b/crates/sw-server/src/data/quest_claims.rs new file mode 100644 index 0000000..f08a45b --- /dev/null +++ b/crates/sw-server/src/data/quest_claims.rs @@ -0,0 +1,533 @@ +//! Quest claim ledger and the per-user match reads GET/claim evaluate from. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Postgres, Transaction}; +use sw_domain::{LeaderboardEntry, SeasonId, UserId}; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::quests::evaluate::{GettingStartedActions, QualifyingMatch}; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct QuestClaimRow { + pub id: Uuid, + pub user_id: Uuid, + pub quest_id: String, + pub period_kind: String, + pub period_id: String, + pub season_id: Option, + pub reward_points: i32, + pub catalog_version: i32, + pub claimed_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct NewQuestClaim { + pub user_id: UserId, + pub quest_id: String, + pub period_kind: String, + pub period_id: String, + pub season_id: Option, + pub reward_points: i32, + pub catalog_version: i32, +} + +#[derive(Debug, sqlx::FromRow)] +struct MatchRow { + game_id: String, + finished_at: DateTime, + is_winner: bool, + entry_micro: i64, + creator_id: Uuid, + player_count: i32, + opponents: Vec, +} + +#[derive(Debug, sqlx::FromRow)] +struct GsRow { + username_set: bool, + hosted: bool, + joined: bool, + won: bool, +} + +#[derive(Debug, sqlx::FromRow)] +struct LeaderboardRow { + user_id: Uuid, + points: i64, + total_matches: i32, + total_wins: i32, + total_pnl: i64, + username: Option, + display_name: Option, + avatar_url: Option, +} + +impl LeaderboardRow { + fn into_entry(self, rank: u32) -> LeaderboardEntry { + let win_rate_bps = if self.total_matches <= 0 { + 0 + } else { + ((self.total_wins as i64 * 10_000) / self.total_matches as i64) as i32 + }; + LeaderboardEntry { + rank, + user_id: UserId(self.user_id), + points: self.points, + total_matches: self.total_matches, + total_wins: self.total_wins, + total_pnl: self.total_pnl, + win_rate_bps, + username: self.username, + display_name: self.display_name, + avatar_url: self.avatar_url, + } + } +} + +pub struct PgQuestRepo { + pool: PgPool, +} + +impl PgQuestRepo { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn qualifying_matches( + &self, + user_id: UserId, + since: DateTime, + ) -> AppResult> { + qualifying_matches(&self.pool, user_id, since).await + } + + pub async fn getting_started_actions( + &self, + user_id: UserId, + ) -> AppResult { + getting_started_actions(&self.pool, user_id).await + } + + pub async fn claims_for_user(&self, user_id: UserId) -> AppResult> { + claims_for_user(&self.pool, user_id).await + } + + pub async fn successful_referral_count(&self, user_id: UserId) -> AppResult { + successful_referral_count(&self.pool, user_id).await + } + + pub async fn daily_claim_count( + &self, + user_id: UserId, + period_ids: &[String], + ) -> AppResult { + daily_claim_count(&self.pool, user_id, period_ids).await + } + + pub async fn season_claim_count(&self, user_id: UserId, season_id: i32) -> AppResult { + season_claim_count(&self.pool, user_id, season_id).await + } + + pub async fn maybe_stamp_getting_started(&self, user_id: UserId) -> AppResult { + maybe_stamp_getting_started(&self.pool, user_id).await + } + + pub async fn insert_claim( + tx: &mut Transaction<'_, Postgres>, + claim: &NewQuestClaim, + ) -> AppResult> { + let row = sqlx::query_as::<_, QuestClaimRow>( + r#" + INSERT INTO quest_claims ( + user_id, quest_id, period_kind, period_id, season_id, + reward_points, catalog_version + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id, quest_id, period_id) DO NOTHING + RETURNING id, user_id, quest_id, period_kind, period_id, season_id, + reward_points, catalog_version, claimed_at + "#, + ) + .bind(claim.user_id.as_uuid()) + .bind(&claim.quest_id) + .bind(&claim.period_kind) + .bind(&claim.period_id) + .bind(claim.season_id) + .bind(claim.reward_points) + .bind(claim.catalog_version) + .fetch_optional(&mut **tx) + .await + .map_err(|err| AppError::Internal(err.into()))?; + Ok(row) + } + + pub async fn get_claim( + tx: &mut Transaction<'_, Postgres>, + user_id: UserId, + quest_id: &str, + period_id: &str, + ) -> AppResult> { + sqlx::query_as::<_, QuestClaimRow>( + r#" + SELECT id, user_id, quest_id, period_kind, period_id, season_id, + reward_points, catalog_version, claimed_at + FROM quest_claims + WHERE user_id = $1 AND quest_id = $2 AND period_id = $3 + "#, + ) + .bind(user_id.as_uuid()) + .bind(quest_id) + .bind(period_id) + .fetch_optional(&mut **tx) + .await + .map_err(|err| AppError::Internal(err.into())) + } + + pub async fn leaderboard_quests( + &self, + season_id: SeasonId, + limit: i64, + offset: i64, + ) -> AppResult<(Vec, i64)> { + let total = sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(DISTINCT user_id)::bigint + FROM quest_claims + WHERE season_id = $1 + "#, + ) + .bind(season_id.as_i32()) + .fetch_one(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))?; + + let rows = sqlx::query_as::<_, LeaderboardRow>( + r#" + SELECT + u.id AS user_id, + COALESCE(SUM(c.reward_points), 0)::bigint AS points, + 0::int AS total_matches, + 0::int AS total_wins, + 0::bigint AS total_pnl, + u.username, + u.display_name, + u.avatar_url + FROM quest_claims c + JOIN users u ON u.id = c.user_id + WHERE c.season_id = $1 + GROUP BY u.id, u.username, u.display_name, u.avatar_url + ORDER BY points DESC, u.id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(season_id.as_i32()) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))?; + + let items = rows + .into_iter() + .enumerate() + .map(|(i, row)| row.into_entry((offset as u32) + (i as u32) + 1)) + .collect(); + Ok((items, total)) + } + + pub async fn leaderboard_all( + &self, + season_id: SeasonId, + limit: i64, + offset: i64, + ) -> AppResult<(Vec, i64)> { + let total = sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*)::bigint FROM ( + SELECT user_id FROM user_game_stats WHERE season_id = $1 + UNION + SELECT user_id FROM quest_claims WHERE season_id = $1 + ) ids + "#, + ) + .bind(season_id.as_i32()) + .fetch_one(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))?; + + let rows = sqlx::query_as::<_, LeaderboardRow>( + r#" + WITH game AS ( + SELECT user_id, + SUM(points) AS points, + SUM(total_matches)::int AS total_matches, + SUM(total_wins)::int AS total_wins, + SUM(total_pnl) AS total_pnl + FROM user_game_stats + WHERE season_id = $1 + GROUP BY user_id + ), + quest AS ( + SELECT user_id, SUM(reward_points)::bigint AS points + FROM quest_claims + WHERE season_id = $1 + GROUP BY user_id + ) + SELECT + u.id AS user_id, + (COALESCE(g.points, 0) + COALESCE(q.points, 0))::bigint AS points, + COALESCE(g.total_matches, 0)::int AS total_matches, + COALESCE(g.total_wins, 0)::int AS total_wins, + COALESCE(g.total_pnl, 0)::bigint AS total_pnl, + u.username, + u.display_name, + u.avatar_url + FROM users u + JOIN ( + SELECT user_id FROM game + UNION + SELECT user_id FROM quest + ) ids ON ids.user_id = u.id + LEFT JOIN game g ON g.user_id = u.id + LEFT JOIN quest q ON q.user_id = u.id + ORDER BY points DESC, u.id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(season_id.as_i32()) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))?; + + let items = rows + .into_iter() + .enumerate() + .map(|(i, row)| row.into_entry((offset as u32) + (i as u32) + 1)) + .collect(); + Ok((items, total)) + } +} + +pub async fn qualifying_matches<'e, E>( + exec: E, + user_id: UserId, + since: DateTime, +) -> AppResult> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + let rows = sqlx::query_as::<_, MatchRow>( + r#" + SELECT + m.game_id, + m.finished_at, + mp.is_winner, + mp.entry_micro, + l.creator_id, + m.player_count, + COALESCE( + ARRAY_AGG(op.user_id) FILTER (WHERE op.user_id <> $1), + '{}' + ) AS opponents + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + JOIN lobbies l ON l.id = m.lobby_id + JOIN match_players op ON op.match_id = m.id + WHERE mp.user_id = $1 + AND m.player_count >= 2 + AND m.finished_at >= $2 + GROUP BY m.id, m.game_id, m.finished_at, mp.is_winner, mp.entry_micro, + l.creator_id, m.player_count + "#, + ) + .bind(user_id.as_uuid()) + .bind(since) + .fetch_all(exec) + .await + .map_err(|err| AppError::Internal(err.into()))?; + + Ok(rows + .into_iter() + .map(|row| QualifyingMatch { + game_id: row.game_id, + finished_at: row.finished_at, + is_winner: row.is_winner, + entry_micro: row.entry_micro, + creator_id: row.creator_id, + player_count: row.player_count, + opponents: row.opponents, + }) + .collect()) +} + +pub async fn getting_started_actions<'e, E>( + exec: E, + user_id: UserId, +) -> AppResult +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + let row = sqlx::query_as::<_, GsRow>( + r#" + SELECT + (u.username IS NOT NULL) AS username_set, + EXISTS ( + SELECT 1 + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + JOIN lobbies l ON l.id = m.lobby_id + WHERE mp.user_id = u.id + AND m.player_count >= 2 + AND l.creator_id = u.id + ) AS hosted, + EXISTS ( + SELECT 1 + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + JOIN lobbies l ON l.id = m.lobby_id + WHERE mp.user_id = u.id + AND m.player_count >= 2 + AND l.creator_id <> u.id + ) AS joined, + EXISTS ( + SELECT 1 + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + WHERE mp.user_id = u.id + AND m.player_count >= 2 + AND mp.is_winner + ) AS won + FROM users u + WHERE u.id = $1 + "#, + ) + .bind(user_id.as_uuid()) + .fetch_optional(exec) + .await + .map_err(|err| AppError::Internal(err.into()))? + .ok_or(AppError::NotFound("user"))?; + + Ok(GettingStartedActions { + username_set: row.username_set, + hosted: row.hosted, + joined: row.joined, + won: row.won, + }) +} + +pub async fn claims_for_user<'e, E>(exec: E, user_id: UserId) -> AppResult> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + sqlx::query_as::<_, QuestClaimRow>( + r#" + SELECT id, user_id, quest_id, period_kind, period_id, season_id, + reward_points, catalog_version, claimed_at + FROM quest_claims + WHERE user_id = $1 + "#, + ) + .bind(user_id.as_uuid()) + .fetch_all(exec) + .await + .map_err(|err| AppError::Internal(err.into())) +} + +pub async fn successful_referral_count<'e, E>(exec: E, user_id: UserId) -> AppResult +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + sqlx::query_scalar( + r#" + SELECT COUNT(*)::bigint + FROM users + WHERE referred_by_user_id = $1 + AND getting_started_completed_at IS NOT NULL + AND deleted_at IS NULL + "#, + ) + .bind(user_id.as_uuid()) + .fetch_one(exec) + .await + .map_err(|err| AppError::Internal(err.into())) +} + +pub async fn daily_claim_count<'e, E>( + exec: E, + user_id: UserId, + period_ids: &[String], +) -> AppResult +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + if period_ids.is_empty() { + return Ok(0); + } + sqlx::query_scalar( + r#" + SELECT COUNT(*)::bigint + FROM quest_claims + WHERE user_id = $1 + AND period_kind = 'daily' + AND period_id = ANY($2) + "#, + ) + .bind(user_id.as_uuid()) + .bind(period_ids) + .fetch_one(exec) + .await + .map_err(|err| AppError::Internal(err.into())) +} + +pub async fn season_claim_count<'e, E>(exec: E, user_id: UserId, season_id: i32) -> AppResult +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + sqlx::query_scalar( + r#" + SELECT COUNT(*)::bigint + FROM quest_claims + WHERE user_id = $1 AND season_id = $2 + "#, + ) + .bind(user_id.as_uuid()) + .bind(season_id) + .fetch_one(exec) + .await + .map_err(|err| AppError::Internal(err.into())) +} + +/// Write-once Getting Started / referral credit when the four actions are true. +pub async fn maybe_stamp_getting_started(pool: &PgPool, user_id: UserId) -> AppResult { + let actions = getting_started_actions(pool, user_id).await?; + if !actions.all_done() { + return Ok(false); + } + let n = sqlx::query( + r#" + UPDATE users SET + getting_started_completed_at = COALESCE(getting_started_completed_at, now()), + referral_credited_at = CASE + WHEN referred_by_user_id IS NOT NULL + THEN COALESCE(referral_credited_at, now()) + ELSE referral_credited_at + END, + updated_at = now() + WHERE id = $1 + AND deleted_at IS NULL + AND ( + getting_started_completed_at IS NULL + OR (referred_by_user_id IS NOT NULL AND referral_credited_at IS NULL) + ) + "#, + ) + .bind(user_id.as_uuid()) + .execute(pool) + .await + .map_err(|err| AppError::Internal(err.into()))? + .rows_affected(); + Ok(n > 0) +} diff --git a/crates/sw-server/src/data/users.rs b/crates/sw-server/src/data/users.rs index fa425bb..ca1825c 100644 --- a/crates/sw-server/src/data/users.rs +++ b/crates/sw-server/src/data/users.rs @@ -596,6 +596,95 @@ impl PgUserRepo { Ok(()) } + pub async fn quest_flags(&self, id: UserId) -> AppResult> { + Self::quest_flags_on(&self.pool, id).await + } + + pub async fn quest_flags_on<'e, E>(exec: E, id: UserId) -> AppResult> + where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, + { + sqlx::query_as::<_, QuestFlags>( + r#" + SELECT + (username IS NOT NULL) AS username_set, + referral_prompt_status::text AS referral_prompt_status, + quest_intro_seen_at, + getting_started_completed_at + FROM users + WHERE id = $1 AND deleted_at IS NULL + "#, + ) + .bind(id.as_uuid()) + .fetch_optional(exec) + .await + .map_err(|err| AppError::Internal(err.into())) + } + + /// Write-once: pending → set. Returns false if the prompt was already answered. + pub async fn set_referral(&self, id: UserId, referrer_id: UserId) -> AppResult { + let n = sqlx::query( + r#" + UPDATE users SET + referred_by_user_id = $2, + referred_at = now(), + referral_prompt_status = 'set', + updated_at = now() + WHERE id = $1 + AND deleted_at IS NULL + AND referral_prompt_status = 'pending' + AND id <> $2 + "#, + ) + .bind(id.as_uuid()) + .bind(referrer_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))? + .rows_affected(); + Ok(n > 0) + } + + /// Write-once: pending → skipped. + pub async fn skip_referral(&self, id: UserId) -> AppResult { + let n = sqlx::query( + r#" + UPDATE users SET + referral_prompt_status = 'skipped', + updated_at = now() + WHERE id = $1 + AND deleted_at IS NULL + AND referral_prompt_status = 'pending' + "#, + ) + .bind(id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))? + .rows_affected(); + Ok(n > 0) + } + + pub async fn mark_quest_intro_seen(&self, id: UserId) -> AppResult<()> { + let n = sqlx::query( + r#" + UPDATE users SET + quest_intro_seen_at = COALESCE(quest_intro_seen_at, now()), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL + "#, + ) + .bind(id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|err| AppError::Internal(err.into()))? + .rows_affected(); + if n == 0 { + return Err(AppError::NotFound("user")); + } + Ok(()) + } + /// Scrub PII, drop custodial keys, and remove the Neon Auth identity so the /// email can be used to sign up again. Keeps the `users` row for FK history. pub async fn anonymize(&self, id: UserId) -> AppResult<()> { @@ -622,6 +711,7 @@ impl PgUserRepo { email_verified_at = NULL, avatar_url = NULL, lobby_alerts_enabled = false, + referred_by_user_id = NULL, deleted_at = now(), updated_at = now() WHERE id = $1 AND deleted_at IS NULL @@ -633,6 +723,14 @@ impl PgUserRepo { .await .map_err(|err| AppError::Internal(err.into()))?; + sqlx::query( + r#"UPDATE users SET referred_by_user_id = NULL WHERE referred_by_user_id = $1"#, + ) + .bind(id.as_uuid()) + .execute(&mut *tx) + .await + .map_err(|err| AppError::Internal(err.into()))?; + sqlx::query(r#"DELETE FROM custodial_wallets WHERE user_id = $1"#) .bind(id.as_uuid()) .execute(&mut *tx) @@ -684,3 +782,11 @@ pub struct UserPrefs { pub legal_version: Option, pub deleted_at: Option>, } + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct QuestFlags { + pub username_set: bool, + pub referral_prompt_status: String, + pub quest_intro_seen_at: Option>, + pub getting_started_completed_at: Option>, +} diff --git a/crates/sw-server/src/host.rs b/crates/sw-server/src/host.rs index d3df783..d3febcb 100644 --- a/crates/sw-server/src/host.rs +++ b/crates/sw-server/src/host.rs @@ -10,7 +10,7 @@ use sqlx::PgPool; use sw_domain::{ChainId, GameId, LobbyId, LobbyStatus, MatchId, UserId, usdcx_to_micro}; use sw_plugin::{ GameHost, MatchResult, PlayerResult, PlayerStateWire, PluginError, PluginResult, - WarsPointContext, calculate_wars_point, + WarsPointContext, }; use tracing::{error, info, warn}; use uuid::Uuid; @@ -273,6 +273,14 @@ impl ServerGameHost { error = %err, "failed to persist match history" ); + } else { + crate::quests::ingest::spawn_after_match( + self.db.clone(), + self.redis.clone(), + self.sessions.clone(), + self.subscriptions.clone(), + record.players.iter().map(|p| p.user_id).collect(), + ); } } @@ -363,7 +371,9 @@ impl ServerGameHost { None => Vec::new(), } }; - let needs_refund = claims.iter().any(|c| c.get("role").and_then(|v| v.as_str()) == Some("refund")); + let needs_refund = claims + .iter() + .any(|c| c.get("role").and_then(|v| v.as_str()) == Some("refund")); let needs_claim = !needs_refund && claims.iter().any(|c| { c.get("amountMicro").and_then(|v| v.as_i64()).unwrap_or(0) > 0 @@ -723,7 +733,20 @@ impl GameHost for ServerGameHost { ctx: &WarsPointContext, is_winner: bool, ) -> PluginResult { - let wars_point = calculate_wars_point(ctx); + let wars_point = { + let rank = ctx.rank.max(1) as i64; + let participants = ctx.participants.max(1) as i64; + let mut points = 5; + points += (participants - rank).max(0) * 2; + if is_winner { + points += 8; + } + let paid = !ctx.is_sponsored && ctx.entry_amount.unwrap_or(0.0) > 0.0; + if paid { + points += 3; + } + points.clamp(0, 40) + }; let won = is_winner; let game_id = ctx.game_id.clone().unwrap_or_else(|| self.game_id.clone()); diff --git a/crates/sw-server/src/lib.rs b/crates/sw-server/src/lib.rs index 2414ed0..1c4a202 100644 --- a/crates/sw-server/src/lib.rs +++ b/crates/sw-server/src/lib.rs @@ -9,6 +9,7 @@ pub mod games; pub mod host; pub mod infra; pub mod middleware; +pub mod quests; pub mod routes; pub mod services; pub mod state; diff --git a/crates/sw-server/src/quests/cache.rs b/crates/sw-server/src/quests/cache.rs new file mode 100644 index 0000000..0f9d456 --- /dev/null +++ b/crates/sw-server/src/quests/cache.rs @@ -0,0 +1,43 @@ +//! Redis cache for `GET /quests/me`. Never a source of truth. + +use redis::AsyncCommands; +use redis::aio::ConnectionManager; +use sw_domain::UserId; +use tracing::warn; + +pub fn cache_key(user_id: UserId) -> String { + format!("sw:quest:me:{}", user_id.as_uuid()) +} + +pub async fn get_json(redis: &mut ConnectionManager, user_id: UserId) -> Option { + let key = cache_key(user_id); + match redis.get::<_, Option>(&key).await { + Ok(value) => value, + Err(err) => { + warn!(error = %err, "quest cache get failed"); + None + } + } +} + +pub async fn set_json( + redis: &mut ConnectionManager, + user_id: UserId, + payload: &str, + ttl_secs: u64, +) { + let key = cache_key(user_id); + if let Err(err) = redis + .set_ex::<_, _, ()>(&key, payload, ttl_secs.max(1)) + .await + { + warn!(error = %err, "quest cache set failed"); + } +} + +pub async fn invalidate(redis: &mut ConnectionManager, user_id: UserId) { + let key = cache_key(user_id); + if let Err(err) = redis.del::<_, ()>(&key).await { + warn!(error = %err, "quest cache del failed"); + } +} diff --git a/crates/sw-server/src/quests/catalog.rs b/crates/sw-server/src/quests/catalog.rs new file mode 100644 index 0000000..741142a --- /dev/null +++ b/crates/sw-server/src/quests/catalog.rs @@ -0,0 +1,472 @@ +//! In-code quest catalog. Claims store `quest_id`, `catalog_version`, and the +//! reward actually granted. Adding a quest is appending a [`QuestDef`]. + +use super::period::PeriodKind; +use sw_domain::USDCX_MICROS_PER_UNIT; + +pub const VERSION: i32 = 1; + +/// Bonus mission stages: (dollars, reward points). Sequential, independent +/// progress per stage. Claiming one unlocks the next at zero. +pub const PAID_STAGES: &[(i64, i32)] = &[(5, 80), (10, 120), (20, 200), (50, 400), (100, 700)]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Metric { + UsernameSet, + HostedGames, + JoinedGames, + GamesPlayed, + GamesWon, + UniqueGames, + UniqueOpponents, + PaidGames, + PaidEntryMicro, + ActiveDays, + DailyClaims, + AnyClaims, + SuccessfulReferrals, + LongestStreak, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + Fixed(i64), + /// Distinct games equal to the live registry size. + RegisteredGames, +} + +impl Target { + pub fn value(self, registered_games: usize) -> i64 { + match self { + Self::Fixed(n) => n, + Self::RegisteredGames => registered_games.max(1) as i64, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cta { + pub href: &'static str, + pub label: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuestDef { + pub id: &'static str, + pub title: &'static str, + pub description: &'static str, + pub category: PeriodKind, + pub metric: Metric, + pub target: Target, + pub reward_points: i32, + pub cta: Cta, +} + +const PLAY: Cta = Cta { + href: "/lobbies", + label: "Play", +}; +const GAMES: Cta = Cta { + href: "/games", + label: "Pick a game", +}; +const SETTINGS: Cta = Cta { + href: "/settings", + label: "Choose username", +}; +const QUESTS: Cta = Cta { + href: "/quests", + label: "View quests", +}; + +const QUESTS_STATIC: &[QuestDef] = &[ + QuestDef { + id: "gs.username", + title: "Choose your username", + description: "This is how other players see you.", + category: PeriodKind::GettingStarted, + metric: Metric::UsernameSet, + target: Target::Fixed(1), + reward_points: 40, + cta: SETTINGS, + }, + QuestDef { + id: "gs.host", + title: "Host your first match", + description: "Create a lobby and play it out.", + category: PeriodKind::GettingStarted, + metric: Metric::HostedGames, + target: Target::Fixed(1), + reward_points: 60, + cta: PLAY, + }, + QuestDef { + id: "gs.join", + title: "Join a match", + description: "Sit down in a room you didn't host.", + category: PeriodKind::GettingStarted, + metric: Metric::JoinedGames, + target: Target::Fixed(1), + reward_points: 60, + cta: PLAY, + }, + QuestDef { + id: "gs.win", + title: "Win your first match", + description: "Finish first in a real lobby.", + category: PeriodKind::GettingStarted, + metric: Metric::GamesWon, + target: Target::Fixed(1), + reward_points: 80, + cta: PLAY, + }, + QuestDef { + id: "daily.play-2", + title: "Play 2 matches", + description: "Jump into two lobbies today.", + category: PeriodKind::Daily, + metric: Metric::GamesPlayed, + target: Target::Fixed(2), + reward_points: 35, + cta: PLAY, + }, + QuestDef { + id: "daily.games-2", + title: "Try 2 different games today", + description: "Don't stick to one title.", + category: PeriodKind::Daily, + metric: Metric::UniqueGames, + target: Target::Fixed(2), + reward_points: 45, + cta: GAMES, + }, + QuestDef { + id: "daily.win-1", + title: "Win a match today", + description: "Finish first in any lobby.", + category: PeriodKind::Daily, + metric: Metric::GamesWon, + target: Target::Fixed(1), + reward_points: 50, + cta: PLAY, + }, + QuestDef { + id: "daily.opponents-3", + title: "Play against 3 different players", + description: "Three different people across today's matches.", + category: PeriodKind::Daily, + metric: Metric::UniqueOpponents, + target: Target::Fixed(3), + reward_points: 55, + cta: PLAY, + }, + QuestDef { + id: "daily.paid-1", + title: "Play a paid match", + description: "Put an entry fee on the line.", + category: PeriodKind::Daily, + metric: Metric::PaidGames, + target: Target::Fixed(1), + reward_points: 40, + cta: PLAY, + }, + QuestDef { + id: "weekly.play-15", + title: "Play 15 matches this week", + description: "Keep showing up.", + category: PeriodKind::Weekly, + metric: Metric::GamesPlayed, + target: Target::Fixed(15), + reward_points: 180, + cta: PLAY, + }, + QuestDef { + id: "weekly.win-5", + title: "Win 5 matches", + description: "Five wins this week.", + category: PeriodKind::Weekly, + metric: Metric::GamesWon, + target: Target::Fixed(5), + reward_points: 200, + cta: PLAY, + }, + QuestDef { + id: "weekly.games-3", + title: "Play 3 different games", + description: "Switch titles at least twice this week.", + category: PeriodKind::Weekly, + metric: Metric::UniqueGames, + target: Target::Fixed(3), + reward_points: 150, + cta: GAMES, + }, + QuestDef { + id: "weekly.opponents-8", + title: "Play against 8 different players", + description: "Get out of the same lobby circle.", + category: PeriodKind::Weekly, + metric: Metric::UniqueOpponents, + target: Target::Fixed(8), + reward_points: 180, + cta: PLAY, + }, + QuestDef { + id: "weekly.days-5", + title: "Keep your streak going", + description: "Play on 5 different days this week.", + category: PeriodKind::Weekly, + metric: Metric::ActiveDays, + target: Target::Fixed(5), + reward_points: 220, + cta: PLAY, + }, + QuestDef { + id: "weekly.paid-8", + title: "Play $8 this week", + description: "Paid entries this week, added up.", + category: PeriodKind::Weekly, + metric: Metric::PaidEntryMicro, + target: Target::Fixed(8 * USDCX_MICROS_PER_UNIT), + reward_points: 160, + cta: PLAY, + }, + QuestDef { + id: "weekly.dailies-4", + title: "Claim 4 daily quests", + description: "Finish and claim four dailies this week.", + category: PeriodKind::Weekly, + metric: Metric::DailyClaims, + target: Target::Fixed(4), + reward_points: 140, + cta: QUESTS, + }, + QuestDef { + id: "weekly.referral-1", + title: "Bring a player in", + description: "Someone you invited finishes Getting Started.", + category: PeriodKind::Weekly, + metric: Metric::SuccessfulReferrals, + target: Target::Fixed(1), + reward_points: 250, + cta: QUESTS, + }, + QuestDef { + id: "monthly.play-40", + title: "Play 40 matches this month", + description: "A full month of play.", + category: PeriodKind::Monthly, + metric: Metric::GamesPlayed, + target: Target::Fixed(40), + reward_points: 500, + cta: PLAY, + }, + QuestDef { + id: "monthly.win-12", + title: "Win 12 matches", + description: "Twelve wins this month.", + category: PeriodKind::Monthly, + metric: Metric::GamesWon, + target: Target::Fixed(12), + reward_points: 550, + cta: PLAY, + }, + QuestDef { + id: "monthly.games-all", + title: "Play every game", + description: "One finished match in each title this month.", + category: PeriodKind::Monthly, + metric: Metric::UniqueGames, + target: Target::RegisteredGames, + reward_points: 400, + cta: GAMES, + }, + QuestDef { + id: "monthly.opponents-15", + title: "Play against 15 different players", + description: "Widen the field this month.", + category: PeriodKind::Monthly, + metric: Metric::UniqueOpponents, + target: Target::Fixed(15), + reward_points: 500, + cta: PLAY, + }, + QuestDef { + id: "monthly.days-12", + title: "Play 12 days this month", + description: "Don't disappear for weeks at a time.", + category: PeriodKind::Monthly, + metric: Metric::ActiveDays, + target: Target::Fixed(12), + reward_points: 600, + cta: PLAY, + }, + QuestDef { + id: "monthly.paid-25", + title: "Play $25 this month", + description: "Paid entries this month, added up.", + category: PeriodKind::Monthly, + metric: Metric::PaidEntryMicro, + target: Target::Fixed(25 * USDCX_MICROS_PER_UNIT), + reward_points: 450, + cta: PLAY, + }, + QuestDef { + id: "monthly.dailies-15", + title: "Claim 15 daily quests", + description: "Fifteen dailies claimed this month.", + category: PeriodKind::Monthly, + metric: Metric::DailyClaims, + target: Target::Fixed(15), + reward_points: 400, + cta: QUESTS, + }, + QuestDef { + id: "monthly.referral-3", + title: "Bring in 3 players", + description: "Three people you invited finish Getting Started.", + category: PeriodKind::Monthly, + metric: Metric::SuccessfulReferrals, + target: Target::Fixed(3), + reward_points: 800, + cta: QUESTS, + }, + QuestDef { + id: "seasonal.streak-10", + title: "Keep a 10-day streak", + description: "Play on 10 days in a row this season.", + category: PeriodKind::Seasonal, + metric: Metric::LongestStreak, + target: Target::Fixed(10), + reward_points: 400, + cta: PLAY, + }, + QuestDef { + id: "seasonal.streak-30", + title: "Keep a 30-day streak", + description: "Play on 30 days in a row this season.", + category: PeriodKind::Seasonal, + metric: Metric::LongestStreak, + target: Target::Fixed(30), + reward_points: 1200, + cta: PLAY, + }, + QuestDef { + id: "seasonal.streak-50", + title: "Keep a 50-day streak", + description: "Play on 50 days in a row this season.", + category: PeriodKind::Seasonal, + metric: Metric::LongestStreak, + target: Target::Fixed(50), + reward_points: 2000, + cta: PLAY, + }, + QuestDef { + id: "seasonal.play-100", + title: "Play 100 matches this season", + description: "A hundred finished lobbies.", + category: PeriodKind::Seasonal, + metric: Metric::GamesPlayed, + target: Target::Fixed(100), + reward_points: 900, + cta: PLAY, + }, + QuestDef { + id: "seasonal.win-30", + title: "Win 30 matches this season", + description: "Thirty wins on the board.", + category: PeriodKind::Seasonal, + metric: Metric::GamesWon, + target: Target::Fixed(30), + reward_points: 1000, + cta: PLAY, + }, + QuestDef { + id: "seasonal.games-all", + title: "Play every game", + description: "One finished match in each title this season.", + category: PeriodKind::Seasonal, + metric: Metric::UniqueGames, + target: Target::RegisteredGames, + reward_points: 700, + cta: GAMES, + }, + QuestDef { + id: "seasonal.opponents-40", + title: "Play against 40 different players", + description: "See the field this season.", + category: PeriodKind::Seasonal, + metric: Metric::UniqueOpponents, + target: Target::Fixed(40), + reward_points: 1000, + cta: PLAY, + }, + QuestDef { + id: "seasonal.quests-40", + title: "Claim 40 quests", + description: "Forty claims this season, any kind.", + category: PeriodKind::Seasonal, + metric: Metric::AnyClaims, + target: Target::Fixed(40), + reward_points: 800, + cta: QUESTS, + }, +]; + +pub fn paid_defs() -> Vec { + const TITLES: &[&str] = &["Play $5", "Play $10", "Play $20", "Play $50", "Play $100"]; + PAID_STAGES + .iter() + .enumerate() + .map(|(i, &(dollars, reward))| QuestDef { + id: paid_id_static(i), + title: TITLES[i], + description: if i + 1 == PAID_STAGES.len() { + "The last bonus mission this season." + } else { + "Complete this bonus mission to unlock the next one." + }, + category: PeriodKind::PaidLadder, + metric: Metric::PaidEntryMicro, + target: Target::Fixed(dollars * USDCX_MICROS_PER_UNIT), + reward_points: reward, + cta: PLAY, + }) + .collect() +} + +fn paid_id_static(index: usize) -> &'static str { + match index { + 0 => "paid.volume:5", + 1 => "paid.volume:10", + 2 => "paid.volume:20", + 3 => "paid.volume:50", + 4 => "paid.volume:100", + _ => unreachable!("paid ladder has five stages"), + } +} + +pub fn all_defs() -> Vec { + let mut out = QUESTS_STATIC.to_vec(); + out.extend(paid_defs()); + out +} + +pub fn get(id: &str) -> Option { + all_defs().into_iter().find(|q| q.id == id) +} + +pub fn paid_stage_index(quest_id: &str) -> Option { + PAID_STAGES + .iter() + .enumerate() + .find(|(i, _)| paid_id_static(*i) == quest_id) + .map(|(i, _)| i) +} + +pub fn previous_paid_id(index: usize) -> Option<&'static str> { + if index == 0 { + None + } else { + Some(paid_id_static(index - 1)) + } +} diff --git a/crates/sw-server/src/quests/evaluate.rs b/crates/sw-server/src/quests/evaluate.rs new file mode 100644 index 0000000..e382d1f --- /dev/null +++ b/crates/sw-server/src/quests/evaluate.rs @@ -0,0 +1,489 @@ +//! Bucket qualifying matches into period metrics and map the catalog. + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, NaiveDate, Utc}; +use uuid::Uuid; + +use super::catalog::{self, Metric, QuestDef}; +use super::period::{self, PeriodClock, PeriodKind}; +use super::streak::{self, Streak}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum QuestState { + Locked, + Active, + Claimable, + Claimed, +} + +#[derive(Debug, Clone)] +pub struct QualifyingMatch { + pub game_id: String, + pub finished_at: DateTime, + pub is_winner: bool, + pub entry_micro: i64, + pub creator_id: Uuid, + pub player_count: i32, + pub opponents: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct PeriodBucket { + pub games_played: i64, + pub games_won: i64, + pub unique_games: HashSet, + pub unique_opponents: HashSet, + pub paid_games: i64, + pub paid_entry_micro: i64, + pub active_days: HashSet, + pub hosted: i64, + pub joined: i64, +} + +impl PeriodBucket { + fn add(&mut self, row: &QualifyingMatch, user_id: Uuid) { + if row.player_count < 2 { + return; + } + self.games_played += 1; + if row.is_winner { + self.games_won += 1; + } + self.unique_games.insert(row.game_id.clone()); + for opp in &row.opponents { + if *opp != user_id { + self.unique_opponents.insert(*opp); + } + } + if row.entry_micro > 0 { + self.paid_games += 1; + self.paid_entry_micro += row.entry_micro; + } + self.active_days.insert(row.finished_at.date_naive()); + if row.creator_id == user_id { + self.hosted += 1; + } else { + self.joined += 1; + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct GettingStartedActions { + pub username_set: bool, + pub hosted: bool, + pub joined: bool, + pub won: bool, +} + +impl GettingStartedActions { + pub fn all_done(self) -> bool { + self.username_set && self.hosted && self.joined && self.won + } +} + +#[derive(Debug, Clone, Default)] +pub struct Extras { + pub getting_started: GettingStartedActions, + pub referral_successes: i64, + pub daily_claims_in_week: i64, + pub daily_claims_in_month: i64, + pub any_claims_in_season: i64, + pub season_streak: Streak, +} + +#[derive(Debug, Clone)] +pub struct OpenPeriods { + pub daily: PeriodClock, + pub weekly: PeriodClock, + pub monthly: PeriodClock, + pub seasonal: Option, + pub paid: Option, + pub lifetime: PeriodClock, +} + +impl OpenPeriods { + pub fn current( + now: DateTime, + season_id: Option, + season_start: Option>, + season_end: Option>, + ) -> Self { + let seasonal = match (season_id, season_start, season_end) { + (Some(id), Some(start), Some(end)) => Some(period::seasonal(id, start, end)), + _ => None, + }; + let paid = seasonal.clone().map(|mut clock| { + clock.kind = PeriodKind::PaidLadder; + clock + }); + Self { + daily: period::daily(now), + weekly: period::weekly(now), + monthly: period::monthly(now), + seasonal, + paid, + lifetime: period::lifetime(), + } + } + + pub fn covering_start(&self) -> DateTime { + let mut start = self.weekly.starts_at.min(self.monthly.starts_at); + if let Some(season) = &self.seasonal { + start = start.min(season.starts_at); + } + start + } + + pub fn clock_for(&self, kind: PeriodKind) -> Option<&PeriodClock> { + match kind { + PeriodKind::GettingStarted => Some(&self.lifetime), + PeriodKind::Daily => Some(&self.daily), + PeriodKind::Weekly => Some(&self.weekly), + PeriodKind::Monthly => Some(&self.monthly), + PeriodKind::Seasonal => self.seasonal.as_ref(), + PeriodKind::PaidLadder => self.paid.as_ref(), + } + } + + pub fn daily_ids_in_week(&self) -> Vec { + (0..7) + .map(|i| { + (self.weekly.starts_at + chrono::Duration::days(i)) + .date_naive() + .format("%Y-%m-%d") + .to_string() + }) + .collect() + } + + pub fn daily_ids_in_month(&self) -> Vec { + let start = self.monthly.starts_at.date_naive(); + let end = self + .monthly + .resets_at + .map(|d| d.date_naive()) + .unwrap_or(start); + let mut ids = Vec::new(); + let mut day = start; + while day < end { + ids.push(day.format("%Y-%m-%d").to_string()); + day += chrono::Duration::days(1); + } + ids + } +} + +#[derive(Debug, Clone, Default)] +pub struct Buckets { + inner: HashMap, +} + +impl Buckets { + pub fn from_matches(user_id: Uuid, rows: &[QualifyingMatch], periods: &OpenPeriods) -> Self { + let mut inner: HashMap = HashMap::new(); + for row in rows { + if row.player_count < 2 { + continue; + } + for clock in [ + Some(&periods.daily), + Some(&periods.weekly), + Some(&periods.monthly), + periods.seasonal.as_ref(), + ] + .into_iter() + .flatten() + { + if in_window(row.finished_at, clock) { + inner.entry(clock.kind).or_default().add(row, user_id); + } + } + } + Self { inner } + } + + pub fn get(&self, kind: PeriodKind) -> PeriodBucket { + self.inner.get(&kind).cloned().unwrap_or_default() + } +} + +fn in_window(at: DateTime, clock: &PeriodClock) -> bool { + at >= clock.starts_at && clock.resets_at.is_none_or(|end| at < end) +} + +/// Paid volume that counts toward the current bonus mission stage. +/// +/// Independent of weekly / monthly / seasonal `PaidEntryMicro` buckets. +/// `after` is the previous stage's `claimed_at`: only matches strictly later +/// count, so progress never carries from one stage into the next. +pub fn bonus_paid_micro( + matches: &[QualifyingMatch], + clock: &PeriodClock, + after: Option>, +) -> i64 { + matches + .iter() + .filter(|row| row.player_count >= 2 && row.entry_micro > 0) + .filter(|row| in_window(row.finished_at, clock)) + .filter(|row| after.is_none_or(|t| row.finished_at > t)) + .map(|row| row.entry_micro) + .sum() +} + +pub fn season_dates(rows: &[QualifyingMatch], seasonal: Option<&PeriodClock>) -> Vec { + let Some(clock) = seasonal else { + return Vec::new(); + }; + let mut dates: Vec = rows + .iter() + .filter(|row| row.player_count >= 2 && in_window(row.finished_at, clock)) + .map(|row| row.finished_at.date_naive()) + .collect(); + dates.sort_unstable(); + dates.dedup(); + dates +} + +pub fn metric_value( + def: &QuestDef, + buckets: &Buckets, + extras: &Extras, + registered_games: usize, +) -> i64 { + let bucket = buckets.get(def.category); + let raw = match def.metric { + Metric::UsernameSet => i64::from(extras.getting_started.username_set), + Metric::HostedGames => i64::from(extras.getting_started.hosted), + Metric::JoinedGames => i64::from(extras.getting_started.joined), + Metric::GamesPlayed => bucket.games_played, + Metric::GamesWon => { + if def.category == PeriodKind::GettingStarted { + i64::from(extras.getting_started.won) + } else { + bucket.games_won + } + } + Metric::UniqueGames => bucket.unique_games.len() as i64, + Metric::UniqueOpponents => bucket.unique_opponents.len() as i64, + Metric::PaidGames => bucket.paid_games, + Metric::PaidEntryMicro => bucket.paid_entry_micro, + Metric::ActiveDays => bucket.active_days.len() as i64, + Metric::DailyClaims => match def.category { + PeriodKind::Weekly => extras.daily_claims_in_week, + PeriodKind::Monthly => extras.daily_claims_in_month, + _ => 0, + }, + Metric::AnyClaims => extras.any_claims_in_season, + Metric::SuccessfulReferrals => extras.referral_successes, + Metric::LongestStreak => extras.season_streak.longest as i64, + }; + let target = def.target.value(registered_games); + raw.min(target) +} + +pub fn quest_state( + def: &QuestDef, + progress: i64, + target: i64, + claimed_ids: &HashSet, + period_id: &str, +) -> QuestState { + let key = claim_key(def.id, period_id); + if claimed_ids.contains(&key) { + return QuestState::Claimed; + } + if let Some(index) = catalog::paid_stage_index(def.id) + && let Some(prev) = catalog::previous_paid_id(index) + && !claimed_ids.contains(&claim_key(prev, period_id)) + { + return QuestState::Locked; + } + if progress >= target && target > 0 { + QuestState::Claimable + } else { + QuestState::Active + } +} + +/// Claim uniqueness is `(user_id, quest_id, period_id)`. +pub fn claim_key(quest_id: &str, period_id: &str) -> String { + format!("{quest_id}:{period_id}") +} + +pub fn claimed_set(rows: &[(String, String)]) -> HashSet { + rows.iter() + .map(|(quest_id, period_id)| claim_key(quest_id, period_id)) + .collect() +} + +pub fn streak_from_matches(rows: &[QualifyingMatch], seasonal: Option<&PeriodClock>) -> Streak { + streak::from_sorted_unique_dates(&season_dates(rows, seasonal)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn at(day: u32, hour: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 9, day, hour, 0, 0).unwrap() + } + + fn match_row( + game: &str, + day: u32, + winner: bool, + creator: Uuid, + opponents: Vec, + paid: bool, + player_count: i32, + ) -> QualifyingMatch { + QualifyingMatch { + game_id: game.into(), + finished_at: at(day, 12), + is_winner: winner, + entry_micro: if paid { 1_000_000 } else { 0 }, + creator_id: creator, + player_count, + opponents, + } + } + + #[test] + fn ignores_solo_matches() { + let me = Uuid::now_v7(); + let periods = OpenPeriods::current(at(1, 15), None, None, None); + let rows = [match_row("checkers", 1, true, me, vec![], false, 1)]; + let buckets = Buckets::from_matches(me, &rows, &periods); + assert_eq!(buckets.get(PeriodKind::Daily).games_played, 0); + } + + #[test] + fn hosted_vs_joined() { + let me = Uuid::now_v7(); + let other = Uuid::now_v7(); + let periods = OpenPeriods::current(at(1, 15), None, None, None); + let rows = [ + match_row("checkers", 1, false, me, vec![other], false, 2), + match_row("ludo", 1, true, other, vec![me], false, 2), + ]; + let buckets = Buckets::from_matches(me, &rows, &periods); + let daily = buckets.get(PeriodKind::Daily); + assert_eq!(daily.hosted, 1); + assert_eq!(daily.joined, 1); + assert_eq!(daily.games_won, 1); + assert_eq!(daily.unique_games.len(), 2); + assert_eq!(daily.unique_opponents.len(), 1); + } + + #[test] + fn paid_stage_locked_until_previous_claimed() { + let def = catalog::get("paid.volume:10").unwrap(); + let claimed = HashSet::new(); + let state = quest_state(&def, 10_000_000, 10_000_000, &claimed, "season:3"); + assert_eq!(state, QuestState::Locked); + + let mut claimed = HashSet::new(); + claimed.insert(claim_key("paid.volume:5", "season:3")); + let state = quest_state(&def, 10_000_000, 10_000_000, &claimed, "season:3"); + assert_eq!(state, QuestState::Claimable); + } + + #[test] + fn bonus_stage_progress_does_not_carry() { + let other = Uuid::now_v7(); + let me = Uuid::now_v7(); + let season_start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(); + let season_end = Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap(); + let clock = period::paid_ladder(3, season_start, season_end); + let rows = [ + match_row("checkers", 1, false, me, vec![other], true, 2), + match_row("checkers", 2, false, me, vec![other], true, 2), + match_row("checkers", 2, false, me, vec![other], true, 2), + match_row("checkers", 2, false, me, vec![other], true, 2), + match_row("checkers", 2, false, me, vec![other], true, 2), + ]; + // Five $1 matches before the $5 claim — enough for stage 1, not carried forward. + assert_eq!(bonus_paid_micro(&rows, &clock, None), 5_000_000); + + let claimed_at = at(2, 13); + assert_eq!(bonus_paid_micro(&rows, &clock, Some(claimed_at)), 0); + + let mut next = rows.to_vec(); + next.push(QualifyingMatch { + game_id: "ludo".into(), + finished_at: at(3, 12), + is_winner: false, + entry_micro: 10_000_000, + creator_id: me, + player_count: 2, + opponents: vec![other], + }); + assert_eq!( + bonus_paid_micro(&next, &clock, Some(claimed_at)), + 10_000_000 + ); + } + + #[test] + fn bonus_progress_is_not_the_weekly_paid_bucket() { + let me = Uuid::now_v7(); + let other = Uuid::now_v7(); + let periods = OpenPeriods::current(at(1, 15), Some(3), Some(at(1, 0)), Some(at(30, 0))); + let rows = [match_row("checkers", 1, false, me, vec![other], true, 2)]; + let buckets = Buckets::from_matches(me, &rows, &periods); + assert_eq!(buckets.get(PeriodKind::Weekly).paid_entry_micro, 1_000_000); + assert_eq!(buckets.get(PeriodKind::Monthly).paid_entry_micro, 1_000_000); + assert_eq!( + buckets.get(PeriodKind::Seasonal).paid_entry_micro, + 1_000_000 + ); + assert_eq!(buckets.get(PeriodKind::PaidLadder).paid_entry_micro, 0); + let clock = periods.paid.as_ref().unwrap(); + assert_eq!(bonus_paid_micro(&rows, clock, None), 1_000_000); + } + + #[test] + fn getting_started_progress_is_actions_not_claims() { + let extras = Extras { + getting_started: GettingStartedActions { + username_set: true, + hosted: true, + joined: true, + won: true, + }, + ..Default::default() + }; + assert!(extras.getting_started.all_done()); + let def = catalog::get("gs.username").unwrap(); + let buckets = Buckets::default(); + assert_eq!(metric_value(&def, &buckets, &extras, 4), 1); + let state = quest_state(&def, 1, 1, &HashSet::new(), "lifetime"); + assert_eq!(state, QuestState::Claimable); + } + + #[test] + fn referral_metric_uses_successful_count() { + let def = catalog::get("weekly.referral-1").unwrap(); + let extras = Extras { + referral_successes: 0, + ..Default::default() + }; + assert_eq!(metric_value(&def, &Buckets::default(), &extras, 4), 0); + let extras = Extras { + referral_successes: 1, + ..Default::default() + }; + assert_eq!(metric_value(&def, &Buckets::default(), &extras, 4), 1); + } + + #[test] + fn unique_games_target_follows_registry() { + let def = catalog::get("monthly.games-all").unwrap(); + assert_eq!(def.target.value(4), 4); + assert_eq!(def.target.value(2), 2); + } +} diff --git a/crates/sw-server/src/quests/ingest.rs b/crates/sw-server/src/quests/ingest.rs new file mode 100644 index 0000000..893990b --- /dev/null +++ b/crates/sw-server/src/quests/ingest.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use redis::aio::ConnectionManager; +use sqlx::PgPool; +use sw_domain::UserId; +use tracing::warn; + +use crate::data::quest_claims::PgQuestRepo; +use crate::services::realtime; +use crate::ws::{SessionManager, SubscriptionManager}; + +/// After a successful match-history write: cache DEL, Getting Started stamp, WS. +/// Safe to retry. Does not touch `user_game_stats`. +pub fn spawn_after_match( + db: PgPool, + redis: ConnectionManager, + sessions: Arc, + subscriptions: Arc, + user_ids: Vec, +) { + tokio::spawn(async move { + let repo = PgQuestRepo::new(db); + for user_id in user_ids { + let mut redis = redis.clone(); + crate::quests::cache::invalidate(&mut redis, user_id).await; + if let Err(err) = repo.maybe_stamp_getting_started(user_id).await { + warn!(user_id = %user_id, error = %err, "quest getting-started stamp failed"); + } + realtime::publish_quest_updated_raw(&subscriptions, &sessions, user_id); + } + }); +} diff --git a/crates/sw-server/src/quests/mod.rs b/crates/sw-server/src/quests/mod.rs new file mode 100644 index 0000000..94ce68e --- /dev/null +++ b/crates/sw-server/src/quests/mod.rs @@ -0,0 +1,161 @@ +pub mod cache; +pub mod catalog; +pub mod evaluate; +pub mod ingest; +pub mod period; +pub mod streak; +pub mod view; + +use chrono::Utc; +use redis::aio::ConnectionManager; +use sqlx::{PgPool, Postgres, Transaction}; +use sw_domain::{Season, UserId}; + +use crate::data::quest_claims::{self, PgQuestRepo}; +use crate::data::users::{PgUserRepo, QuestFlags}; +use crate::error::AppResult; +use crate::quests::evaluate::{Extras, OpenPeriods}; +use crate::quests::view::{AssembleInput, QuestMeResponse, assemble}; + +pub async fn load_me( + db: &PgPool, + redis: &mut ConnectionManager, + user_id: UserId, + season: Option<&Season>, + registered_games: usize, + use_cache: bool, +) -> AppResult { + let now = Utc::now(); + let stamped = PgQuestRepo::new(db.clone()) + .maybe_stamp_getting_started(user_id) + .await?; + + if use_cache + && !stamped + && let Some(raw) = cache::get_json(redis, user_id).await + && let Ok(cached) = serde_json::from_str::(&raw) + { + return Ok(cached); + } + + let snapshot = load_from_db(db, user_id, season, registered_games, now).await?; + let ttl = period::cache_ttl_secs(now); + if let Ok(raw) = serde_json::to_string(&snapshot) { + cache::set_json(redis, user_id, &raw, ttl).await; + } + Ok(snapshot) +} + +pub async fn load_from_db( + db: &PgPool, + user_id: UserId, + season: Option<&Season>, + registered_games: usize, + now: chrono::DateTime, +) -> AppResult { + let periods = OpenPeriods::current( + now, + season.map(|s| s.id.as_i32()), + season.map(|s| s.starts_at), + season.map(|s| s.ends_at), + ); + let since = periods.covering_start(); + let repo = PgQuestRepo::new(db.clone()); + let users = PgUserRepo::new(db.clone()); + + let week_ids = periods.daily_ids_in_week(); + let month_ids = periods.daily_ids_in_month(); + let (matches, claims, flags, referrals, daily_week, daily_month, season_claims) = tokio::try_join!( + repo.qualifying_matches(user_id, since), + repo.claims_for_user(user_id), + users.quest_flags(user_id), + repo.successful_referral_count(user_id), + repo.daily_claim_count(user_id, &week_ids), + repo.daily_claim_count(user_id, &month_ids), + async { + match season.map(|s| s.id.as_i32()) { + Some(id) => repo.season_claim_count(user_id, id).await, + None => Ok(0), + } + }, + )?; + + let flags = flags.ok_or(crate::error::AppError::NotFound("user"))?; + let gs = repo.getting_started_actions(user_id).await?; + let extras = Extras { + getting_started: gs, + referral_successes: referrals, + daily_claims_in_week: daily_week, + daily_claims_in_month: daily_month, + any_claims_in_season: season_claims, + season_streak: Default::default(), + }; + + Ok(assemble(AssembleInput { + user_id, + now, + season, + registered_games, + matches: &matches, + claims: &claims, + flags: &flags, + extras, + })) +} + +pub async fn load_from_tx( + tx: &mut Transaction<'_, Postgres>, + user_id: UserId, + season: Option<&Season>, + registered_games: usize, + now: chrono::DateTime, +) -> AppResult { + let periods = OpenPeriods::current( + now, + season.map(|s| s.id.as_i32()), + season.map(|s| s.starts_at), + season.map(|s| s.ends_at), + ); + let since = periods.covering_start(); + let week_ids = periods.daily_ids_in_week(); + let month_ids = periods.daily_ids_in_month(); + + let matches = quest_claims::qualifying_matches(&mut **tx, user_id, since).await?; + let claims = quest_claims::claims_for_user(&mut **tx, user_id).await?; + let flags = quest_flags_tx(tx, user_id).await?; + let referrals = quest_claims::successful_referral_count(&mut **tx, user_id).await?; + let daily_week = quest_claims::daily_claim_count(&mut **tx, user_id, &week_ids).await?; + let daily_month = quest_claims::daily_claim_count(&mut **tx, user_id, &month_ids).await?; + let season_claims = match season.map(|s| s.id.as_i32()) { + Some(id) => quest_claims::season_claim_count(&mut **tx, user_id, id).await?, + None => 0, + }; + let gs = quest_claims::getting_started_actions(&mut **tx, user_id).await?; + + Ok(assemble(AssembleInput { + user_id, + now, + season, + registered_games, + matches: &matches, + claims: &claims, + flags: &flags, + extras: Extras { + getting_started: gs, + referral_successes: referrals, + daily_claims_in_week: daily_week, + daily_claims_in_month: daily_month, + any_claims_in_season: season_claims, + season_streak: Default::default(), + }, + })) +} + +async fn quest_flags_tx( + tx: &mut Transaction<'_, Postgres>, + user_id: UserId, +) -> AppResult { + PgUserRepo::quest_flags_on(&mut **tx, user_id) + .await? + .ok_or(crate::error::AppError::NotFound("user")) +} diff --git a/crates/sw-server/src/quests/period.rs b/crates/sw-server/src/quests/period.rs new file mode 100644 index 0000000..b658633 --- /dev/null +++ b/crates/sw-server/src/quests/period.rs @@ -0,0 +1,159 @@ +//! UTC quest periods. Client-supplied dates are never trusted. + +use chrono::{DateTime, Datelike, Duration, TimeZone, Utc}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PeriodKind { + GettingStarted, + Daily, + Weekly, + Monthly, + Seasonal, + PaidLadder, +} + +impl PeriodKind { + pub fn as_str(self) -> &'static str { + match self { + Self::GettingStarted => "getting_started", + Self::Daily => "daily", + Self::Weekly => "weekly", + Self::Monthly => "monthly", + Self::Seasonal => "seasonal", + Self::PaidLadder => "paid_ladder", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeriodClock { + pub kind: PeriodKind, + pub id: String, + pub starts_at: DateTime, + pub resets_at: Option>, +} + +pub fn daily(now: DateTime) -> PeriodClock { + let day = now.date_naive(); + let starts_at = Utc.from_utc_datetime(&day.and_hms_opt(0, 0, 0).expect("midnight")); + let resets_at = starts_at + Duration::days(1); + PeriodClock { + kind: PeriodKind::Daily, + id: day.format("%Y-%m-%d").to_string(), + starts_at, + resets_at: Some(resets_at), + } +} + +pub fn weekly(now: DateTime) -> PeriodClock { + let iso = now.iso_week(); + let id = format!("{}-W{:02}", iso.year(), iso.week()); + let weekday = now.weekday().num_days_from_monday() as i64; + let day = now.date_naive() - Duration::days(weekday); + let starts_at = Utc.from_utc_datetime(&day.and_hms_opt(0, 0, 0).expect("midnight")); + PeriodClock { + kind: PeriodKind::Weekly, + id, + starts_at, + resets_at: Some(starts_at + Duration::weeks(1)), + } +} + +pub fn monthly(now: DateTime) -> PeriodClock { + let starts_at = Utc + .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) + .single() + .expect("month start"); + let next = if now.month() == 12 { + Utc.with_ymd_and_hms(now.year() + 1, 1, 1, 0, 0, 0) + } else { + Utc.with_ymd_and_hms(now.year(), now.month() + 1, 1, 0, 0, 0) + } + .single() + .expect("next month"); + PeriodClock { + kind: PeriodKind::Monthly, + id: format!("{}-{:02}", now.year(), now.month()), + starts_at, + resets_at: Some(next), + } +} + +pub fn seasonal(season_id: i32, starts_at: DateTime, ends_at: DateTime) -> PeriodClock { + PeriodClock { + kind: PeriodKind::Seasonal, + id: format!("season:{season_id}"), + starts_at, + resets_at: Some(ends_at), + } +} + +pub fn lifetime() -> PeriodClock { + PeriodClock { + kind: PeriodKind::GettingStarted, + id: "lifetime".into(), + starts_at: DateTime::::UNIX_EPOCH, + resets_at: None, + } +} + +pub fn paid_ladder( + season_id: i32, + starts_at: DateTime, + ends_at: DateTime, +) -> PeriodClock { + let mut clock = seasonal(season_id, starts_at, ends_at); + clock.kind = PeriodKind::PaidLadder; + clock +} + +/// Covering window so daily/weekly/monthly all sit in one match query. +pub fn covering_start( + week: &PeriodClock, + month: &PeriodClock, + season: &PeriodClock, +) -> DateTime { + week.starts_at.min(month.starts_at).min(season.starts_at) +} + +pub fn cache_ttl_secs(now: DateTime) -> u64 { + let day = daily(now); + let until_midnight = day + .resets_at + .map(|end| (end - now).num_seconds().max(1) as u64) + .unwrap_or(600); + until_midnight.min(600) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn daily_id_and_reset() { + let now = Utc.with_ymd_and_hms(2026, 9, 1, 15, 30, 0).unwrap(); + let p = daily(now); + assert_eq!(p.id, "2026-09-01"); + assert_eq!(p.resets_at.unwrap().date_naive().to_string(), "2026-09-02"); + } + + #[test] + fn iso_week_straddles_year() { + let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(); + let p = weekly(now); + assert!(p.id.starts_with("2026-W") || p.id.starts_with("2025-W")); + assert_eq!(p.starts_at.weekday().num_days_from_monday(), 0); + } + + #[test] + fn monthly_september() { + let now = Utc.with_ymd_and_hms(2026, 9, 15, 1, 0, 0).unwrap(); + let p = monthly(now); + assert_eq!(p.id, "2026-09"); + assert_eq!( + p.resets_at.unwrap(), + Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap() + ); + } +} diff --git a/crates/sw-server/src/quests/streak.rs b/crates/sw-server/src/quests/streak.rs new file mode 100644 index 0000000..5f1c467 --- /dev/null +++ b/crates/sw-server/src/quests/streak.rs @@ -0,0 +1,92 @@ +//! Streak walks over distinct UTC play dates. No extra table. + +use chrono::NaiveDate; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Streak { + pub current: i32, + pub longest: i32, + pub last_active_date: Option, +} + +pub fn from_sorted_unique_dates(dates: &[NaiveDate]) -> Streak { + if dates.is_empty() { + return Streak { + current: 0, + longest: 0, + last_active_date: None, + }; + } + + let mut longest = 1; + let mut run = 1; + for window in dates.windows(2) { + let gap = (window[1] - window[0]).num_days(); + if gap == 1 { + run += 1; + longest = longest.max(run); + } else if gap > 1 { + run = 1; + } + } + + let last = *dates.last().expect("non-empty"); + let mut current = 1; + for i in (1..dates.len()).rev() { + let gap = (dates[i] - dates[i - 1]).num_days(); + if gap == 1 { + current += 1; + } else if gap > 1 { + break; + } + } + + Streak { + current, + longest, + last_active_date: Some(last), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + #[test] + fn empty() { + let s = from_sorted_unique_dates(&[]); + assert_eq!(s.current, 0); + assert_eq!(s.longest, 0); + assert!(s.last_active_date.is_none()); + } + + #[test] + fn consecutive_and_gap() { + let dates = [d(2026, 9, 1), d(2026, 9, 2), d(2026, 9, 4)]; + let s = from_sorted_unique_dates(&dates); + assert_eq!(s.current, 1); + assert_eq!(s.longest, 2); + assert_eq!(s.last_active_date, Some(d(2026, 9, 4))); + } + + #[test] + fn current_is_tail_run() { + let dates = [d(2026, 9, 1), d(2026, 9, 3), d(2026, 9, 4), d(2026, 9, 5)]; + let s = from_sorted_unique_dates(&dates); + assert_eq!(s.current, 3); + assert_eq!(s.longest, 3); + } + + #[test] + fn same_day_deduped_upstream() { + let dates = [d(2026, 9, 1)]; + let s = from_sorted_unique_dates(&dates); + assert_eq!(s.current, 1); + assert_eq!(s.longest, 1); + } +} diff --git a/crates/sw-server/src/quests/view.rs b/crates/sw-server/src/quests/view.rs new file mode 100644 index 0000000..d975129 --- /dev/null +++ b/crates/sw-server/src/quests/view.rs @@ -0,0 +1,429 @@ +//! Assembled `GET /quests/me` payload. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sw_domain::{Season, UserId}; + +use crate::data::quest_claims::QuestClaimRow; +use crate::data::users::QuestFlags; +use crate::quests::catalog::{self, QuestDef}; +use crate::quests::evaluate::{ + Buckets, Extras, OpenPeriods, QualifyingMatch, QuestState, bonus_paid_micro, claimed_set, + metric_value, quest_state, streak_from_matches, +}; +use crate::quests::period::PeriodClock; +use crate::quests::streak::Streak; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CtaView { + pub href: String, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeriodView { + pub kind: crate::quests::period::PeriodKind, + pub id: String, + pub starts_at: DateTime, + pub resets_at: Option>, + pub resets_label: String, +} + +impl From<&PeriodClock> for PeriodView { + fn from(clock: &PeriodClock) -> Self { + Self { + kind: clock.kind, + id: clock.id.clone(), + starts_at: clock.starts_at, + resets_at: clock.resets_at, + resets_label: "Resets 00:00 UTC".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuestView { + pub id: String, + pub title: String, + pub description: String, + pub category: crate::quests::period::PeriodKind, + pub progress: i64, + pub target: i64, + pub state: QuestState, + pub reward_points: i32, + pub cta: CtaView, + pub period_id: String, + pub resets_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuestMeResponse { + pub catalog_version: i32, + pub now: DateTime, + pub periods: Vec, + pub streak: StreakView, + pub getting_started_completed: bool, + pub getting_started_completed_at: Option>, + pub referral_prompt_status: String, + pub quest_intro_seen_at: Option>, + pub successful_referrals: i64, + #[serde(default)] + pub season_quest_points: i64, + pub quests: Vec, + #[serde(default)] + pub bonus_mission: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BonusMissionView { + #[serde(flatten)] + pub quest: QuestView, + pub stage_index: i32, + pub stage_count: i32, + pub dollars: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreakView { + pub current: i32, + pub longest: i32, + pub last_active_date: Option, +} + +impl From for StreakView { + fn from(s: Streak) -> Self { + Self { + current: s.current, + longest: s.longest, + last_active_date: s.last_active_date.map(|d| d.to_string()), + } + } +} + +pub struct AssembleInput<'a> { + pub user_id: UserId, + pub now: DateTime, + pub season: Option<&'a Season>, + pub registered_games: usize, + pub matches: &'a [QualifyingMatch], + pub claims: &'a [QuestClaimRow], + pub flags: &'a QuestFlags, + pub extras: Extras, +} + +pub fn assemble(input: AssembleInput<'_>) -> QuestMeResponse { + let season_id = input.season.map(|s| s.id.as_i32()); + let periods = OpenPeriods::current( + input.now, + season_id, + input.season.map(|s| s.starts_at), + input.season.map(|s| s.ends_at), + ); + let buckets = Buckets::from_matches(input.user_id.as_uuid(), input.matches, &periods); + let claimed = claimed_set( + &input + .claims + .iter() + .map(|c| (c.quest_id.clone(), c.period_id.clone())) + .collect::>(), + ); + let mut extras = input.extras; + extras.season_streak = streak_from_matches(input.matches, periods.seasonal.as_ref()); + extras.getting_started.username_set |= input.flags.username_set; + + let mut period_views = vec![ + PeriodView::from(&periods.lifetime), + PeriodView::from(&periods.daily), + PeriodView::from(&periods.weekly), + PeriodView::from(&periods.monthly), + ]; + if let Some(season) = &periods.seasonal { + period_views.push(PeriodView::from(season)); + } + + let mut quests = Vec::new(); + for def in catalog::all_defs() { + if def.category == crate::quests::period::PeriodKind::PaidLadder { + continue; + } + let Some(clock) = periods.clock_for(def.category) else { + continue; + }; + quests.push(view_for( + &def, + clock, + &buckets, + &extras, + &claimed, + input.registered_games, + )); + } + + let bonus_mission = + current_bonus_mission(periods.paid.as_ref(), input.matches, input.claims, &claimed); + + let season_quest_points = input + .claims + .iter() + .filter(|c| season_id.is_some() && c.season_id == season_id) + .map(|c| i64::from(c.reward_points)) + .sum(); + + QuestMeResponse { + catalog_version: catalog::VERSION, + now: input.now, + periods: period_views, + streak: extras.season_streak.into(), + getting_started_completed: extras.getting_started.all_done() + || input.flags.getting_started_completed_at.is_some(), + getting_started_completed_at: input.flags.getting_started_completed_at, + referral_prompt_status: input.flags.referral_prompt_status.clone(), + quest_intro_seen_at: input.flags.quest_intro_seen_at, + successful_referrals: extras.referral_successes, + season_quest_points, + quests, + bonus_mission, + } +} + +fn view_for( + def: &QuestDef, + clock: &PeriodClock, + buckets: &Buckets, + extras: &Extras, + claimed: &std::collections::HashSet, + registered_games: usize, +) -> QuestView { + let target = def.target.value(registered_games); + let progress = metric_value(def, buckets, extras, registered_games); + let state = quest_state(def, progress, target, claimed, &clock.id); + quest_view(def, clock, progress, target, state) +} + +fn quest_view( + def: &QuestDef, + clock: &PeriodClock, + progress: i64, + target: i64, + state: QuestState, +) -> QuestView { + QuestView { + id: def.id.to_owned(), + title: def.title.to_owned(), + description: def.description.to_owned(), + category: def.category, + progress, + target, + state, + reward_points: def.reward_points, + cta: CtaView { + href: def.cta.href.to_owned(), + label: def.cta.label.to_owned(), + }, + period_id: clock.id.clone(), + resets_at: clock.resets_at, + } +} + +fn current_bonus_mission( + clock: Option<&PeriodClock>, + matches: &[QualifyingMatch], + claims: &[QuestClaimRow], + claimed: &std::collections::HashSet, +) -> Option { + let clock = clock?; + let stage_count = catalog::PAID_STAGES.len() as i32; + for (index, def) in catalog::paid_defs().into_iter().enumerate() { + if claimed.contains(&crate::quests::evaluate::claim_key(def.id, &clock.id)) { + continue; + } + let after = catalog::previous_paid_id(index).and_then(|prev| { + claims + .iter() + .find(|c| c.quest_id == prev && c.period_id == clock.id) + .map(|c| c.claimed_at) + }); + let target = def.target.value(1); + let progress = bonus_paid_micro(matches, clock, after).min(target); + let state = quest_state(&def, progress, target, claimed, &clock.id); + return Some(BonusMissionView { + quest: quest_view(&def, clock, progress, target, state), + stage_index: index as i32, + stage_count, + dollars: catalog::PAID_STAGES[index].0, + }); + } + None +} + +pub fn quest_view_for<'a>(me: &'a QuestMeResponse, quest_id: &str) -> Option<&'a QuestView> { + me.quests.iter().find(|q| q.id == quest_id).or_else(|| { + me.bonus_mission + .as_ref() + .filter(|b| b.quest.id == quest_id) + .map(|b| &b.quest) + }) +} + +pub fn paid_period_id(me: &QuestMeResponse) -> Option<&str> { + me.bonus_mission + .as_ref() + .map(|b| b.quest.period_id.as_str()) + .or_else(|| { + me.periods + .iter() + .find(|p| { + matches!( + p.kind, + crate::quests::period::PeriodKind::Seasonal + | crate::quests::period::PeriodKind::PaidLadder + ) + }) + .map(|p| p.id.as_str()) + }) +} + +pub fn is_claimable<'a>(me: &'a QuestMeResponse, quest_id: &str) -> Option<&'a QuestView> { + quest_view_for(me, quest_id).filter(|q| q.state == QuestState::Claimable) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use sw_domain::SeasonId; + use uuid::Uuid; + + use crate::quests::evaluate::QuestState; + + fn at(day: u32, hour: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 9, day, hour, 0, 0).unwrap() + } + + fn season() -> Season { + Season { + id: SeasonId(3), + name: "Test".into(), + description: None, + starts_at: at(1, 0), + ends_at: Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap(), + created_at: at(1, 0), + } + } + + fn flags() -> QuestFlags { + QuestFlags { + username_set: false, + referral_prompt_status: "skipped".into(), + quest_intro_seen_at: None, + getting_started_completed_at: None, + } + } + + fn paid_match(me: Uuid, other: Uuid, day: u32, hour: u32, micro: i64) -> QualifyingMatch { + QualifyingMatch { + game_id: "checkers".into(), + finished_at: at(day, hour), + is_winner: false, + entry_micro: micro, + creator_id: me, + player_count: 2, + opponents: vec![other], + } + } + + fn claim_row( + quest_id: &str, + period_id: &str, + at_time: DateTime, + points: i32, + ) -> QuestClaimRow { + QuestClaimRow { + id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + quest_id: quest_id.into(), + period_kind: "paid_ladder".into(), + period_id: period_id.into(), + season_id: Some(3), + reward_points: points, + catalog_version: 1, + claimed_at: at_time, + } + } + + #[test] + fn bonus_mission_shows_only_current_stage_with_fresh_progress() { + let me = UserId::new(); + let other = Uuid::now_v7(); + let season = season(); + let matches = [ + paid_match(me.as_uuid(), other, 1, 12, 5_000_000), + paid_match(me.as_uuid(), other, 2, 12, 5_000_000), + ]; + let flags = flags(); + let first = assemble(AssembleInput { + user_id: me, + now: at(2, 15), + season: Some(&season), + registered_games: 4, + matches: &matches, + claims: &[], + flags: &flags, + extras: Extras::default(), + }); + assert!( + first + .quests + .iter() + .all(|q| q.category != crate::quests::period::PeriodKind::PaidLadder) + ); + let bonus = first.bonus_mission.as_ref().expect("stage 1 visible"); + assert_eq!(bonus.quest.id, "paid.volume:5"); + assert_eq!(bonus.dollars, 5); + assert_eq!(bonus.quest.progress, 5_000_000); + assert_eq!(bonus.quest.state, QuestState::Claimable); + + let weekly_paid = first + .quests + .iter() + .find(|q| q.id == "weekly.paid-8") + .expect("weekly paid stays independent"); + assert_eq!(weekly_paid.progress, 8_000_000); + assert_eq!(weekly_paid.state, QuestState::Claimable); + + let claims = [claim_row( + "paid.volume:5", + &bonus.quest.period_id, + at(2, 13), + 80, + )]; + let second = assemble(AssembleInput { + user_id: me, + now: at(2, 15), + season: Some(&season), + registered_games: 4, + matches: &matches, + claims: &claims, + flags: &flags, + extras: Extras::default(), + }); + let next = second.bonus_mission.as_ref().expect("stage 2 visible"); + assert_eq!(next.quest.id, "paid.volume:10"); + assert_eq!(next.dollars, 10); + assert_eq!(next.quest.progress, 0); + assert_eq!(next.quest.state, QuestState::Active); + assert_eq!(second.season_quest_points, 80); + let weekly_after = second + .quests + .iter() + .find(|q| q.id == "weekly.paid-8") + .unwrap(); + assert_eq!(weekly_after.progress, 8_000_000); + } +} diff --git a/crates/sw-server/src/routes/leaderboard.rs b/crates/sw-server/src/routes/leaderboard.rs index ba2181f..e98b8de 100644 --- a/crates/sw-server/src/routes/leaderboard.rs +++ b/crates/sw-server/src/routes/leaderboard.rs @@ -4,6 +4,7 @@ use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use sw_domain::{GameId, LeaderboardEntry, SeasonId}; +use crate::data::quest_claims::PgQuestRepo; use crate::data::seasons::{PgSeasonRepo, SeasonRepo}; use crate::data::stats::PgStatsRepo; use crate::error::{AppError, AppResult}; @@ -15,11 +16,22 @@ pub fn router() -> Router { .route("/seasons/{season_id}", get(season_leaderboard)) } +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum Board { + #[default] + Game, + Quests, + All, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct LeaderboardQuery { season_id: Option, game_id: Option, + #[serde(default)] + board: Board, #[serde(default = "default_limit")] limit: i64, #[serde(default)] @@ -60,19 +72,34 @@ async fn fetch_leaderboard( state: &AppState, season_id: SeasonId, game_id: Option, + board: Board, limit: i64, offset: i64, ) -> AppResult { let (limit, offset) = clamp_page(limit, offset); - let stats = PgStatsRepo::new(state.db.clone()); - let (items, total) = if let Some(raw) = game_id { - let game_id = GameId::new(raw).map_err(|e| AppError::BadRequest(e.to_string()))?; - stats - .leaderboard_by_game(season_id, &game_id, limit, offset) - .await? - } else { - stats.leaderboard_overall(season_id, limit, offset).await? + let (items, total) = match board { + Board::Game => { + let stats = PgStatsRepo::new(state.db.clone()); + if let Some(raw) = game_id { + let game_id = GameId::new(raw).map_err(|e| AppError::BadRequest(e.to_string()))?; + stats + .leaderboard_by_game(season_id, &game_id, limit, offset) + .await? + } else { + stats.leaderboard_overall(season_id, limit, offset).await? + } + } + Board::Quests => { + PgQuestRepo::new(state.db.clone()) + .leaderboard_quests(season_id, limit, offset) + .await? + } + Board::All => { + PgQuestRepo::new(state.db.clone()) + .leaderboard_all(season_id, limit, offset) + .await? + } }; Ok(LeaderboardResponse { @@ -88,8 +115,15 @@ async fn leaderboard( Query(query): Query, ) -> AppResult> { let season_id = resolve_season_id(&state, query.season_id).await?; - let page = - fetch_leaderboard(&state, season_id, query.game_id, query.limit, query.offset).await?; + let page = fetch_leaderboard( + &state, + season_id, + query.game_id, + query.board, + query.limit, + query.offset, + ) + .await?; Ok(Json(page)) } @@ -102,6 +136,7 @@ async fn season_leaderboard( &state, SeasonId(season_id), query.game_id, + query.board, query.limit, query.offset, ) diff --git a/crates/sw-server/src/routes/mod.rs b/crates/sw-server/src/routes/mod.rs index 26f65c4..80c23ff 100644 --- a/crates/sw-server/src/routes/mod.rs +++ b/crates/sw-server/src/routes/mod.rs @@ -3,6 +3,7 @@ mod games; mod health; mod leaderboard; mod lobbies; +mod quests; mod seasons; mod users; mod wallet; @@ -30,6 +31,7 @@ pub fn router(state: AppState) -> Router { let write = Router::new() .nest("/lobbies", lobbies::write_router()) .nest("/users", users::write_router()) + .nest("/quests", quests::write_router()) .nest("/wallet", wallet::write_router()) .nest("/admin", admin::write_router()) .layer(middleware::from_fn_with_state( @@ -41,6 +43,7 @@ pub fn router(state: AppState) -> Router { .merge(sensitive) .merge(write) .nest("/users", users::read_router()) + .nest("/quests", quests::read_router()) .nest("/games", games::router()) .nest("/lobbies", lobbies::read_router()) .nest("/seasons", seasons::router()) diff --git a/crates/sw-server/src/routes/quests.rs b/crates/sw-server/src/routes/quests.rs new file mode 100644 index 0000000..21c3913 --- /dev/null +++ b/crates/sw-server/src/routes/quests.rs @@ -0,0 +1,171 @@ +use axum::extract::State; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::data::quest_claims::{NewQuestClaim, PgQuestRepo, QuestClaimRow}; +use crate::data::seasons::{PgSeasonRepo, SeasonRepo}; +use crate::error::{AppError, AppResult}; +use crate::quests::catalog; +use crate::quests::evaluate::QuestState; +use crate::quests::view::{QuestMeResponse, is_claimable, paid_period_id, quest_view_for}; +use crate::quests::{self, cache}; +use crate::services::realtime; +use crate::state::AppState; + +pub fn read_router() -> Router { + Router::new().route("/me", get(get_me)) +} + +pub fn write_router() -> Router { + Router::new().route("/claims", post(claim)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClaimBody { + quest_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaimView { + id: Uuid, + quest_id: String, + period_id: String, + reward_points: i32, + claimed_at: chrono::DateTime, + already_claimed: bool, +} + +impl ClaimView { + fn from_row(row: QuestClaimRow, already_claimed: bool) -> Self { + Self { + id: row.id, + quest_id: row.quest_id, + period_id: row.period_id, + reward_points: row.reward_points, + claimed_at: row.claimed_at, + already_claimed, + } + } +} + +async fn get_me(State(state): State, auth: AuthUser) -> AppResult> { + let season = PgSeasonRepo::new(state.db.clone()).current().await?; + let mut redis = state.redis.clone(); + let snapshot = quests::load_me( + &state.db, + &mut redis, + auth.user_id, + season.as_ref(), + state.games.len(), + true, + ) + .await?; + Ok(Json(snapshot)) +} + +async fn claim( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> AppResult> { + let quest_id = body.quest_id.trim(); + if quest_id.is_empty() { + return Err(AppError::BadRequest("questId is required".into())); + } + let def = catalog::get(quest_id).ok_or(AppError::NotFound("quest"))?; + let season = PgSeasonRepo::new(state.db.clone()).current().await?; + let now = Utc::now(); + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::Internal(err.into()))?; + + let snapshot = quests::load_from_tx( + &mut tx, + auth.user_id, + season.as_ref(), + state.games.len(), + now, + ) + .await?; + + let view = match quest_view_for(&snapshot, def.id) { + Some(view) => view, + None if catalog::paid_stage_index(def.id).is_some() => { + let period_id = paid_period_id(&snapshot); + if let Some(period_id) = period_id + && let Some(existing) = + PgQuestRepo::get_claim(&mut tx, auth.user_id, def.id, period_id).await? + { + tx.commit() + .await + .map_err(|err| AppError::Internal(err.into()))?; + return Ok(Json(ClaimView::from_row(existing, true))); + } + tx.commit() + .await + .map_err(|err| AppError::Internal(err.into()))?; + return Err(AppError::Conflict("not_complete".into())); + } + None => return Err(AppError::NotFound("quest")), + }; + + if view.state == QuestState::Claimed { + let existing = PgQuestRepo::get_claim(&mut tx, auth.user_id, def.id, &view.period_id) + .await? + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("claimed quest missing row")))?; + tx.commit() + .await + .map_err(|err| AppError::Internal(err.into()))?; + return Ok(Json(ClaimView::from_row(existing, true))); + } + + if is_claimable(&snapshot, def.id).is_none() { + tx.commit() + .await + .map_err(|err| AppError::Internal(err.into()))?; + return Err(AppError::Conflict("not_complete".into())); + } + + let inserted = PgQuestRepo::insert_claim( + &mut tx, + &NewQuestClaim { + user_id: auth.user_id, + quest_id: def.id.to_owned(), + period_kind: def.category.as_str().to_owned(), + period_id: view.period_id.clone(), + season_id: season.as_ref().map(|s| s.id.as_i32()), + reward_points: def.reward_points, + catalog_version: catalog::VERSION, + }, + ) + .await?; + + let (row, already) = if let Some(row) = inserted { + (row, false) + } else { + let existing = PgQuestRepo::get_claim(&mut tx, auth.user_id, def.id, &view.period_id) + .await? + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("claim conflict without row")))?; + (existing, true) + }; + + tx.commit() + .await + .map_err(|err| AppError::Internal(err.into()))?; + + let mut redis = state.redis.clone(); + cache::invalidate(&mut redis, auth.user_id).await; + realtime::publish_quest_updated(&state, auth.user_id); + realtime::publish_leaderboard_updated(&state, season.as_ref().map(|s| s.id.as_i32()), ""); + + Ok(Json(ClaimView::from_row(row, already))) +} diff --git a/crates/sw-server/src/routes/users.rs b/crates/sw-server/src/routes/users.rs index 63d37c8..f1d3c15 100644 --- a/crates/sw-server/src/routes/users.rs +++ b/crates/sw-server/src/routes/users.rs @@ -29,6 +29,8 @@ pub fn write_router() -> Router { .route("/", post(upsert_user)) .route("/me", delete(delete_account)) .route("/me/legal-accept", post(accept_legal)) + .route("/me/referral", post(set_referral)) + .route("/me/quest-intro", post(mark_quest_intro)) .route("/me/preferences", axum::routing::patch(update_preferences)) .route("/me/push-subscription", post(save_push_subscription)) .route("/me/push-subscription", delete(delete_push_subscription)) @@ -58,7 +60,7 @@ pub fn read_router() -> Router { } /// 3–24 chars, lowercase alphanumeric plus `_` and `-`, must start with a letter. -fn validate_username(raw: &str) -> AppResult { +pub(crate) fn validate_username(raw: &str) -> AppResult { let username = raw.trim().to_lowercase(); let len = username.chars().count(); if !(3..=24).contains(&len) { @@ -118,12 +120,19 @@ struct UserResponse { current_chain: ChainId, legal_accepted_at: Option>, legal_version: Option, + referral_prompt_status: String, + quest_intro_seen_at: Option>, + getting_started_completed_at: Option>, created_at: DateTime, updated_at: DateTime, } impl UserResponse { - fn from_user_prefs(user: User, prefs: Option) -> Self { + fn from_user_prefs( + user: User, + prefs: Option, + flags: Option, + ) -> Self { let prefs = prefs.unwrap_or(crate::data::users::UserPrefs { lobby_alerts_enabled: true, current_chain: ChainId::default(), @@ -131,6 +140,12 @@ impl UserResponse { legal_version: None, deleted_at: None, }); + let flags = flags.unwrap_or(crate::data::users::QuestFlags { + username_set: user.username.is_some(), + referral_prompt_status: "pending".into(), + quest_intro_seen_at: None, + getting_started_completed_at: None, + }); Self { id: user.id.as_uuid(), username: user.username, @@ -142,6 +157,9 @@ impl UserResponse { current_chain: prefs.current_chain, legal_accepted_at: prefs.legal_accepted_at, legal_version: prefs.legal_version, + referral_prompt_status: flags.referral_prompt_status, + quest_intro_seen_at: flags.quest_intro_seen_at, + getting_started_completed_at: flags.getting_started_completed_at, created_at: user.created_at, updated_at: user.updated_at, } @@ -150,7 +168,7 @@ impl UserResponse { impl From for UserResponse { fn from(user: User) -> Self { - Self::from_user_prefs(user, None) + Self::from_user_prefs(user, None, None) } } @@ -158,7 +176,8 @@ const LEGAL_VERSION: &str = "2026-08-21"; async fn json_user(repo: &PgUserRepo, user: User) -> AppResult> { let prefs = repo.prefs(user.id).await?; - Ok(Json(UserResponse::from_user_prefs(user, prefs))) + let flags = repo.quest_flags(user.id).await?; + Ok(Json(UserResponse::from_user_prefs(user, prefs, flags))) } #[derive(Debug, Deserialize)] @@ -341,7 +360,9 @@ async fn update_profile( } } - let user = PgUserRepo::new(state.db.clone()) + let username_set = username.is_some(); + let repo = PgUserRepo::new(state.db.clone()); + let user = repo .update_profile( UserId::from(user_id), UpdateProfileInput { @@ -355,7 +376,77 @@ async fn update_profile( ) .await?; - Ok(Json(UserResponse::from(user))) + if username_set { + let mut redis = state.redis.clone(); + crate::quests::cache::invalidate(&mut redis, UserId::from(user_id)).await; + let _ = crate::data::quest_claims::PgQuestRepo::new(state.db.clone()) + .maybe_stamp_getting_started(UserId::from(user_id)) + .await; + crate::services::realtime::publish_quest_updated(&state, UserId::from(user_id)); + } + + json_user(&repo, user).await +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReferralBody { + username: Option, + #[serde(default)] + skip: bool, +} + +async fn set_referral( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> AppResult> { + let repo = PgUserRepo::new(state.db.clone()); + let ok = if body.skip { + repo.skip_referral(auth.user_id).await? + } else { + let raw = body + .username + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| AppError::BadRequest("username is required".into()))?; + let username = validate_username(raw)?; + let referrer = repo + .get_by_username(&username) + .await? + .ok_or(AppError::NotFound("user"))?; + if referrer.id == auth.user_id { + return Err(AppError::BadRequest("cannot refer yourself".into())); + } + repo.set_referral(auth.user_id, referrer.id).await? + }; + if !ok { + return Err(AppError::Conflict("referral already set".into())); + } + let mut redis = state.redis.clone(); + crate::quests::cache::invalidate(&mut redis, auth.user_id).await; + crate::services::realtime::publish_quest_updated(&state, auth.user_id); + let user = repo + .get_active_by_id(auth.user_id) + .await? + .ok_or(AppError::NotFound("user"))?; + json_user(&repo, user).await +} + +async fn mark_quest_intro( + State(state): State, + auth: AuthUser, +) -> AppResult> { + let repo = PgUserRepo::new(state.db.clone()); + repo.mark_quest_intro_seen(auth.user_id).await?; + let mut redis = state.redis.clone(); + crate::quests::cache::invalidate(&mut redis, auth.user_id).await; + let user = repo + .get_active_by_id(auth.user_id) + .await? + .ok_or(AppError::NotFound("user"))?; + json_user(&repo, user).await } /// Everything the profile page needs in one round trip. diff --git a/crates/sw-server/src/services/realtime.rs b/crates/sw-server/src/services/realtime.rs index 3880456..29e3334 100644 --- a/crates/sw-server/src/services/realtime.rs +++ b/crates/sw-server/src/services/realtime.rs @@ -427,6 +427,25 @@ pub fn publish_leaderboard_updated(state: &AppState, season_id: Option, gam ); } +pub fn publish_quest_updated(state: &AppState, user_id: UserId) { + publish_quest_updated_raw(&state.subscriptions, &state.sessions, user_id); +} + +pub fn publish_quest_updated_raw( + subscriptions: &crate::ws::SubscriptionManager, + sessions: &crate::ws::SessionManager, + user_id: UserId, +) { + subscriptions.publish( + sessions, + &user_topic(user_id), + ServerMessage { + kind: "quest.updated".into(), + payload: json!({ "userId": user_id.as_uuid().to_string() }), + }, + ); +} + /// A finished match, for the landing page ticker and game activity feeds. pub fn publish_match_finished(state: &AppState, payload: Value) { state.subscriptions.publish( diff --git a/crates/sw-server/src/services/vault_oracle.rs b/crates/sw-server/src/services/vault_oracle.rs index b5e4d3e..d89573d 100644 --- a/crates/sw-server/src/services/vault_oracle.rs +++ b/crates/sw-server/src/services/vault_oracle.rs @@ -82,11 +82,7 @@ pub fn is_draw_result(result: &sw_plugin::MatchResult) -> bool { /// settle does not treat empty-or-named winners as a full-pot claim or a draw /// refund. pub fn is_distributed_settlement(result: &sw_plugin::MatchResult) -> bool { - result - .stats - .get("settlement") - .and_then(|v| v.as_str()) - == Some("distributed") + result.stats.get("settlement").and_then(|v| v.as_str()) == Some("distributed") } /// Entry actually paid by this seat. Sponsored guests pay nothing. @@ -179,10 +175,7 @@ mod tests { assert_eq!(winner_for_claim(&draw), None); let claims = draw_refund_claims( - vec![ - (a, Some("SP1".into())), - (b, Some("SP2".into())), - ], + vec![(a, Some("SP1".into())), (b, Some("SP2".into()))], 1_000_000, false, a, @@ -200,10 +193,7 @@ mod tests { let creator = uid(1); let guest = uid(2); let claims = draw_refund_claims( - vec![ - (creator, Some("SP1".into())), - (guest, Some("SP2".into())), - ], + vec![(creator, Some("SP1".into())), (guest, Some("SP2".into()))], 2_000_000, true, creator, diff --git a/migrations/20260901000001_quests.sql b/migrations/20260901000001_quests.sql new file mode 100644 index 0000000..435d0ee --- /dev/null +++ b/migrations/20260901000001_quests.sql @@ -0,0 +1,81 @@ +-- Quest system: write-once user flags + claim ledger. +-- Progress is computed from matches / match_players; do not add period-stat tables. + +DO $$ BEGIN + CREATE TYPE referral_prompt_status AS ENUM ('pending', 'set', 'skipped'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS referred_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS referred_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS referral_prompt_status referral_prompt_status NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS quest_intro_seen_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS getting_started_completed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS referral_credited_at TIMESTAMPTZ; + +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_referred_by_not_self; +ALTER TABLE users + ADD CONSTRAINT users_referred_by_not_self + CHECK (referred_by_user_id IS NULL OR referred_by_user_id <> id); + +CREATE INDEX IF NOT EXISTS users_referred_by_idx + ON users (referred_by_user_id) + WHERE referred_by_user_id IS NOT NULL; + +-- Existing accounts should not see the "who invited you" prompt. +UPDATE users +SET referral_prompt_status = 'skipped' +WHERE referral_prompt_status = 'pending'; + +CREATE TABLE IF NOT EXISTS quest_claims ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + quest_id TEXT NOT NULL, + period_kind TEXT NOT NULL, + period_id TEXT NOT NULL, + season_id INT REFERENCES seasons(id) ON DELETE SET NULL, + reward_points INT NOT NULL, + catalog_version INT NOT NULL, + claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT quest_claims_unique UNIQUE (user_id, quest_id, period_id) +); + +CREATE INDEX IF NOT EXISTS quest_claims_season_user_idx + ON quest_claims (season_id, user_id); + +CREATE INDEX IF NOT EXISTS quest_claims_user_period_idx + ON quest_claims (user_id, period_kind, period_id); + +-- Backfill Getting Started completion from existing qualifying matches. +UPDATE users u +SET getting_started_completed_at = now() +WHERE u.getting_started_completed_at IS NULL + AND u.username IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + JOIN lobbies l ON l.id = m.lobby_id + WHERE mp.user_id = u.id + AND m.player_count >= 2 + AND l.creator_id = u.id + ) + AND EXISTS ( + SELECT 1 + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + JOIN lobbies l ON l.id = m.lobby_id + WHERE mp.user_id = u.id + AND m.player_count >= 2 + AND l.creator_id <> u.id + ) + AND EXISTS ( + SELECT 1 + FROM match_players mp + JOIN matches m ON m.id = mp.match_id + WHERE mp.user_id = u.id + AND m.player_count >= 2 + AND mp.is_winner + );