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
@@ -499,6 +499,13 @@ const MIGRATIONS: &[&str] = &[
|
||||
CREATE TABLE economy_operations (server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
operation_id TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(server_id, operation_id));
|
||||
"#,
|
||||
r#"
|
||||
CREATE TABLE user_post_likes (
|
||||
post_id INTEGER NOT NULL REFERENCES user_posts(id) ON DELETE CASCADE,
|
||||
user_uuid TEXT NOT NULL,
|
||||
PRIMARY KEY (post_id, user_uuid)
|
||||
);
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
@@ -536,6 +543,7 @@ async fn migrate(pool: &SqlitePool) -> Result<()> {
|
||||
}
|
||||
backfill_uuids(pool).await?;
|
||||
crate::seed::seed_quests_and_achievements(pool).await?;
|
||||
if current < 9 { crate::seed::upgrade_seeded_quest_targets(pool).await?; }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+97
-11
@@ -3,6 +3,92 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// Targets with a material, mob, or dimension requirement use the action
|
||||
// telemetry sent by both Paper and the Fabric/Forge bridge. Other quests use
|
||||
// the aggregate counters in the sync payload.
|
||||
const SPECIFIC_QUEST_TARGETS: &[(&str, &str)] = &[
|
||||
("q_d_mine_coal", "action:block_broken:COAL_ORE|DEEPSLATE_COAL_ORE|COAL_BLOCK"),
|
||||
("q_d_mine_stone", "action:block_broken:STONE|COBBLESTONE"),
|
||||
("q_d_mine_iron", "action:block_broken:IRON_ORE|DEEPSLATE_IRON_ORE"),
|
||||
("q_d_mine_copper", "action:block_broken:COPPER_ORE|DEEPSLATE_COPPER_ORE"),
|
||||
("q_d_mine_gold", "action:block_broken:GOLD_ORE|DEEPSLATE_GOLD_ORE|NETHER_GOLD_ORE"),
|
||||
("q_d_mine_diamond", "action:block_broken:DIAMOND_ORE|DEEPSLATE_DIAMOND_ORE"),
|
||||
("q_d_mine_redstone", "action:block_broken:REDSTONE_ORE|DEEPSLATE_REDSTONE_ORE"),
|
||||
("q_d_mine_deepslate", "action:block_broken:DEEPSLATE|COBBLED_DEEPSLATE"),
|
||||
("q_d_mine_quartz", "action:block_broken:NETHER_QUARTZ_ORE"),
|
||||
("q_d_mine_debris", "action:block_broken:ANCIENT_DEBRIS"),
|
||||
("q_d_mine_sand", "action:block_broken:SAND|GRAVEL"),
|
||||
("q_d_mine_obsidian", "action:block_broken:OBSIDIAN"),
|
||||
("q_d_mob_zombies", "action:mob_kills:ZOMBIE"),
|
||||
("q_d_mob_skeletons", "action:mob_kills:SKELETON"),
|
||||
("q_d_mob_spiders", "action:mob_kills:SPIDER|CAVE_SPIDER"),
|
||||
("q_d_mob_creepers", "action:mob_kills:CREEPER"),
|
||||
("q_d_mob_endermen", "action:mob_kills:ENDERMAN"),
|
||||
("q_d_mob_witches", "action:mob_kills:WITCH"),
|
||||
("q_d_mob_slimes", "action:mob_kills:SLIME|MAGMA_CUBE"),
|
||||
("q_d_mob_piglins", "action:mob_kills:PIGLIN|ZOMBIFIED_PIGLIN"),
|
||||
("q_d_mob_blazes", "action:mob_kills:BLAZE"),
|
||||
("q_d_mob_drowned", "action:mob_kills:DROWNED"),
|
||||
("q_d_build_planks", "action:block_placed:*_PLANKS"),
|
||||
("q_d_build_bricks", "action:block_placed:STONE_BRICKS|POLISHED_*"),
|
||||
("q_d_build_glass", "action:block_placed:GLASS|GLASS_PANE|*_GLASS|*_GLASS_PANE"),
|
||||
("q_d_build_torches", "action:block_placed:TORCH|WALL_TORCH"),
|
||||
("q_d_build_lanterns", "action:block_placed:LANTERN|SOUL_LANTERN"),
|
||||
("q_d_build_slabs", "action:block_placed:*_SLAB|*_STAIRS"),
|
||||
("q_d_build_walls", "action:block_placed:*_WALL"),
|
||||
("q_d_farm_wood", "action:block_broken:*_LOG|*_WOOD"),
|
||||
("q_d_farm_birch", "action:block_broken:BIRCH_LOG|SPRUCE_LOG"),
|
||||
("q_d_farm_jungle", "action:block_broken:JUNGLE_LOG|ACACIA_LOG"),
|
||||
("q_d_farm_wheat", "action:block_broken:WHEAT"),
|
||||
("q_d_farm_potatoes", "action:block_broken:POTATOES"),
|
||||
("q_d_farm_carrots", "action:block_broken:CARROTS"),
|
||||
("q_d_farm_sugarcane", "action:block_broken:SUGAR_CANE"),
|
||||
("q_d_farm_pumpkins", "action:block_broken:PUMPKIN|MELON"),
|
||||
("q_d_farm_flowers", "action:block_broken:*_TULIP|DANDELION|POPPY|OXEYE_DAISY|CORNFLOWER|AZURE_BLUET|ALLIUM"),
|
||||
("q_d_farm_leaves", "action:block_broken:*_LEAVES"),
|
||||
("q_d_explore_depths", "action:block_broken:TUFF|DEEPSLATE_TILES"),
|
||||
("q_d_explore_diorite", "action:block_broken:GRANITE|DIORITE|ANDESITE"),
|
||||
("q_d_explore_nether", "action:block_broken:*@minecraft:the_nether"),
|
||||
("q_d_explore_clay", "action:block_broken:CLAY"),
|
||||
("q_d_explore_snow", "action:block_broken:SNOW_BLOCK|SNOW|ICE|PACKED_ICE|BLUE_ICE"),
|
||||
("q_d_explore_magma", "action:block_broken:MAGMA_BLOCK|BASALT"),
|
||||
("q_d_build_decor", "action:block_placed:*_CARPET|FLOWER_POT|PAINTING"),
|
||||
("q_w_mine_deepslate_grand", "action:block_broken:DEEPSLATE|COBBLED_DEEPSLATE"),
|
||||
("q_w_mine_iron_hoard", "action:block_broken:IRON_ORE|DEEPSLATE_IRON_ORE"),
|
||||
("q_w_mine_diamonds_grand", "action:block_broken:DIAMOND_ORE|DEEPSLATE_DIAMOND_ORE"),
|
||||
("q_w_mine_gold_grand", "action:block_broken:GOLD_ORE|DEEPSLATE_GOLD_ORE|NETHER_GOLD_ORE"),
|
||||
("q_w_mine_redstone_grand", "action:block_broken:REDSTONE_ORE|DEEPSLATE_REDSTONE_ORE"),
|
||||
("q_w_mine_debris_grand", "action:block_broken:ANCIENT_DEBRIS"),
|
||||
("q_w_mine_nether_grand", "action:block_broken:*@minecraft:the_nether"),
|
||||
("q_w_mine_stone_grand", "action:block_broken:STONE|COBBLESTONE"),
|
||||
("q_w_mob_skeletons_grand", "action:mob_kills:SKELETON"),
|
||||
("q_w_mob_creepers_grand", "action:mob_kills:CREEPER"),
|
||||
("q_w_mob_endermen_grand", "action:mob_kills:ENDERMAN"),
|
||||
("q_w_mob_blazes_grand", "action:mob_kills:BLAZE"),
|
||||
("q_w_mob_withers_grand", "action:mob_kills:WITHER_SKELETON"),
|
||||
("q_w_build_castle", "action:block_placed:STONE_BRICKS|*_STONE_BRICKS"),
|
||||
("q_w_build_planks_grand", "action:block_placed:*_PLANKS"),
|
||||
("q_w_build_glass_grand", "action:block_placed:GLASS|*_GLASS"),
|
||||
("q_w_build_lanterns_grand", "action:block_placed:LANTERN|SOUL_LANTERN|GLOWSTONE"),
|
||||
("q_w_build_enclosure", "action:block_placed:*_FENCE|*_WALL"),
|
||||
("q_w_farm_logs_grand", "action:block_broken:*_LOG|*_WOOD"),
|
||||
("q_w_farm_crops_grand", "action:block_broken:WHEAT|POTATOES|CARROTS|BEETROOTS"),
|
||||
("q_w_farm_reeds_grand", "action:block_broken:SUGAR_CANE|BAMBOO"),
|
||||
("q_w_farm_pumpkins_grand", "action:block_broken:PUMPKIN|MELON"),
|
||||
("q_w_explore_nether_grand2", "action:block_broken:*@minecraft:the_nether"),
|
||||
("q_w_explore_ocean", "action:block_broken:PRISMARINE|PRISMARINE_BRICKS|DARK_PRISMARINE|GRAVEL|*_CORAL_BLOCK"),
|
||||
("q_w_explore_sand_grand", "action:block_broken:SAND|RED_SAND"),
|
||||
("q_w_build_complex", "action:block_placed:STONE_BRICKS|POLISHED_GRANITE"),
|
||||
];
|
||||
|
||||
pub async fn upgrade_seeded_quest_targets(pool: &SqlitePool) -> Result<()> {
|
||||
for (id, target) in SPECIFIC_QUEST_TARGETS {
|
||||
sqlx::query("UPDATE quests SET target_stat = ? WHERE id = ? AND target_stat IN ('blocks_broken', 'blocks_placed', 'mob_kills')")
|
||||
.bind(target).bind(id).execute(pool).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn seed_quests_and_achievements(pool: &SqlitePool) -> Result<()> {
|
||||
// Only seed if quests table is empty
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quests").fetch_one(pool).await?;
|
||||
@@ -40,7 +126,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
("q_d_mine_quartz", "Nether Prospecting", "Mine 16 Nether quartz ore blocks", "daily", "mining", "blocks_broken", 16, 180, "sparkles"),
|
||||
("q_d_mine_debris", "Debris Hunter", "Mine 2 Ancient Debris blocks in the Nether", "daily", "mining", "blocks_broken", 2, 450, "shield"),
|
||||
("q_d_mine_sand", "Dune Excavator", "Dig 32 sand or gravel blocks", "daily", "mining", "blocks_broken", 32, 90, "layers"),
|
||||
("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks with a diamond pickaxe", "daily", "mining", "blocks_broken", 8, 250, "shield"),
|
||||
("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks", "daily", "mining", "blocks_broken", 8, 250, "shield"),
|
||||
|
||||
// Daily Combat
|
||||
("q_d_mob_zombies", "Zombie Purge", "Defeat 8 zombies wandering in the dark", "daily", "combat", "mob_kills", 8, 120, "swords"),
|
||||
@@ -53,7 +139,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
("q_d_mob_piglins", "Piglin Skirmish", "Defeat 6 hostile piglins or zombified piglins", "daily", "combat", "mob_kills", 6, 160, "swords"),
|
||||
("q_d_mob_blazes", "Firestarter", "Defeat 4 blazing fortress defenders", "daily", "combat", "mob_kills", 4, 250, "flame"),
|
||||
("q_d_mob_drowned", "Deep Water Danger", "Defeat 5 drowned lurkers in rivers or oceans", "daily", "combat", "mob_kills", 5, 140, "droplets"),
|
||||
("q_d_mob_monsters", "Night Watch", "Eliminate any 15 hostile monsters", "daily", "combat", "mob_kills", 15, 200, "swords"),
|
||||
("q_d_mob_monsters", "Night Watch", "Defeat any 15 mobs", "daily", "combat", "mob_kills", 15, 200, "swords"),
|
||||
("q_d_pvp_champion", "Gladiator's Glory", "Defeat 1 player in honorable combat", "daily", "combat", "player_kills", 1, 250, "trophy"),
|
||||
|
||||
// Daily Building
|
||||
@@ -63,10 +149,10 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
("q_d_build_torches", "Let There Be Light", "Place 20 torches to illuminate the area", "daily", "building", "blocks_placed", 20, 90, "flame"),
|
||||
("q_d_build_lanterns", "Cozy Hearth", "Place 6 lanterns around your settlement", "daily", "building", "blocks_placed", 6, 120, "sparkles"),
|
||||
("q_d_build_slabs", "Tiered Architecture", "Place 24 slabs or stairs", "daily", "building", "blocks_placed", 24, 110, "layers"),
|
||||
("q_d_build_roof", "Under Cover", "Place 50 roofing or decorative blocks", "daily", "building", "blocks_placed", 50, 140, "home"),
|
||||
("q_d_build_roof", "Under Cover", "Place 50 blocks of any kind", "daily", "building", "blocks_placed", 50, 140, "home"),
|
||||
("q_d_build_walls", "Fortified Walls", "Place 30 cobblestone or deepslate walls", "daily", "building", "blocks_placed", 30, 130, "shield"),
|
||||
("q_d_build_general", "Master Builder", "Place 100 blocks of any material", "daily", "building", "blocks_placed", 100, 180, "hammer"),
|
||||
("q_d_build_monument", "Grand Monument", "Place 150 blocks toward a new structure", "daily", "building", "blocks_placed", 150, 220, "castle"),
|
||||
("q_d_build_monument", "Grand Monument", "Place 150 blocks of any kind", "daily", "building", "blocks_placed", 150, 220, "castle"),
|
||||
|
||||
// Daily Farming & Harvesting
|
||||
("q_d_farm_wood", "Lumberjack", "Chop down 32 wood logs", "daily", "farming", "blocks_broken", 32, 120, "axe"),
|
||||
@@ -83,12 +169,12 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
// Daily Social & Playtime
|
||||
("q_d_play_15m", "Quick Check-in", "Play on any server for 15 minutes", "daily", "social", "playtime_secs", 900, 100, "clock"),
|
||||
("q_d_play_30m", "Dedicated Citizen", "Spend 30 minutes in game", "daily", "social", "playtime_secs", 1800, 180, "clock"),
|
||||
("q_d_play_60m", "Hour of Power", "Play for a full continuous hour", "daily", "social", "playtime_secs", 3600, 250, "clock"),
|
||||
("q_d_chat_chatty", "Good Morning", "Say hello and send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"),
|
||||
("q_d_play_60m", "Hour of Power", "Play for a total of one hour", "daily", "social", "playtime_secs", 3600, 250, "clock"),
|
||||
("q_d_chat_chatty", "Good Morning", "Send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"),
|
||||
("q_d_chat_talkative", "Community Voice", "Send 15 chat messages participating in chat", "daily", "social", "messages", 15, 120, "message-square"),
|
||||
("q_d_join_again", "Frequent Flyer", "Join a server 2 distinct times today", "daily", "social", "joins", 2, 80, "door-open"),
|
||||
("q_d_explore_depths", "Into the Deep", "Mine 30 tuff or deepslate tiles", "daily", "exploration", "blocks_broken", 30, 110, "compass"),
|
||||
("q_d_explore_caves", "Spelunker", "Break 50 blocks exploring natural cave systems", "daily", "exploration", "blocks_broken", 50, 130, "compass"),
|
||||
("q_d_explore_caves", "Spelunker", "Break 50 blocks of any kind", "daily", "exploration", "blocks_broken", 50, 130, "compass"),
|
||||
("q_d_explore_diorite", "Geologist", "Collect 20 granite, diorite, or andesite blocks", "daily", "exploration", "blocks_broken", 20, 100, "mountain"),
|
||||
("q_d_explore_nether", "Nether Expedition", "Mine 40 nether blocks while surviving", "daily", "exploration", "blocks_broken", 40, 160, "flame"),
|
||||
("q_d_explore_clay", "Clay Digger", "Excavate 16 clay blocks from river beds", "daily", "exploration", "blocks_broken", 16, 110, "droplets"),
|
||||
@@ -106,7 +192,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
("q_w_mine_nether_grand", "Underworld Quarry", "Break 300 blocks in the Nether dimension", "weekly", "mining", "blocks_broken", 300, 700, "flame"),
|
||||
("q_w_mine_stone_grand", "Mountain Mover", "Break 1,000 stone or cobblestone blocks", "weekly", "mining", "blocks_broken", 1000, 1100, "mountain"),
|
||||
|
||||
("q_w_mob_purge_grand", "Monster Exterminator", "Eliminate 100 hostile monsters", "weekly", "combat", "mob_kills", 100, 1000, "swords"),
|
||||
("q_w_mob_purge_grand", "Monster Exterminator", "Defeat 100 mobs", "weekly", "combat", "mob_kills", 100, 1000, "swords"),
|
||||
("q_w_mob_skeletons_grand", "Archer's Nemesis", "Defeat 40 skeleton sharpshooters", "weekly", "combat", "mob_kills", 40, 750, "crosshair"),
|
||||
("q_w_mob_creepers_grand", "Demolition Defuser", "Take down 30 dangerous creepers", "weekly", "combat", "mob_kills", 30, 850, "flame"),
|
||||
("q_w_mob_endermen_grand", "Void Hunter", "Defeat 15 elusive Endermen", "weekly", "combat", "mob_kills", 15, 1100, "eye"),
|
||||
@@ -132,9 +218,9 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
("q_w_play_15h", "No-Lifer Supremacy", "Clock in 15 hours of active gameplay", "weekly", "social", "playtime_secs", 54000, 2500, "zap"),
|
||||
("q_w_chat_social", "Town Hall Speaker", "Contribute 50 chat messages across community servers", "weekly", "social", "messages", 50, 600, "message-square"),
|
||||
("q_w_joins_loyal", "Daily Habits", "Join game servers on at least 5 occasions", "weekly", "social", "joins", 5, 700, "door-open"),
|
||||
("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks deep underground", "weekly", "exploration", "blocks_broken", 400, 850, "compass"),
|
||||
("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks of any kind", "weekly", "exploration", "blocks_broken", 400, 850, "compass"),
|
||||
("q_w_explore_nether_grand2", "Infernal Pioneer", "Travel and mine 250 blocks in the Nether", "weekly", "exploration", "blocks_broken", 250, 800, "flame"),
|
||||
("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral underwater", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"),
|
||||
("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral blocks", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"),
|
||||
("q_w_explore_sand_grand", "Desert Terraformer", "Dig 300 sand or red sand blocks", "weekly", "exploration", "blocks_broken", 300, 650, "layers"),
|
||||
("q_w_build_complex", "Civil Engineer", "Place 600 stone bricks or polished granite", "weekly", "building", "blocks_placed", 600, 950, "box"),
|
||||
];
|
||||
@@ -149,7 +235,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
.bind(desc)
|
||||
.bind(period)
|
||||
.bind(cat)
|
||||
.bind(stat)
|
||||
.bind(SPECIFIC_QUEST_TARGETS.iter().find(|(quest_id, _)| *quest_id == id).map(|(_, target)| *target).unwrap_or(stat))
|
||||
.bind(target)
|
||||
.bind(xp)
|
||||
.bind(icon)
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn leveling_and_quests_progression() {
|
||||
"playtime_secs": 60
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
"events": [{"uuid": &alex_uuid, "kind": "action", "detail": "block_broken:DIAMOND_ORE@minecraft:overworld +4"}]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -92,6 +92,12 @@ async fn leveling_and_quests_progression() {
|
||||
assert_eq!(s, StatusCode::OK, "{lvl_after}");
|
||||
let current_xp = lvl_after["global_xp"].as_i64().unwrap();
|
||||
assert!(current_xp > 0, "Alex should have earned XP");
|
||||
let (s, quests_after) = t.call("GET", "/api/v1/quests/me", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let progress = |id: &str| quests_after.as_array().unwrap().iter()
|
||||
.find(|q| q["quest"]["id"] == id).unwrap()["progress"].as_i64().unwrap();
|
||||
assert_eq!(progress("q_d_mine_diamond"), 4);
|
||||
assert_eq!(progress("q_d_mine_coal"), 0);
|
||||
|
||||
// 5. Game Server sync with leveling DISABLED (server host turned it off in config)
|
||||
let (s, sync_off) = t
|
||||
@@ -137,6 +143,9 @@ async fn guilds_and_land_claims() {
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (alex_token, _, alex_uuid, steve_uuid) = setup_users(&t).await;
|
||||
let (server_id, server_token) = create_test_server(&t, &admin).await;
|
||||
let (status, updated) = t.call("PUT", &format!("/api/admin/servers/{server_id}"), Some(&admin),
|
||||
Some(json!({"name": "Survival Hub", "instance_id": "smp-pack"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{updated}");
|
||||
|
||||
// 1. Alex founds a guild
|
||||
let (s, guild) = t
|
||||
@@ -247,6 +256,14 @@ async fn guilds_and_land_claims() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(check_unclaimed["allowed"], true);
|
||||
|
||||
// The in-game command uses the server unclaim route rather than DELETE.
|
||||
let (s, _) = t.call("POST", "/api/server/v1/guilds/claim", Some(&server_token),
|
||||
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (s, response) = t.call("POST", "/api/server/v1/guilds/unclaim", Some(&server_token),
|
||||
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{response}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -332,10 +349,17 @@ async fn social_friends_dms_and_profiles() {
|
||||
let (s, like_res) = t.call("POST", &format!("/api/v1/social/posts/{post_id}/like"), Some(&steve_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{like_res}");
|
||||
assert_eq!(like_res["ok"], true);
|
||||
let (s, repeat_like) = t.call("POST", &format!("/api/v1/social/posts/{post_id}/like"), Some(&steve_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(repeat_like["liked"], false);
|
||||
|
||||
// Steve views Alex's full profile
|
||||
let (s, profile) = t.call("GET", &format!("/api/v1/social/profile/{alex_uuid}"), Some(&steve_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{profile}");
|
||||
assert_eq!(profile["bio"], "Redstone expert and dragon hunter.");
|
||||
assert_eq!(profile["posts"][0]["likes_count"], 1);
|
||||
assert_eq!(profile["posts"][0]["liked_by_me"], true);
|
||||
let (s, author_profile) = t.call("GET", &format!("/api/v1/social/profile/{alex_uuid}"), Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(author_profile["posts"][0]["liked_by_me"], false);
|
||||
}
|
||||
Reference in new issue
Block a user