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\"}")));
}
}