Rewards: delivery on Paper/Fabric, admin bundle editor, launcher chips

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-10-01 00:59:08 +00:00
1 parent 209ade0119
commit 0dc948caa4
18 files changed
+496 -2

No files matched your search

@@ -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; }
}
@@ -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<CorePlayer> 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();
}
}
@@ -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}");
}
}
@@ -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<String> given = new ArrayList<>(), ran = new ArrayList<>();
boolean knowsItems = true, knowsCommands = true;
Recording(Kit.FakePlatform inner) { this.inner = inner; }
public java.util.Optional<CorePlayer> player(java.util.UUID u) { return inner.player(u); }
public java.util.Optional<CorePlayer> playerByName(String n) { return inner.playerByName(n); }
public java.util.Collection<? extends CorePlayer> online() { return inner.online(); }
public java.util.Optional<Pos> 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\"}")));
}
}
@@ -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"); }
}
@@ -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();
@@ -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;
}
}
@@ -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(); }
+11
View File
@@ -735,6 +735,17 @@ pub async fn get_my_level(state: State<'_, AppState>) -> Res<UserLevelInfo> {
resp.json().await.map_err(err)
}
/// What quests, achievements and rank milestones hand out besides XP, as short lines keyed `quest:<id>`.
#[tauri::command]
pub async fn get_reward_summaries(state: State<'_, AppState>) -> Res<serde_json::Value> {
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<Vec<UserQuest>> {
let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?;
+1
View File
@@ -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,
+2
View File
@@ -218,6 +218,8 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): 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' },
+9
View File
@@ -27,7 +27,10 @@
exploration: Compass
};
let extras = $state<Record<string, string[]>>({});
async function loadData() {
invoke<Record<string, string[]>>('get_reward_summaries').then((r) => (extras = r ?? {})).catch(() => {});
loading = true;
try {
const [lvl, quests, achs] = await Promise.all([
@@ -227,6 +230,7 @@
<p class="ach-desc">{ach.description}</p>
<div class="ach-footer">
<span class="xp-tag">+{ach.xp_reward} XP</span>
{#each extras[`achievement:${ach.id}`] ?? [] as extra}<span class="extra-tag"><Gift size={10} /> {extra}</span>{/each}
{#if ach.unlocked}
<span class="badge unlocked-tag"><Check size={11} /> Unlocked</span>
{:else}
@@ -293,6 +297,7 @@
<strong>{currentCount}</strong> / {targetCount} ({pct}%)
</span>
<span class="xp-badge">+{q.xp_reward} XP</span>
{#each extras[`quest:${q.id}`] ?? [] as extra}<span class="extra-tag"><Gift size={10} /> {extra}</span>{/each}
</div>
<div class="progress-bar-bg">
<div class="progress-fill" style:width="{pct}%"></div>
@@ -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;
+21
View File
@@ -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}");
}
@@ -0,0 +1,116 @@
<script lang="ts">
import { Coins, Package, KeyRound, Users, Map as MapIcon, Award, MessageSquare, Terminal, Plus, X, Gift } from '@lucide/svelte';
import { get } from '../lib/api';
import type { RewardAction } from '../lib/rewards';
import type { GameServer } from '../lib/types';
// Everything a quest, achievement or rank milestone hands out besides its XP and title.
let { actions = $bindable([]) }: { actions: RewardAction[] } = $props();
let servers = $state<GameServer[]>([]);
$effect(() => { get<GameServer[]>('/api/admin/servers').then((s) => (servers = Array.isArray(s) ? s : [])).catch(() => {}); });
const kinds = [
{ id: 'money', label: 'Money', icon: Coins, blurb: 'Credit the player’s balance' },
{ id: 'item', label: 'Item', icon: Package, blurb: 'Hand over items in game' },
{ id: 'permission', label: 'Permission', icon: KeyRound, blurb: 'Grant an in-game permission (LuckPerms)' },
{ id: 'group', label: 'Group', icon: Users, blurb: 'Add them to a permission group' },
{ id: 'claim_chunks', label: 'Claim chunks', icon: MapIcon, blurb: 'Raise their guild’s land limit' },
{ id: 'badge', label: 'Badge', icon: Award, blurb: 'Show a badge on their profile' },
{ id: 'message', label: 'Message', icon: MessageSquare, blurb: 'Tell them something in game' },
{ id: 'command', label: 'Command', icon: Terminal, blurb: 'Run a console command (the server must allow it)' }
] as const;
const meta = (t: string) => kinds.find((k) => k.id === t)!;
const items = ['diamond', 'emerald', 'netherite_ingot', 'golden_apple', 'enchanted_golden_apple', 'elytra', 'totem_of_undying', 'experience_bottle',
'iron_ingot', 'gold_ingot', 'ender_pearl', 'name_tag', 'diamond_pickaxe', 'diamond_sword', 'shulker_box', 'cooked_beef', 'bread', 'torch'];
let adding = $state(false);
function add(type: RewardAction['type']) {
const base: Record<string, RewardAction> = {
money: { type: 'money', amount: 100 }, item: { type: 'item', item: 'diamond', amount: 1 },
permission: { type: 'permission', node: '', value: true, minutes: 0 }, group: { type: 'group', group: '' },
claim_chunks: { type: 'claim_chunks', amount: 4 }, badge: { type: 'badge', badge: '' },
message: { type: 'message', text: '' }, command: { type: 'command', command: '' }
};
actions = [...actions, base[type]];
adding = false;
}
const remove = (i: number) => (actions = actions.filter((_, n) => n !== i));
const days = (a: RewardAction) => ((a.minutes ?? 0) / 1440);
const setDays = (a: RewardAction, v: string) => { a.minutes = Math.max(0, Math.round((Number(v) || 0) * 1440)); };
</script>
<section class="bundle">
<header>
<span class="head"><Gift size={16} /> Extra rewards <small>{actions.length ? `${actions.length} attached` : 'none yet'}</small></span>
<button type="button" class="ghost sm" onclick={() => (adding = !adding)}><Plus size={14} /> Add reward</button>
</header>
{#if adding}
<div class="menu">
{#each kinds as k}
{@const Icon = k.icon}
<button type="button" class="kind" onclick={() => add(k.id)}><Icon size={16} /><strong>{k.label}</strong><small>{k.blurb}</small></button>
{/each}
</div>
{/if}
{#each actions as a, i}
{@const Icon = meta(a.type).icon}
<div class="row">
<span class="ic"><Icon size={16} /></span>
<div class="fields">
<strong class="kind-name">{meta(a.type).label}</strong>
{#if a.type === 'money'}
<label>Amount<input type="number" min="0.01" step="0.01" bind:value={a.amount} /></label>
{:else if a.type === 'item'}
<label class="wide">Item<input list="reward-items" bind:value={a.item} placeholder="diamond or modid:item" /></label>
<label>How many<input type="number" min="1" max="6400" bind:value={a.amount} /></label>
{:else if a.type === 'permission'}
<label class="wide">Permission node<input bind:value={a.node} placeholder="essentials.fly" /></label>
<label>For (days, 0 = forever)<input type="number" min="0" step="1" value={days(a)} oninput={(e) => setDays(a, e.currentTarget.value)} /></label>
{:else if a.type === 'group'}
<label class="wide">Group<input bind:value={a.group} placeholder="vip" /></label>
{:else if a.type === 'claim_chunks'}
<label>Extra chunks<input type="number" min="1" max="10000" bind:value={a.amount} /></label>
{:else if a.type === 'badge'}
<label class="wide">Badge name<input bind:value={a.badge} maxlength="40" placeholder="Pathfinder" /></label>
{:else if a.type === 'message'}
<label class="wide">Message<input bind:value={a.text} maxlength="200" placeholder="Thanks for playing!" /></label>
{:else if a.type === 'command'}
<label class="wide">Console command <small>({'{player}'} and {'{uuid}'} are filled in)</small><input bind:value={a.command} placeholder="give {'{player}'} minecraft:cake 1" /></label>
{/if}
{#if ['money', 'item', 'permission', 'group', 'command', 'message'].includes(a.type) && servers.length > 1}
<label>Where
<select value={a.server_id ?? ''} onchange={(e) => (a.server_id = e.currentTarget.value ? Number(e.currentTarget.value) : null)}>
<option value="">{a.type === 'money' ? 'Every economy' : 'Wherever they are online'}</option>
{#each servers as s}<option value={s.id}>{s.name}</option>{/each}
</select>
</label>
{/if}
</div>
<button type="button" class="ghost icon" onclick={() => remove(i)} aria-label="Remove reward"><X size={15} /></button>
</div>
{/each}
<datalist id="reward-items">{#each items as it}<option value={it}></option>{/each}</datalist>
{#if !actions.length && !adding}<p class="empty">Give money, items, permissions, claim chunks and more when this is earned.</p>{/if}
</section>
<style>
.bundle { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.9rem; border: 1px solid var(--line, #ffffff1a); border-radius: 14px; background: color-mix(in srgb, var(--accent, #8b6cff) 5%, transparent); }
header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
.head { display: inline-flex; align-items: center; gap: 0.45rem; font-weight: 600; }
.head small { color: var(--muted); font-weight: 400; margin-left: 0.3rem; }
.menu { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 0.5rem; }
.kind { display: grid; grid-template-columns: auto 1fr; gap: 0.1rem 0.6rem; align-items: center; text-align: left; padding: 0.6rem 0.7rem; border-radius: 12px; background: var(--surface, #ffffff0d); border: 1px solid var(--line, #ffffff1a); }
.kind:hover { border-color: var(--accent); }
.kind small { grid-column: 2; color: var(--muted); font-size: 0.72rem; line-height: 1.3; }
.row { display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.65rem; border-radius: 12px; background: var(--surface, #ffffff0d); border: 1px solid var(--line, #ffffff14); }
.ic { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: 10px; background: color-mix(in srgb, var(--accent, #8b6cff) 20%, transparent); color: var(--accent, #8b6cff); flex-shrink: 0; }
.fields { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 0.7rem; flex: 1; min-width: 0; }
.kind-name { flex-basis: 100%; font-size: 0.85rem; }
label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.74rem; color: var(--muted); min-width: 7.5rem; }
label.wide { flex: 1; min-width: 12rem; }
.empty { margin: 0; color: var(--muted); font-size: 0.82rem; }
.sm { display: inline-flex; align-items: center; gap: 0.3rem; }
</style>
+23
View File
@@ -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 });
@@ -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<string>('all');
let filterFrame = $state<string>('all');
let bundle = $state<RewardAction[]>([]);
let draft = $state<Partial<Achievement> | 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 @@
<input id="a-event" type="text" bind:value={draft.event_type} placeholder="e.g. kill_dragon, enter_nether, craft_beacon" />
</div>
<RewardBundleEditor bind:actions={bundle} />
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={saving}>
+11 -1
View File
@@ -3,6 +3,8 @@
import { onMount } from 'svelte';
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } 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 { LevelReward, UserLevelInfo } from '../lib/types';
@@ -12,6 +14,7 @@
let loading = $state(true);
let curve = $state({ base: 100, exponent: 1.5, max: 0 });
let bundle = $state<RewardAction[]>([]);
let draft = $state<Partial<LevelReward> | null>(null);
let draftOpen = $state(false);
let saving = $state(false);
@@ -64,11 +67,14 @@
description: 'Grants the exclusive [Explorer] title across all servers and in launcher profile.',
icon: '🧭',
};
bundle = [];
draftOpen = true;
}
function openEdit(r: LevelReward) {
draft = { ...r };
bundle = [];
loadBundle('level_reward', r.id).then((b) => (bundle = b)).catch(() => {});
draftOpen = true;
}
@@ -81,9 +87,11 @@
try {
if (draft.id) {
await put(`/api/admin/levels/rewards/${draft.id}`, { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
await saveBundle('level_reward', draft.id, bundle);
toast('Reward updated');
} else {
await post('/api/admin/levels/rewards', { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
const made = await post<{ id: number }>('/api/admin/levels/rewards', { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
if (bundle.length) await saveBundle('level_reward', made.id, bundle);
toast('Reward created');
}
draftOpen = false;
@@ -246,6 +254,8 @@
</div>
{/if}
<RewardBundleEditor bind:actions={bundle} />
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={saving}>
+9
View File
@@ -2,6 +2,8 @@
import { onMount } from 'svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pin, SlidersHorizontal, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } 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 { Quest } from '../lib/types';
@@ -12,6 +14,7 @@
let filterCategory = $state<string>('all');
let search = $state('');
let bundle = $state<RewardAction[]>([]);
let draft = $state<Partial<Quest> | null>(null);
let draftOpen = $state(false);
let saving = $state(false);
@@ -104,11 +107,14 @@
active: true,
pinned: false,
};
bundle = [];
draftOpen = true;
}
function openEdit(q: Quest) {
draft = { ...q };
bundle = [];
loadBundle('quest', q.id).then((b) => (bundle = b)).catch(() => {});
draftOpen = true;
}
@@ -127,6 +133,7 @@
await post('/api/admin/quests', draft);
toast('Quest created');
}
await saveBundle('quest', draft.id!, bundle);
draftOpen = false;
draft = null;
await loadQuests();
@@ -363,6 +370,8 @@
</label>
</div>
<RewardBundleEditor bind:actions={bundle} />
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={saving}>