Fix PR 3 review findings [skip ci]
This commit is contained in:
1 parent
590178e41a
commit
454620f21e
21 files changed
+522
-160
No files matched your search
@@ -496,6 +496,7 @@ pub async fn server_market_buy(
|
||||
.bind(&payload.buyer_uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let new_balance = buyer_bal - price;
|
||||
|
||||
// Pay seller
|
||||
sqlx::query(
|
||||
@@ -543,6 +544,7 @@ pub async fn server_market_buy(
|
||||
"item_data": item_data,
|
||||
"item_name": item_name,
|
||||
"amount": amount,
|
||||
"price": price
|
||||
"price": price,
|
||||
"new_balance": new_balance
|
||||
})).await
|
||||
}
|
||||
@@ -1047,7 +1047,6 @@ pub async fn server_unclaim_chunk(
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&server.instance_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -14,7 +14,65 @@ pub fn current_daily_key() -> String {
|
||||
}
|
||||
|
||||
pub fn current_weekly_key() -> String {
|
||||
chrono::Utc::now().format("%Y-W%V").to_string()
|
||||
chrono::Utc::now().format("%G-W%V").to_string()
|
||||
}
|
||||
|
||||
fn action_matches(target: &str, action: &str) -> bool {
|
||||
let Some(target) = target.strip_prefix("action:") else { return false };
|
||||
let (target_action, target_dimension) = target.split_once('@').map_or((target, None), |(a, d)| (a, Some(d)));
|
||||
let (actual_action, actual_dimension) = action.split_once('@').map_or((action, None), |(a, d)| (a, Some(d)));
|
||||
if target_dimension.is_some_and(|d| Some(d) != actual_dimension) { return false; }
|
||||
let Some((target_kind, materials)) = target_action.split_once(':') else { return false };
|
||||
let Some((actual_kind, material)) = actual_action.split_once(':') else { return false };
|
||||
if target_kind != actual_kind { return false; }
|
||||
materials.split('|').any(|pattern| {
|
||||
if pattern == "*" { true }
|
||||
else if let Some(suffix) = pattern.strip_prefix('*') { material.ends_with(suffix) }
|
||||
else if let Some(prefix) = pattern.strip_suffix('*') { material.starts_with(prefix) }
|
||||
else { material == pattern }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod action_tests {
|
||||
use super::action_matches;
|
||||
|
||||
#[test]
|
||||
fn only_matching_materials_and_dimensions_advance_quests() {
|
||||
assert!(action_matches("action:block_broken:DIAMOND_ORE|DEEPSLATE_DIAMOND_ORE", "block_broken:DIAMOND_ORE@minecraft:overworld"));
|
||||
assert!(!action_matches("action:block_broken:DIAMOND_ORE", "block_broken:STONE@minecraft:overworld"));
|
||||
assert!(action_matches("action:block_placed:*_PLANKS", "block_placed:OAK_PLANKS@minecraft:overworld"));
|
||||
assert!(action_matches("action:block_broken:*@minecraft:the_nether", "block_broken:NETHERRACK@minecraft:the_nether"));
|
||||
assert!(!action_matches("action:block_broken:*@minecraft:the_nether", "block_broken:STONE@minecraft:overworld"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance quests that require a particular block, mob, or dimension rather
|
||||
/// than a generic stat counter.
|
||||
pub async fn advance_action_quests(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, action: &str, count: i64, now: &str,
|
||||
) -> AppResult<()> {
|
||||
if count <= 0 { return Ok(()); }
|
||||
let rows: Vec<(String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT id, period, target_stat, target_count FROM quests WHERE enabled = 1 AND target_stat LIKE 'action:%'",
|
||||
).fetch_all(&mut **tx).await?;
|
||||
let daily = current_daily_key();
|
||||
let weekly = current_weekly_key();
|
||||
for (id, period, target, required) in rows {
|
||||
if !action_matches(&target, action) { continue; }
|
||||
let key = if period == "weekly" { &weekly } else { &daily };
|
||||
sqlx::query(
|
||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
|
||||
progress = progress + excluded.progress,
|
||||
completed = CASE WHEN progress + excluded.progress >= ? THEN 1 ELSE completed END,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(uuid).bind(&id).bind(key).bind(count).bind(count >= required).bind(now).bind(required)
|
||||
.execute(&mut **tx).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all active daily and weekly quests for the authenticated user with live progress.
|
||||
|
||||
@@ -392,6 +392,17 @@ pub struct Sync {
|
||||
pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>, Json(s): Json<Sync>) -> AppResult<Json<Value>> {
|
||||
let at_now = now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
let leveling_on = s.features.as_ref().and_then(|f| f.leveling_enabled).unwrap_or(true);
|
||||
let mut xp_before = std::collections::HashMap::new();
|
||||
if !leveling_on {
|
||||
for uuid in s.stats.iter().filter_map(|d| dashed(&d.uuid))
|
||||
.chain(s.events.iter().filter_map(|e| e.uuid.as_deref().and_then(dashed))) {
|
||||
if xp_before.contains_key(&uuid) { continue; }
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or(0);
|
||||
xp_before.insert(uuid, xp);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the receipt in the same transaction as the deltas. A response
|
||||
// lost after commit can then be retried without counting activity twice.
|
||||
@@ -476,6 +487,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
}
|
||||
|
||||
let achievements_on = s.features.as_ref().and_then(|f| f.achievements_enabled).unwrap_or(true);
|
||||
let quests_on = s.features.as_ref().and_then(|f| f.quests_enabled).unwrap_or(true);
|
||||
|
||||
for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) {
|
||||
let kind = clip(&e.kind, 24).to_ascii_lowercase();
|
||||
@@ -494,8 +506,18 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
.bind(&kind)
|
||||
.bind(e.detail.as_deref().map(|d| clip(d, 256)))
|
||||
.bind(at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if quests_on && kind == "action" {
|
||||
if let (Some(uuid), Some(detail)) = (e.uuid.as_deref().and_then(dashed), e.detail.as_deref()) {
|
||||
if let Some((action, count)) = detail.rsplit_once(" +") {
|
||||
if let Ok(count) = count.parse::<i64>() {
|
||||
crate::routes::quests::advance_action_quests(&mut tx, &uuid, action, count.clamp(0, 1_000_000), &at_now).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if event unlocks an achievement (if achievements enabled)
|
||||
if achievements_on {
|
||||
@@ -523,7 +545,17 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
let mut rewarded = std::collections::HashSet::new();
|
||||
for d in &s.stats { if let Some(uuid) = dashed(&d.uuid) { rewarded.insert(uuid); } }
|
||||
for e in &s.events { if let Some(uuid) = e.uuid.as_deref().and_then(dashed) { rewarded.insert(uuid); } }
|
||||
for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; }
|
||||
if leveling_on {
|
||||
for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; }
|
||||
} else {
|
||||
// Achievement unlocks remain enabled, but their trigger must not grant
|
||||
// progression on a server whose leveling module is disabled.
|
||||
for (uuid, xp) in xp_before {
|
||||
let level = crate::routes::leveling::level_from_xp(xp).0;
|
||||
sqlx::query("UPDATE user_levels SET global_xp = ?, global_level = ? WHERE uuid = ?")
|
||||
.bind(xp).bind(level).bind(&uuid).execute(&mut *tx).await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
// Occasional housekeeping.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Friends, Direct Messages, Game Invites, Player Profiles, and Social Feed.
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::auth::{AuthUser, MaybeUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, Query, State};
|
||||
@@ -456,6 +456,7 @@ pub async fn respond_game_invite(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_player_profile(
|
||||
MaybeUser(viewer): MaybeUser,
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserProfileView>> {
|
||||
@@ -497,23 +498,27 @@ pub async fn get_player_profile(
|
||||
.await?;
|
||||
|
||||
// Recent posts
|
||||
let post_rows: Vec<(i64, String, String, String, Option<String>, i64, String)> = sqlx::query_as(
|
||||
"SELECT id, user_uuid, author_name, content, image_url, likes_count, created_at
|
||||
FROM user_posts WHERE user_uuid = ? ORDER BY id DESC LIMIT 10",
|
||||
let post_rows: Vec<(i64, String, String, String, Option<String>, i64, bool, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.user_uuid, p.author_name, p.content, p.image_url, p.likes_count,
|
||||
EXISTS(SELECT 1 FROM user_post_likes l WHERE l.post_id = p.id AND l.user_uuid = ?),
|
||||
p.created_at
|
||||
FROM user_posts p WHERE p.user_uuid = ? ORDER BY p.id DESC LIMIT 10",
|
||||
)
|
||||
.bind(viewer.as_ref().map(|user| user.uuid.as_str()).unwrap_or(""))
|
||||
.bind(&puuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let posts = post_rows
|
||||
.into_iter()
|
||||
.map(|(id, u_uuid, aname, content, img, likes, cat)| UserPost {
|
||||
.map(|(id, u_uuid, aname, content, img, likes, liked_by_me, cat)| UserPost {
|
||||
id,
|
||||
user_uuid: u_uuid,
|
||||
author_name: aname,
|
||||
content,
|
||||
image_url: img,
|
||||
likes_count: likes,
|
||||
liked_by_me,
|
||||
created_at: cat,
|
||||
})
|
||||
.collect();
|
||||
@@ -634,6 +639,7 @@ pub async fn create_user_post(
|
||||
content: content.to_string(),
|
||||
image_url: payload.image_url,
|
||||
likes_count: 0,
|
||||
liked_by_me: false,
|
||||
created_at: now,
|
||||
}))
|
||||
}
|
||||
@@ -653,16 +659,31 @@ pub async fn delete_user_post(
|
||||
}
|
||||
|
||||
pub async fn like_user_post(
|
||||
_auth: AuthUser,
|
||||
auth: AuthUser,
|
||||
Path(post_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?")
|
||||
let mut tx = state.db.begin().await?;
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_posts WHERE id = ?)")
|
||||
.bind(post_id)
|
||||
.execute(&state.db)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists { return Err(AppError::not_found("Post not found")); }
|
||||
let added = sqlx::query("INSERT OR IGNORE INTO user_post_likes (post_id, user_uuid) VALUES (?, ?)")
|
||||
.bind(post_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected() > 0;
|
||||
if added {
|
||||
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?")
|
||||
.bind(post_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
Ok(Json(serde_json::json!({ "ok": true, "liked": added })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user