diff --git a/integrations/common/src/main/java/net/scopenet/core/Platform.java b/integrations/common/src/main/java/net/scopenet/core/Platform.java index 7bbe6d1..c336791 100644 --- a/integrations/common/src/main/java/net/scopenet/core/Platform.java +++ b/integrations/common/src/main/java/net/scopenet/core/Platform.java @@ -19,4 +19,10 @@ public interface Platform { void runMain(Runnable task); /** Run off the server thread, for network calls. */ void runAsync(Runnable task); + + /** Put items in the player's inventory (anything that doesn't fit drops at their feet). False if the item doesn't exist here. */ + default boolean giveItem(UUID player, String itemId, int amount) { return false; } + + /** Run a command as the console; true if it was found and run. Called on the server thread. */ + default boolean console(String command) { return false; } } diff --git a/integrations/common/src/main/java/net/scopenet/core/RewardPoller.java b/integrations/common/src/main/java/net/scopenet/core/RewardPoller.java new file mode 100644 index 0000000..a99d036 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/RewardPoller.java @@ -0,0 +1,122 @@ +package net.scopenet.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Optional; +import java.util.UUID; + +/** + * Collects the rewards the panel owes to players on this server (items, permissions, groups, commands, messages) and carries + * them out, then tells the panel what worked. The panel only hands rewards over for players who are online here. + */ +public final class RewardPoller { + public static final long EVERY_MS = 5_000; + + private final Env env; + private volatile RewardRules rules; + private long next; + private volatile boolean running; + + public RewardPoller(Env env, RewardRules rules) { this.env = env; this.rules = rules; } + + public void rules(RewardRules rules) { this.rules = rules; } + + /** Call often on the server thread; it asks the panel at most every few seconds. */ + public void tick() { + if (!rules.enabled()) return; + long now = env.clock.getAsLong(); + if (running || now < next) return; + next = now + EVERY_MS; + running = true; + env.platform.runAsync(() -> { + try { + JsonElement answer = env.panel.call("rewards/poll", new JsonObject()); + JsonArray list = answer.isJsonObject() && answer.getAsJsonObject().has("deliveries") ? answer.getAsJsonObject().getAsJsonArray("deliveries") : new JsonArray(); + if (list.size() > 0) env.platform.runMain(() -> carryOut(list)); + } catch (Exception e) { + // The panel is briefly unreachable: try again on the next round. + } finally { + running = false; + } + }); + } + + private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; } + + /** Server thread. Applies each delivery, then reports back from the background. */ + void carryOut(JsonArray list) { + JsonArray done = new JsonArray(), failed = new JsonArray(); + for (JsonElement el : list) { + JsonObject d = el.getAsJsonObject(); + long id = d.get("id").getAsLong(); + String error; + try { + error = apply(d); + } catch (RuntimeException e) { + error = e.getClass().getSimpleName(); + } + if (error == null) done.add(id); + else { + JsonObject f = new JsonObject(); + f.addProperty("id", id); + f.addProperty("error", error); + failed.add(f); + } + } + JsonObject body = new JsonObject(); + body.add("done", done); + body.add("failed", failed); + env.platform.runAsync(() -> { + try { env.panel.call("rewards/ack", body); } catch (Exception e) { /* the panel re-offers it, and a second try is harmless */ } + }); + } + + /** Null when carried out, otherwise why not. */ + String apply(JsonObject d) { + UUID uuid; + try { uuid = UUID.fromString(text(d, "uuid")); } catch (IllegalArgumentException e) { return "bad player id"; } + Optional player = env.platform.player(uuid); + if (player.isEmpty()) return "player left"; // retried when they are back + JsonObject a = d.has("payload") && d.get("payload").isJsonObject() ? d.getAsJsonObject("payload") : new JsonObject(); + String name = player.get().name(); + RewardRules r = rules; + switch (text(d, "kind")) { + case "item" -> { + int amount = a.has("amount") ? a.get("amount").getAsInt() : 1; + if (!env.platform.giveItem(uuid, text(a, "item"), amount)) return "unknown item " + text(a, "item"); + player.get().send(Format.GREEN + "You received " + Format.WHITE + amount + "x " + prettyItem(text(a, "item")) + Format.GREEN + "!"); + } + case "permission" -> { + boolean value = !a.has("value") || a.get("value").getAsBoolean(); + long minutes = a.has("minutes") ? a.get("minutes").getAsLong() : 0; + String template = minutes > 0 ? r.permissionTempCommand() : value ? r.permissionCommand() : r.permissionDenyCommand(); + String command = template.replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{node}", text(a, "node")) + .replace("{value}", Boolean.toString(value)).replace("{duration}", minutes + "m"); + if (!env.platform.console(command)) return "the permissions command did not run (is LuckPerms installed?)"; + player.get().send(Format.GREEN + "You gained a new permission: " + Format.WHITE + text(a, "node") + Format.GREEN + "."); + } + case "group" -> { + String command = r.groupCommand().replace("{player}", name).replace("{uuid}", uuid.toString()).replace("{group}", text(a, "group")); + if (!env.platform.console(command)) return "the group command did not run (is LuckPerms installed?)"; + player.get().send(Format.GREEN + "You joined the " + Format.WHITE + text(a, "group") + Format.GREEN + " group."); + } + case "command" -> { + if (!r.allowCommands()) return "custom commands are switched off on this server (rewards.allow-commands)"; + String command = text(a, "command").replace("{player}", name).replace("{uuid}", uuid.toString()); + if (!env.platform.console(command)) return "command not found"; + } + case "message" -> player.get().send(Format.GOLD + text(a, "text")); + default -> { return "unknown reward kind"; } + } + return null; + } + + private static String prettyItem(String id) { + String n = id.contains(":") ? id.substring(id.indexOf(':') + 1) : id; + StringBuilder out = new StringBuilder(); + for (String w : n.split("[_/]")) if (!w.isEmpty()) out.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1)).append(' '); + return out.toString().trim(); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/RewardRules.java b/integrations/common/src/main/java/net/scopenet/core/RewardRules.java new file mode 100644 index 0000000..2a18afa --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/RewardRules.java @@ -0,0 +1,13 @@ +package net.scopenet.core; + +/** + * How this server carries out rewards the panel hands over. Permissions and groups go through console commands so any + * permissions plugin works; the defaults are LuckPerms'. Custom admin commands stay off unless the server owner allows them. + */ +public record RewardRules(boolean enabled, boolean allowCommands, String permissionCommand, String permissionDenyCommand, + String permissionTempCommand, String groupCommand) { + public static RewardRules defaults() { + return new RewardRules(true, false, "lp user {player} permission set {node} true", "lp user {player} permission set {node} false", + "lp user {player} permission settemp {node} {value} {duration}", "lp user {player} parent add {group}"); + } +} diff --git a/integrations/common/src/test/java/net/scopenet/core/RewardPollerTest.java b/integrations/common/src/test/java/net/scopenet/core/RewardPollerTest.java new file mode 100644 index 0000000..6521950 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/RewardPollerTest.java @@ -0,0 +1,88 @@ +package net.scopenet.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RewardPollerTest { + /** A platform that records what was given and run. */ + static final class Recording implements Platform { + final Kit.FakePlatform inner; + final List given = new ArrayList<>(), ran = new ArrayList<>(); + boolean knowsItems = true, knowsCommands = true; + Recording(Kit.FakePlatform inner) { this.inner = inner; } + public java.util.Optional player(java.util.UUID u) { return inner.player(u); } + public java.util.Optional playerByName(String n) { return inner.playerByName(n); } + public java.util.Collection online() { return inner.online(); } + public java.util.Optional safeSurface(String w, int x, int z) { return inner.safeSurface(w, x, z); } + public Pos worldSpawn(String w) { return inner.worldSpawn(w); } + public void runMain(Runnable r) { r.run(); } + public void runAsync(Runnable r) { r.run(); } + @Override public boolean giveItem(java.util.UUID p, String item, int amount) { if (knowsItems) given.add(item + "x" + amount); return knowsItems; } + @Override public boolean console(String c) { if (knowsCommands) ran.add(c); return knowsCommands; } + } + + private static JsonObject delivery(long id, String uuid, String kind, String payload) { + JsonObject o = new JsonObject(); + o.addProperty("id", id); + o.addProperty("uuid", uuid); + o.addProperty("kind", kind); + o.add("payload", JsonParser.parseString(payload)); + return o; + } + + @Test void rewardsAreCarriedOutAndReported(@TempDir Path dir) { + Kit kit = new Kit(dir); + Kit.Player steve = kit.platform.add("Steve"); + Recording platform = new Recording(kit.platform); + Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t")); + JsonObject[] ack = new JsonObject[1]; + kit.panel.handlers.put("rewards/ack", b -> { ack[0] = b; return new JsonObject(); }); + kit.panel.handlers.put("rewards/poll", b -> { + JsonArray list = new JsonArray(); + String id = steve.id.toString(); + list.add(delivery(1, id, "item", "{\"item\":\"minecraft:diamond\",\"amount\":3}")); + list.add(delivery(2, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":0}")); + list.add(delivery(3, id, "permission", "{\"node\":\"essentials.fly\",\"value\":true,\"minutes\":10080}")); + list.add(delivery(4, id, "group", "{\"group\":\"vip\"}")); + list.add(delivery(5, id, "command", "{\"command\":\"give {player} cake 1\"}")); + list.add(delivery(6, id, "message", "{\"text\":\"Well done!\"}")); + JsonObject o = new JsonObject(); + o.add("deliveries", list); + return o; + }); + RewardPoller poller = new RewardPoller(env, RewardRules.defaults()); + poller.tick(); + assertEquals(List.of("minecraft:diamondx3"), platform.given); + assertEquals(List.of("lp user Steve permission set essentials.fly true", "lp user Steve permission settemp essentials.fly true 10080m", + "lp user Steve parent add vip"), platform.ran, "custom commands are off by default"); + assertEquals(5, ack[0].getAsJsonArray("done").size()); + assertEquals(1, ack[0].getAsJsonArray("failed").size()); + assertTrue(ack[0].getAsJsonArray("failed").get(0).getAsJsonObject().get("error").getAsString().contains("allow-commands")); + assertTrue(steve.heard("Well done!")); + assertTrue(steve.heard("3x Diamond")); + } + + @Test void commandsRunWhenTheOwnerAllowsThem(@TempDir Path dir) { + Kit kit = new Kit(dir); + Kit.Player steve = kit.platform.add("Steve"); + Recording platform = new Recording(kit.platform); + Env env = new Env(platform, kit.panel, Features.all(), dir, kit.now::get, new java.util.Random(1), java.util.logging.Logger.getLogger("t")); + RewardRules r = RewardRules.defaults(); + RewardPoller poller = new RewardPoller(env, new RewardRules(true, true, r.permissionCommand(), r.permissionDenyCommand(), r.permissionTempCommand(), r.groupCommand())); + assertNull(poller.apply(delivery(1, steve.id.toString(), "command", "{\"command\":\"give {player} cake 1\"}"))); + assertEquals(List.of("give Steve cake 1"), platform.ran); + platform.knowsItems = false; + assertTrue(poller.apply(delivery(2, steve.id.toString(), "item", "{\"item\":\"mod:thing\",\"amount\":1}")).contains("unknown item")); + kit.platform.online.clear(); + assertEquals("player left", poller.apply(delivery(3, steve.id.toString(), "message", "{\"text\":\"hi\"}"))); + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java index 8de8142..c2fdf7f 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java @@ -42,6 +42,13 @@ final class FabricConfig { bool("clientlink.enabled", true), text("economy.currency_symbol", "$")); } + net.scopenet.core.RewardRules rewards() { + net.scopenet.core.RewardRules d = net.scopenet.core.RewardRules.defaults(); + return new net.scopenet.core.RewardRules(bool("rewards.enabled", true), bool("rewards.allow_commands", false), + text("rewards.permission_command", d.permissionCommand()), text("rewards.permission_deny_command", d.permissionDenyCommand()), + text("rewards.permission_temp_command", d.permissionTempCommand()), text("rewards.group_command", d.groupCommand())); + } + /** {@code permissions.default_level}: "all" lets everyone use commands unless LuckPerms says otherwise, "op" limits them to operators. */ boolean everyoneByDefault() { return !text("permissions.default_level", "all").equalsIgnoreCase("op"); } } diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java index a34a5a4..9e69690 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java @@ -55,6 +55,7 @@ public final class FabricFeatures implements Runnable { private Perms perms; private FabricConfig config; private ActionPoller poller; + private net.scopenet.core.RewardPoller rewards; private MapService map; private int ticks; private String placeholderStatus = "off"; @@ -113,6 +114,7 @@ public final class FabricFeatures implements Runnable { // Actions from the map, and the claims and pins the SCOPENET Map draws. poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()})); + rewards = new net.scopenet.core.RewardPoller(env, config.rewards()); map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(), config.bool("map.show_homes", false), s.getMotd()); integration.setMapOverlay(() -> { MapService current = map; return current == null ? "{\"claims\":[],\"pins\":[]}" : net.scopenet.core.map.MapOverlay.toJson(current.snapshot()).toString(); }); @@ -150,6 +152,7 @@ public final class FabricFeatures implements Runnable { cache.tick(p -> link.pushState(p)); link.tick(); poller.tick(); + rewards.tick(); map.tick(); } if (ticks % 40 == 0) commands.tick(); diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java index 87872c9..07d7d99 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricPlatform.java @@ -72,4 +72,26 @@ final class FabricPlatform implements Platform { @Override public void runMain(Runnable task) { server.execute(task); } @Override public void runAsync(Runnable task) { io.execute(task); } + + @Override public boolean giveItem(UUID id, String item, int amount) { + ServerPlayer p = server.getPlayerList().getPlayer(id); + ResourceLocation location = ResourceLocation.tryParse(item); + if (p == null || location == null) return false; + var found = net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(location); + if (found.isEmpty() || found.get() == net.minecraft.world.item.Items.AIR) return false; + net.minecraft.world.item.Item type = found.get(); + for (int left = amount; left > 0; ) { + int n = Math.min(left, type.getMaxStackSize()); + net.minecraft.world.item.ItemStack stack = new net.minecraft.world.item.ItemStack(type, n); + p.getInventory().add(stack); + if (!stack.isEmpty()) p.drop(stack, false); + left -= n; + } + return true; + } + + @Override public boolean console(String command) { + server.getCommands().performPrefixedCommand(server.createCommandSourceStack(), command); + return true; + } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java index e33f648..8b4d4b5 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java @@ -30,6 +30,7 @@ final class ClientLinkBridge implements PluginMessageListener { private final Integration integration; private final ShopPoints shops; private ActionPoller poller; + private net.scopenet.core.RewardPoller rewards; private MapService mapService; private EconomyHandler economy; @@ -82,7 +83,12 @@ final class ClientLinkBridge implements PluginMessageListener { mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(), plugin.getConfig().getBoolean("map.show-homes", false), Bukkit.getServer().getName()); integration.setMapOverlay(() -> net.scopenet.core.map.MapOverlay.toJson(mapService.snapshot()).toString()); - Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20); + var cfg = plugin.getConfig(); + var defaults = net.scopenet.core.RewardRules.defaults(); + rewards = new net.scopenet.core.RewardPoller(env, new net.scopenet.core.RewardRules(cfg.getBoolean("rewards.enabled", true), cfg.getBoolean("rewards.allow-commands", false), + cfg.getString("rewards.permission-command", defaults.permissionCommand()), cfg.getString("rewards.permission-deny-command", defaults.permissionDenyCommand()), + cfg.getString("rewards.permission-temp-command", defaults.permissionTempCommand()), cfg.getString("rewards.group-command", defaults.groupCommand()))); + Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); rewards.tick(); mapService.tick(); }, 60, 20); } /** /market point add|remove|list, on the block the player is looking at (or standing on). */ @@ -150,6 +156,22 @@ final class ClientLinkBridge implements PluginMessageListener { } @Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); } + @Override public boolean giveItem(UUID id, String item, int amount) { + Player p = Bukkit.getPlayer(id); + if (p == null) return false; + Material m = Material.matchMaterial(item); + if (m == null && item.startsWith("minecraft:")) m = Material.matchMaterial(item.substring(10)); + if (m == null || !m.isItem() || m == Material.AIR) return false; + for (int left = amount; left > 0; ) { + int n = Math.min(left, m.getMaxStackSize()); + for (ItemStack over : p.getInventory().addItem(new ItemStack(m, n)).values()) p.getWorld().dropItemNaturally(p.getLocation(), over); + left -= n; + } + return true; + } + + @Override public boolean console(String command) { return Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); } + record PaperPlayer(Player p) implements CorePlayer { @Override public UUID uuid() { return p.getUniqueId(); } @Override public String name() { return p.getName(); } diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index fada24a..0409624 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -735,6 +735,17 @@ pub async fn get_my_level(state: State<'_, AppState>) -> Res { resp.json().await.map_err(err) } +/// What quests, achievements and rank milestones hand out besides XP, as short lines keyed `quest:`. +#[tauri::command] +pub async fn get_reward_summaries(state: State<'_, AppState>) -> Res { + let req = account_api(&state, reqwest::Method::GET, "/reward-bundles").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Ok(serde_json::json!({})); + } + resp.json().await.map_err(err) +} + #[tauri::command] pub async fn get_my_quests(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?; diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index f0ab3c5..91d2a7e 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -101,6 +101,7 @@ pub fn run() { commands::get_livemap_info, commands::get_my_level, commands::get_my_quests, + commands::get_reward_summaries, commands::claim_quest, commands::get_my_achievements, commands::get_guilds, diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts index 63e6144..9205531 100644 --- a/launcher/src/lib/mock.ts +++ b/launcher/src/lib/mock.ts @@ -218,6 +218,8 @@ export async function mockInvoke(cmd: string, args: Record): Promis return 21; case 'get_my_level': return { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] }; + case 'get_reward_summaries': + return { 'quest:q1': ['$250.00', 'Diamond x3'], 'quest:q2': ['+4 claim chunks'] }; case 'get_my_quests': return [ { quest: { id: 'q1', title: 'Deepslate Quarry', description: 'Mine 50 deepslate blocks in the caverns.', quest_type: 'daily', category: 'mining', xp_reward: 120, stat_type: 'mine_deepslate', target_count: 50, icon: 'pickaxe', active: true }, current_count: 35, completed: false, claimed: false, expires_at: '2026-10-01T00:00:00Z' }, diff --git a/launcher/src/pages/Quests.svelte b/launcher/src/pages/Quests.svelte index a21ae7b..f389d5a 100644 --- a/launcher/src/pages/Quests.svelte +++ b/launcher/src/pages/Quests.svelte @@ -27,7 +27,10 @@ exploration: Compass }; + let extras = $state>({}); + async function loadData() { + invoke>('get_reward_summaries').then((r) => (extras = r ?? {})).catch(() => {}); loading = true; try { const [lvl, quests, achs] = await Promise.all([ @@ -227,6 +230,7 @@

{ach.description}

@@ -626,6 +631,9 @@ } .progress-labels { display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; justify-content: space-between; font-size: 0.8rem; } @@ -754,6 +762,7 @@ align-items: center; margin-top: 0.2rem; } + .extra-tag { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-size: 0.7rem; font-weight: 600; color: #fbbf24; background: color-mix(in srgb, #fbbf24 14%, transparent); border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent); } .xp-tag { font-size: 0.78rem; font-weight: 700; diff --git a/panel/server/tests/rank_titles.rs b/panel/server/tests/rank_titles.rs index 82857c2..bf88552 100644 --- a/panel/server/tests/rank_titles.rs +++ b/panel/server/tests/rank_titles.rs @@ -31,3 +31,24 @@ async fn renaming_a_rank_title_reaches_players() { let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await; assert!(rank(&levels).is_null(), "{levels}"); } + +#[tokio::test] +async fn renaming_a_global_title_reaches_the_profile_and_reward_list() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"Steve","password":"password123"}))).await; + let steve = t.login("Steve", "password123").await; + let uuid = t.uuid("Steve").await; + sqlx::query("INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, 100000, 10, 'now') ON CONFLICT(uuid) DO UPDATE SET global_xp = 100000").bind(&uuid).execute(&t.db).await.unwrap(); + let (_, made) = t.call("POST", "/api/admin/levels/rewards", Some(&admin), Some(json!({"level": 100, "reward_type": "title", "reward_value": "Explorer"}))).await; + let id = made["id"].as_i64().unwrap(); + let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await; + assert_eq!(me["title"], "Explorer", "{me}"); + t.call("PUT", &format!("/api/admin/levels/rewards/{id}"), Some(&admin), Some(json!({"level": 100, "reward_type": "title", "reward_value": "Pathfinder"}))).await; + let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await; + assert_eq!(me["title"], "Pathfinder", "{me}"); + let rewards = me["rewards"].to_string(); + assert!(rewards.contains("Pathfinder") && !rewards.contains("Explorer"), "{rewards}"); + let (_, list) = t.call("GET", "/api/v1/levels/rewards", Some(&steve), None).await; + assert!(list.to_string().contains("Pathfinder") && !list.to_string().contains("Explorer"), "{list}"); +} diff --git a/panel/web/src/components/RewardBundleEditor.svelte b/panel/web/src/components/RewardBundleEditor.svelte new file mode 100644 index 0000000..4b88f6f --- /dev/null +++ b/panel/web/src/components/RewardBundleEditor.svelte @@ -0,0 +1,116 @@ + + +
+
+ Extra rewards {actions.length ? `${actions.length} attached` : 'none yet'} + +
+ + {#if adding} + + {/if} + + {#each actions as a, i} + {@const Icon = meta(a.type).icon} +
+ +
+ {meta(a.type).label} + {#if a.type === 'money'} + + {:else if a.type === 'item'} + + + {:else if a.type === 'permission'} + + + {:else if a.type === 'group'} + + {:else if a.type === 'claim_chunks'} + + {:else if a.type === 'badge'} + + {:else if a.type === 'message'} + + {:else if a.type === 'command'} + + {/if} + {#if ['money', 'item', 'permission', 'group', 'command', 'message'].includes(a.type) && servers.length > 1} + + {/if} +
+ +
+ {/each} + {#each items as it}{/each} + {#if !actions.length && !adding}

Give money, items, permissions, claim chunks and more when this is earned.

{/if} +
+ + diff --git a/panel/web/src/lib/rewards.ts b/panel/web/src/lib/rewards.ts new file mode 100644 index 0000000..3d5aefd --- /dev/null +++ b/panel/web/src/lib/rewards.ts @@ -0,0 +1,23 @@ +import { get, put } from './api'; + +export type RewardAction = { + type: 'money' | 'item' | 'permission' | 'group' | 'claim_chunks' | 'badge' | 'message' | 'command'; + amount?: number; + item?: string; + node?: string; + value?: boolean; + minutes?: number; + group?: string; + badge?: string; + text?: string; + command?: string; + server_id?: number | null; +}; + +export type RewardSource = 'quest' | 'achievement' | 'level_reward'; + +export const loadBundle = (kind: RewardSource, id: string | number) => + get<{ actions: RewardAction[] }>(`/api/admin/reward-bundles/${kind}/${encodeURIComponent(String(id))}`).then((r) => r.actions ?? []); + +export const saveBundle = (kind: RewardSource, id: string | number, actions: RewardAction[]) => + put(`/api/admin/reward-bundles/${kind}/${encodeURIComponent(String(id))}`, { actions }); diff --git a/panel/web/src/pages/AchievementEditor.svelte b/panel/web/src/pages/AchievementEditor.svelte index d34911a..82ee88e 100644 --- a/panel/web/src/pages/AchievementEditor.svelte +++ b/panel/web/src/pages/AchievementEditor.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } from '@lucide/svelte'; import Modal from '../components/Modal.svelte'; + import RewardBundleEditor from '../components/RewardBundleEditor.svelte'; + import { loadBundle, saveBundle, type RewardAction } from '../lib/rewards'; import { get, post, put, del } from '../lib/api'; import { toast, toastError } from '../lib/toast.svelte'; import type { Achievement } from '../lib/types'; @@ -12,6 +14,7 @@ let filterCategory = $state('all'); let filterFrame = $state('all'); + let bundle = $state([]); let draft = $state | null>(null); let draftOpen = $state(false); let saving = $state(false); @@ -77,11 +80,14 @@ stat_type: 'blocks_broken', target_count: 100, }; + bundle = []; draftOpen = true; } function openEdit(a: Achievement) { draft = { ...a }; + bundle = []; + loadBundle('achievement', a.id).then((b) => (bundle = b)).catch(() => {}); draftOpen = true; } @@ -100,6 +106,7 @@ await post('/api/admin/achievements', draft); toast('Achievement created'); } + await saveBundle('achievement', draft.id!, bundle); draftOpen = false; draft = null; await loadAchievements(); @@ -368,6 +375,8 @@
+ + {/if} + + + +