From c7d41330c3316131f127f550df92ca24da4246de Mon Sep 17 00:00:00 2001 From: downbtn Date: Tue, 28 Jul 2026 22:46:11 -0400 Subject: [PATCH] auto-deduct aspects --- src/tel/eden/mod/EdenModClient.java | 104 +++++++++++++++++- .../eden/mod/chat/DiscordChatFormatter.java | 7 ++ .../eden/mod/net/BridgeWebSocketClient.java | 27 +++++ src/tel/eden/mod/reward/GuildRewards.java | 66 +++++++++-- 4 files changed, 195 insertions(+), 9 deletions(-) diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 4a6fbd8..9f719e9 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -195,6 +195,17 @@ private record PendingWarReport(String territory, List members) { } private final java.util.concurrent.ConcurrentLinkedQueue pendingWarReports = new java.util.concurrent.ConcurrentLinkedQueue<>(); + + /** A deduct request sent to the backend and still waiting for its reply. */ + private record PendingDeduct(String rewardKind, String target, int displayUnits) { + } + + // Deduct replies carry no request id, and a failed one carries nothing but the error + // string — so matching a failure back to the player it was about means remembering + // what we asked for. The socket delivers replies in request order, so the oldest + // outstanding request is the one being answered. Cleared on reconnect, since anything + // in flight when the socket dropped will never be answered. + private final java.util.concurrent.ConcurrentLinkedQueue pendingDeducts = new java.util.concurrent.ConcurrentLinkedQueue<>(); private long lastWeeklyWarsRefresh; // Set on game join, sent once the bridge connects, cleared on send/disconnect, so // a "logged in" notice fires per game session (not on every WS reconnect). @@ -312,6 +323,9 @@ public void onInitializeClient() { // Relay the guild's live reward storage to the backend counter: the exact value // after a gift run, and (in onClientTick) whenever a Chief opens the menu. guildRewards.setStorageReporter(this::relayStorage); + // Deduct what was just paid out from the backend's pending balance, so a payout + // no longer has to be followed by a /manage reset on Discord. + guildRewards.setDeductReporter(this::onRewardHandedOut); KeyMapping.Category edenCategory = new KeyMapping.Category(net.minecraft.resources.Identifier.parse("edenmod")); openConfigKey = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.edenmod.open_config", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_B, edenCategory)); @@ -383,7 +397,7 @@ public void onInitializeClient() { }).then(ClientCommandManager.literal("download").executes(ctx -> { updateDownload(ctx.getSource()); return 1; - }))).then(buildGiftCommand()).then(ClientCommandManager.literal("dump").then(ClientCommandManager.argument("member", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> dumpEmeralds(ctx.getSource(), StringArgumentType.getString(ctx, "member"))))).then(ClientCommandManager.literal("wars").executes(ctx -> { + }))).then(buildGiftCommand()).then(buildDeductCommand()).then(ClientCommandManager.literal("dump").then(ClientCommandManager.argument("member", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> dumpEmeralds(ctx.getSource(), StringArgumentType.getString(ctx, "member"))))).then(ClientCommandManager.literal("wars").executes(ctx -> { requestWarCounts(ctx.getSource(), 7); return 1; }).then(ClientCommandManager.argument("days", com.mojang.brigadier.arguments.IntegerArgumentType.integer(1, 365)).executes(ctx -> { @@ -584,6 +598,22 @@ public void onAspectsPending(java.util.List entries, String error, pendingAspectsGeneration.incrementAndGet(); } + @Override + public void onRewardDeductReply(String target, String rewardKind, int amount, int remaining, String error, String color) { + PendingDeduct sent = pendingDeducts.poll(); + if (error != null && !error.isEmpty()) { + displayColored(color, () -> DiscordChatFormatter.systemLine("Couldn't deduct pending rewards: " + error, ChatFormatting.RED)); + // Offer the manual route for the request that failed. If nothing is + // outstanding the reply answers a request from before a reconnect, + // and there is no player to name. + if (sent != null) { + display(() -> GuildRewards.manageResetFallbackLine(sent.rewardKind(), sent.target())); + } + return; + } + displayColored(color, () -> DiscordChatFormatter.systemLine("Deducted " + amount + " pending " + rewardKind + " from " + target + " — " + remaining + " remaining.", ChatFormatting.GREEN)); + } + @Override public void onPartyUpdate(String event, String actor, PartyInfo party, String color) { knownParties.removeIf(p -> p.id() == party.id()); @@ -861,6 +891,35 @@ private LiteralArgumentBuilder giftTypeArg(String lit return ClientCommandManager.literal(literal).then(ClientCommandManager.argument("amount", IntegerArgumentType.integer(1)).executes(ctx -> giftReward(ctx.getSource(), StringArgumentType.getString(ctx, "member"), type, IntegerArgumentType.getInteger(ctx, "amount")))); } + /** Build {@code /eden deduct } (Chiefs only). */ + private LiteralArgumentBuilder buildDeductCommand() { + return ClientCommandManager.literal("deduct").then(deductKindArg("aspects")).then(deductKindArg("emeralds")); + } + + private LiteralArgumentBuilder deductKindArg(String kind) { + // The bound matches the backend's own validation, so an out-of-range amount is + // rejected by the command parser instead of making a doomed round trip. + return ClientCommandManager.literal(kind).then(ClientCommandManager.argument("member", StringArgumentType.word()).suggests(this::suggestMembers).then(ClientCommandManager.argument("amount", IntegerArgumentType.integer(1, 100_000)).executes(ctx -> deductReward(ctx.getSource(), kind, StringArgumentType.getString(ctx, "member"), IntegerArgumentType.getInteger(ctx, "amount"))))); + } + + private int deductReward(FabricClientCommandSource source, String rewardKind, String member, int amount) { + guildRewards.ensureFresh(playerName()); + // Courtesy check only — the backend authorises by the linked account either way. + // Skipped while the roster is still loading, so a cold rank cache can't refuse a + // Chief; the unknown-member case is likewise left to the backend, whose view of + // the guild is fresher than the cached roster. + if (!guildRewards.memberNames().isEmpty() && !guildRewards.isChief()) { + source.sendFeedback(Component.literal("Only guild Chiefs can deduct pending rewards.").withStyle(ChatFormatting.RED)); + return 0; + } + if (socket == null) { + source.sendFeedback(notConnected()); + return 0; + } + sendDeduct(rewardKind, member, amount); + return 1; + } + private CompletableFuture suggestMembers(CommandContext context, SuggestionsBuilder builder) { String remaining = builder.getRemaining().toLowerCase(Locale.ROOT); for (String name : guildRewards.memberNames()) { @@ -887,6 +946,39 @@ private int dumpEmeralds(FabricClientCommandSource source, String member) { return 1; } + /** + * A reward with a pending balance was just handed out in-game. Batch payouts deduct + * it straight away; single gifts offer the deduction as a clickable command, since a + * gift isn't necessarily paying off what the member is owed. + * + *

Runs on the GuildRewards worker thread. + */ + private void onRewardHandedOut(String receiver, String rewardKind, int displayUnits, boolean autoDeduct) { + if (displayUnits <= 0) { + // An emerald handout that doesn't fill whole display units; the backend can't + // take a fraction of one, so this still has to be settled by hand. + display(() -> GuildRewards.manageResetFallbackLine(rewardKind, receiver)); + return; + } + if (autoDeduct) { + sendDeduct(rewardKind, receiver, displayUnits); + } else { + display(() -> DiscordChatFormatter.deductOffer(rewardKind, receiver, displayUnits)); + } + } + + /** Send one deduct request, falling back to the manual command when offline. */ + private void sendDeduct(String rewardKind, String target, int displayUnits) { + BridgeWebSocketClient current = socket; + if (current == null) { + display(() -> DiscordChatFormatter.systemLine("Not connected to the bridge — deduct " + target + "'s pending " + rewardKind + " by hand:", ChatFormatting.RED)); + display(() -> GuildRewards.manageResetFallbackLine(rewardKind, target)); + return; + } + pendingDeducts.add(new PendingDeduct(rewardKind, target, displayUnits)); + current.sendRewardDeductRequest(rewardKind, target, displayUnits); + } + /** Gate the reward commands to Wynncraft and ensure the member list is loaded. */ private boolean ensureRewardsReady(FabricClientCommandSource source) { if (!onWynncraft) { @@ -1572,7 +1664,7 @@ private int showEmojis(FabricClientCommandSource source) { private record HelpEntry(String command, String description) { } - private static final List HELP_ENTRIES = List.of(new HelpEntry("/eden config", "open the config screen"), new HelpEntry("/eden online", "who's connected to the bridge"), new HelpEntry("/eden cf", "flip a coin"), new HelpEntry("/eden diceroll", "roll a die"), new HelpEntry("/eden wars [days]", "guild war counts (same as Discord)"), new HelpEntry("/eden emojis", "open the chat emote picker"), new HelpEntry("/eden party", "list open parties (click to join)"), new HelpEntry("/eden party create [note]", "open a raid party"), new HelpEntry("/eden party join ", "join a party"), new HelpEntry("/eden party leave [id]", "leave your party"), new HelpEntry("/eden anni [note]", "open an Annihilation party (2-10)"), new HelpEntry("/eden command alias", "open the command alias editor"), new HelpEntry("/eden command keybind", "open the command keybind editor"), new HelpEntry("/eden update", "check for a pending update"), new HelpEntry("/eden update download", "download the update now (applies on exit)"), new HelpEntry("/eden aspects pending", "members' pending aspects — Chiefs only"), new HelpEntry("/eden gift ", "gift guild rewards — Chiefs only"), new HelpEntry("/eden dump ", "gift all guild-bank emeralds to a member — Chiefs only"), new HelpEntry("/eden help", "this help screen")); + private static final List HELP_ENTRIES = List.of(new HelpEntry("/eden config", "open the config screen"), new HelpEntry("/eden online", "who's connected to the bridge"), new HelpEntry("/eden cf", "flip a coin"), new HelpEntry("/eden diceroll", "roll a die"), new HelpEntry("/eden wars [days]", "guild war counts (same as Discord)"), new HelpEntry("/eden emojis", "open the chat emote picker"), new HelpEntry("/eden party", "list open parties (click to join)"), new HelpEntry("/eden party create [note]", "open a raid party"), new HelpEntry("/eden party join ", "join a party"), new HelpEntry("/eden party leave [id]", "leave your party"), new HelpEntry("/eden anni [note]", "open an Annihilation party (2-10)"), new HelpEntry("/eden command alias", "open the command alias editor"), new HelpEntry("/eden command keybind", "open the command keybind editor"), new HelpEntry("/eden update", "check for a pending update"), new HelpEntry("/eden update download", "download the update now (applies on exit)"), new HelpEntry("/eden aspects pending", "members' pending aspects — Chiefs only"), new HelpEntry("/eden gift ", "gift guild rewards — Chiefs only"), new HelpEntry("/eden dump ", "gift all guild-bank emeralds to a member — Chiefs only"), new HelpEntry("/eden deduct ", "deduct a payout from pending rewards — Chiefs only"), new HelpEntry("/eden help", "this help screen")); private static final class TrackedCommandKeybind { private final String input; @@ -1648,6 +1740,14 @@ private boolean checkWynncraftTabActive(Minecraft mc) { /** On a fresh bridge connection, announce this session's login exactly once. */ private void onBridgeConnected() { + // Any deduct that was in flight when the socket dropped will never be answered. + // Retrying isn't safe — the backend may well have applied it before the drop — so + // hand each one back to the Chief to check and settle manually. + for (PendingDeduct stale = pendingDeducts.poll(); stale != null; stale = pendingDeducts.poll()) { + PendingDeduct entry = stale; + display(() -> DiscordChatFormatter.systemLine("Lost the bridge before " + entry.target() + "'s " + entry.displayUnits() + " " + entry.rewardKind() + " were confirmed deducted — check and reset if needed:", ChatFormatting.RED)); + display(() -> GuildRewards.manageResetFallbackLine(entry.rewardKind(), entry.target())); + } if (loginPending) { loginPending = false; BridgeWebSocketClient current = socket; diff --git a/src/tel/eden/mod/chat/DiscordChatFormatter.java b/src/tel/eden/mod/chat/DiscordChatFormatter.java index f356fe0..af8b6ae 100644 --- a/src/tel/eden/mod/chat/DiscordChatFormatter.java +++ b/src/tel/eden/mod/chat/DiscordChatFormatter.java @@ -118,6 +118,13 @@ public static Component updateAvailable(String version, String pageUrl) { return line; } + /** "Paid X N aspects [Deduct them]" — one click deducts the payout on the backend. */ + public static Component deductOffer(String rewardKind, String target, int displayUnits) { + String command = "/eden deduct " + rewardKind + " " + target + " " + displayUnits; + Style deduct = Style.EMPTY.withColor(ChatFormatting.GREEN).withUnderlined(true).withClickEvent(new ClickEvent.RunCommand(command)).withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to run " + command))); + return Component.empty().append(prefix(SHIELD)).append(Component.literal("Paid " + target + " " + displayUnits + " " + rewardKind + " ").withStyle(ChatFormatting.GOLD)).append(Component.literal("[Deduct them]").setStyle(deduct)); + } + /** A green/gold/red client-side notice line with the guild shield prefix. */ public static Component systemLine(String text, ChatFormatting color) { return Component.empty().append(prefix(SHIELD)).append(Component.literal(text).withStyle(color)); diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index 3159d84..ffecacd 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -52,6 +52,14 @@ public interface MessageSink { */ void onAspectsPending(java.util.List entries, String error, String color); + /** + * Response to a {@code rewardDeductRequest}: on success {@code error} is empty + * and target/kind/amount/remaining carry the display-unit values the backend + * applied. On failure only {@code error} is set — the reply carries no target, + * kind or amount, so the caller has to remember what it asked for. + */ + void onRewardDeductReply(String target, String rewardKind, int amount, int remaining, String error, String color); + /** A raid party changed state ({@code open}/{@code join}/{@code full}/etc.). */ void onPartyUpdate(String event, String actor, PartyInfo party, String color); @@ -224,6 +232,24 @@ public void sendAspectsPendingRequest() { sendType("aspectsPendingRequest"); } + /** + * Ask the backend to deduct {@code amount} pending rewards from {@code target} + * after an in-game payout (Chiefs only; the backend authorises by JWT). The amount + * is in the same display units the Discord side shows, not internal sub-units. + */ + public void sendRewardDeductRequest(String rewardKind, String target, int amount) { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "rewardDeductRequest"); + obj.addProperty("rewardKind", rewardKind); + obj.addProperty("target", target); + obj.addProperty("amount", amount); + current.sendText(obj.toString(), true); + } + /** Open a new party in-game for the given label (raid name or Annihilation). */ public void sendPartyOpen(String raid, int maxSize, String note, int filled) { WebSocket current = socket; @@ -653,6 +679,7 @@ private void handlePayload(String payload) { case "logoutNotice" -> sink.onLogoutNotice(get(obj, "username"), get(obj, "color")); case "onlineList" -> sink.onOnlineList(getStringArray(obj, "users"), get(obj, "color")); case "aspectsPendingReply" -> sink.onAspectsPending(parsePendingEntries(obj), get(obj, "error"), get(obj, "color")); + case "rewardDeductReply" -> sink.onRewardDeductReply(get(obj, "target"), get(obj, "rewardKind"), getInt(obj, "amount", 0), getInt(obj, "remaining", 0), get(obj, "error"), get(obj, "color")); case "partyUpdate" -> sink.onPartyUpdate(get(obj, "event"), get(obj, "actor"), parseParty(obj), get(obj, "color")); case "partyListReply" -> sink.onPartyList(parsePartyList(obj), get(obj, "color")); case "partyFeedback" -> sink.onPartyFeedback(get(obj, "message"), get(obj, "color")); diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index f75ba5b..9de7e2a 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -60,6 +60,10 @@ public final class GuildRewards { private static final int NEXT_PAGE_SLOT = 28; private static final int MAX_PAGES = 15; private static final int EMERALDS_PER_ITEM = 1024; + // The backend tracks pending emeralds in 4096-emerald display units (one liquid + // emerald), but the guild menu hands them out one 1024-emerald item at a time, so + // four handouts make up one deductible unit. + private static final int ITEMS_PER_DISPLAY_UNIT = 4096 / EMERALDS_PER_ITEM; private static final Pattern COUNT = Pattern.compile("(\\d+)\\s*/\\s*\\d+"); /** A reward kind and how it maps onto the guild-manage menu. */ @@ -107,8 +111,24 @@ public interface StorageReporter { void report(int aspects, int tomes, long emeralds); } + /** + * Notified after each handout of a reward kind that has a pending balance on the + * backend ("aspects"/"emeralds"), so the payout can be deducted there instead of + * being reset by hand on Discord. + * + *

{@code displayUnits} is the handout in the backend's display units, or -1 when + * the amount handed out doesn't convert to a whole number of them. {@code autoDeduct} + * is true for batch payouts — the Chief already chose those amounts from the pending + * list, so deducting them needs no further confirmation — and false for single gifts, + * which are offered as a clickable command instead. + */ + public interface DeductReporter { + void report(String receiver, String rewardKind, int displayUnits, boolean autoDeduct); + } + private volatile RewardReporter reporter; private volatile StorageReporter storageReporter; + private volatile DeductReporter deductReporter; // True while a gift run is driving the menu, so the passive tick-time reader in // EdenModClient doesn't relay a mid-gift (pre-swap) count; the run relays the exact // post-gift value itself. @@ -124,6 +144,11 @@ public void setStorageReporter(StorageReporter storageReporter) { this.storageReporter = storageReporter; } + /** Attach the reporter that deducts a handout from the backend's pending balance. */ + public void setDeductReporter(DeductReporter deductReporter) { + this.deductReporter = deductReporter; + } + /** Whether a gift run is currently driving the guild-manage menu. */ public boolean isGiftInProgress() { return giftInProgress; @@ -322,7 +347,7 @@ private void run(String name, RewardType type, int requested, boolean dump) { chat(name + " has not been in the guild for a week, and is not eligible " + "for rewards.", ChatFormatting.YELLOW); return; } - runSingle(name, type, requested, dump); + runSingle(name, type, requested, dump, false); } catch (Exception e) { LOGGER.warn("Gift run failed", e); chat("Gift failed: " + e.getMessage(), ChatFormatting.RED); @@ -337,8 +362,11 @@ private void run(String name, RewardType type, int requested, boolean dump) { * flag. Returns true when at least one unit was handed out; a false return means a * soft failure (menu wouldn't open, nothing to gift, member item missing) that has * already been reported in chat. Client-thread timeouts propagate as exceptions. + * + *

{@code batch} marks a run that is part of a payout of the backend's pending + * list, which deducts the handout automatically rather than offering the deduction. */ - private boolean runSingle(String name, RewardType type, int requested, boolean dump) { + private boolean runSingle(String name, RewardType type, int requested, boolean dump, boolean batch) { if (!openRewardsMenu()) { chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); return false; @@ -388,16 +416,40 @@ private boolean runSingle(String name, RewardType type, int requested, boolean d currentStorageReporter.report((int) finalCounts[0], (int) finalCounts[1], finalCounts[2]); } if (type.resetKind != null) { - // Show the matching /manage reset command, clickable to copy, so the - // pending balance can be zeroed on Discord after the in-game payout. - String command = "/manage reset kind:" + type.resetKind + " player:" + name; - chatComponent(Component.literal(command).withStyle(Style.EMPTY.withColor(ChatFormatting.GREEN).withUnderlined(true).withClickEvent(new ClickEvent.CopyToClipboard(command)).withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to copy this command"))))); + DeductReporter currentDeductReporter = deductReporter; + if (currentDeductReporter != null) { + currentDeductReporter.report(name, type.resetKind, displayUnits(type, amount), batch); + } else { + chatComponent(manageResetFallbackLine(type.resetKind, name)); + } } else { chat("Done — gifted " + name + " " + total + " " + type.label + ".", ChatFormatting.GREEN); } return true; } + /** + * How many of the backend's display units a handout of {@code menuAmount} items is + * worth, or -1 when it doesn't divide into whole units. Aspects map one-to-one; + * emeralds only line up every {@link #ITEMS_PER_DISPLAY_UNIT} items, and the backend + * has no way to take a fraction of a unit. + */ + public static int displayUnits(RewardType type, int menuAmount) { + if (type != RewardType.EMERALD) { + return menuAmount; + } + return menuAmount % ITEMS_PER_DISPLAY_UNIT == 0 ? menuAmount / ITEMS_PER_DISPLAY_UNIT : -1; + } + + /** + * The matching {@code /manage reset} command, clickable to copy, so the pending + * balance can still be zeroed by hand on Discord when the bridge can't do it. + */ + public static Component manageResetFallbackLine(String resetKind, String player) { + String command = "/manage reset kind:" + resetKind + " player:" + player; + return Component.literal(command).withStyle(Style.EMPTY.withColor(ChatFormatting.GREEN).withUnderlined(true).withClickEvent(new ClickEvent.CopyToClipboard(command)).withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to copy this command")))); + } + /** Open {@code /gu man} and step into member management. True if the menu came up. */ private boolean openRewardsMenu() { Minecraft mc = Minecraft.getInstance(); @@ -491,7 +543,7 @@ private void batchRun(List requested) { try { for (PayoutTarget target : targets) { done++; - if (runSingle(target.name(), RewardType.ASPECT, target.aspects(), false)) { + if (runSingle(target.name(), RewardType.ASPECT, target.aspects(), false, true)) { paid++; } else { skipped.add(target.name());