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:
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();
|
||||
|
||||
+22
@@ -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(); }
|
||||
|
||||
Reference in new issue
Block a user