From c7d41330c3316131f127f550df92ca24da4246de Mon Sep 17 00:00:00 2001 From: downbtn Date: Tue, 28 Jul 2026 22:46:11 -0400 Subject: [PATCH 1/4] 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()); From 91a31544de16cf308694f1283a6de5af7c6d2544 Mon Sep 17 00:00:00 2001 From: downbtn Date: Thu, 30 Jul 2026 22:04:03 -0400 Subject: [PATCH 2/4] Add toggle for auto-deduct --- src/tel/eden/mod/config/BridgeConfig.java | 8 +++++ src/tel/eden/mod/gui/AspectsPayoutScreen.java | 29 ++++++++++++++++--- src/tel/eden/mod/reward/GuildRewards.java | 29 +++++++++++-------- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index cf5681e..5f8db39 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -60,6 +60,14 @@ public final class BridgeConfig { */ public boolean partyAnnounce = true; + /** + * Whether an aspect payout also deducts what it paid from each member's pending + * total on the backend, so the Discord side matches without a manual reset. Toggled + * by the checkbox on the payout screen, which is where it takes effect; remembered + * so a Chief who settles the totals another way isn't re-ticking it every payout. + */ + public boolean payoutAutoDeduct = true; + public enum GameDisplayMode { ALL("Shown (All)"), NONE("Hidden (All)"), REACTIONS("Show Only Reactions"); diff --git a/src/tel/eden/mod/gui/AspectsPayoutScreen.java b/src/tel/eden/mod/gui/AspectsPayoutScreen.java index 141b817..7547a5d 100644 --- a/src/tel/eden/mod/gui/AspectsPayoutScreen.java +++ b/src/tel/eden/mod/gui/AspectsPayoutScreen.java @@ -26,7 +26,10 @@ */ public final class AspectsPayoutScreen extends EdenReferenceScreen { private static final int BASE_PANEL_WIDTH = 420; - private static final int BASE_PANEL_HEIGHT = 300; + private static final int BASE_PANEL_HEIGHT = 322; + // The auto-update option row, between the quick actions and Pay Out. + private static final int OPTION_ROW_Y = 266; + private static final int OPTION_BOX_SIZE = 12; private static final int ROW_HEIGHT = 28; private static final int VISIBLE_ROWS = 7; private static final int LIST_TOP = 36; @@ -67,8 +70,8 @@ protected void init() { this.addRenderableWidget(Button.builder(Component.literal("Select Non-Chief"), b -> selectNonChief()).bounds(layout.x(148), layout.y(quickY), layout.w(124), layout.h(20)).build()); this.addRenderableWidget(Button.builder(Component.literal("Deselect All"), b -> selected.clear()).bounds(layout.x(281), layout.y(quickY), layout.w(124), layout.h(20)).build()); - payOutButton = this.addRenderableWidget(Button.builder(Component.literal("Pay Out"), b -> payOut()).bounds(layout.x(15), layout.y(266), layout.w(190), layout.h(20)).build()); - this.addRenderableWidget(Button.builder(Component.literal("Back"), b -> this.minecraft.setScreen(parent)).bounds(layout.x(215), layout.y(266), layout.w(190), layout.h(20)).build()); + payOutButton = this.addRenderableWidget(Button.builder(Component.literal("Pay Out"), b -> payOut()).bounds(layout.x(15), layout.y(288), layout.w(190), layout.h(20)).build()); + this.addRenderableWidget(Button.builder(Component.literal("Back"), b -> this.minecraft.setScreen(parent)).bounds(layout.x(215), layout.y(288), layout.w(190), layout.h(20)).build()); requestPending(); refreshSnapshot(); @@ -205,7 +208,7 @@ private void payOut() { } // The guild-manage menu needs the screen, and progress is reported in chat. this.minecraft.setScreen(null); - mod.guildRewards().payoutAspects(targets); + mod.guildRewards().payoutAspects(targets, mod.config().payoutAutoDeduct); } private void sendChat(String message) { @@ -243,9 +246,16 @@ public void render(GuiGraphics g, int mouseX, int mouseY, float delta) { layout.drawScrollbar(g, layout.x(393), listTop, layout.w(8), listHeight, VISIBLE_ROWS, rows.size(), scrollOffset); g.drawString(this.font, footerText(), layout.x(15), layout.y(228), 0xFFCCCCCC); + renderAutoDeductOption(g); popReferencePose(g); } + private void renderAutoDeductOption(GuiGraphics g) { + boolean checked = mod.config().payoutAutoDeduct; + drawCheckbox(g, layout.x(15), layout.y(OPTION_ROW_Y), layout.w(OPTION_BOX_SIZE), checked, true); + g.drawString(this.font, "Auto-update Pending Totals", layout.x(33), layout.y(OPTION_ROW_Y + 2), checked ? 0xFFFFFFFF : 0xFFAAAAAA); + } + private void renderRows(GuiGraphics g, double mouseX, double mouseY) { for (int visible = 0; visible < VISIBLE_ROWS; visible++) { int index = scrollOffset + visible; @@ -325,6 +335,11 @@ public boolean mouseClicked(MouseButtonEvent event, boolean bl) { double mouseY = scaled.y(); if (scaled.button() == 0) { + if (isOverAutoDeductOption(mouseX, mouseY)) { + mod.config().payoutAutoDeduct = !mod.config().payoutAutoDeduct; + mod.config().save(); + return true; + } if (isOverScrollbar(mouseX, mouseY)) { draggingScrollbar = true; updateScrollFromMouse(mouseY); @@ -380,6 +395,12 @@ private boolean isOverList(double mouseX, double mouseY) { return mouseX >= layout.x(15) && mouseX <= layout.x(401) && mouseY >= layout.y(LIST_TOP) && mouseY <= layout.y(LIST_BOTTOM); } + /** The whole label is clickable, not just the 12px box — it's a small target. */ + private boolean isOverAutoDeductOption(double mouseX, double mouseY) { + int labelEnd = layout.x(33) + this.font.width("Auto-update Pending Totals"); + return mouseX >= layout.x(15) && mouseX <= labelEnd && mouseY >= layout.y(OPTION_ROW_Y - 2) && mouseY <= layout.y(OPTION_ROW_Y + OPTION_BOX_SIZE + 2); + } + private boolean isOverScrollbar(double mouseX, double mouseY) { return mouseX >= layout.x(393) && mouseX <= layout.x(401) && mouseY >= layout.y(LIST_TOP) && mouseY <= layout.y(LIST_BOTTOM); } diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index 9de7e2a..66c16cd 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -117,10 +117,12 @@ public interface StorageReporter { * 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. + * the amount handed out doesn't convert to a whole number of them. + * + *

{@code autoDeduct} asks for the deduction to happen straight away — a payout + * with the screen's auto-update option on, where the Chief picked the amounts off + * the pending list itself. Otherwise it is only offered as a clickable command, + * which is what single gifts do, since a gift needn't be settling what is owed. */ public interface DeductReporter { void report(String receiver, String rewardKind, int displayUnits, boolean autoDeduct); @@ -363,10 +365,10 @@ private void run(String name, RewardType type, int requested, boolean dump) { * 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. + *

{@code autoDeduct} takes the handout off the member's pending total on the + * backend instead of only offering the deduction as a clickable command. */ - private boolean runSingle(String name, RewardType type, int requested, boolean dump, boolean batch) { + private boolean runSingle(String name, RewardType type, int requested, boolean dump, boolean autoDeduct) { if (!openRewardsMenu()) { chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); return false; @@ -418,7 +420,7 @@ private boolean runSingle(String name, RewardType type, int requested, boolean d if (type.resetKind != null) { DeductReporter currentDeductReporter = deductReporter; if (currentDeductReporter != null) { - currentDeductReporter.report(name, type.resetKind, displayUnits(type, amount), batch); + currentDeductReporter.report(name, type.resetKind, displayUnits(type, amount), autoDeduct); } else { chatComponent(manageResetFallbackLine(type.resetKind, name)); } @@ -468,15 +470,18 @@ private boolean openRewardsMenu() { * Pay out aspects to several members in one go (off-thread). The whole batch is * checked against the guild's available aspects first: if it doesn't fit, nothing * is distributed at all. + * + *

With {@code autoDeduct}, each member's payout is also deducted from their + * pending total on the backend; otherwise the deduction is only offered. */ - public void payoutAspects(List targets) { + public void payoutAspects(List targets, boolean autoDeduct) { List copy = List.copyOf(targets); if (!copy.isEmpty()) { - worker.submit(() -> batchRun(copy)); + worker.submit(() -> batchRun(copy, autoDeduct)); } } - private void batchRun(List requested) { + private void batchRun(List requested, boolean autoDeduct) { giftInProgress = true; try { if (!isChief()) { @@ -543,7 +548,7 @@ private void batchRun(List requested) { try { for (PayoutTarget target : targets) { done++; - if (runSingle(target.name(), RewardType.ASPECT, target.aspects(), false, true)) { + if (runSingle(target.name(), RewardType.ASPECT, target.aspects(), false, autoDeduct)) { paid++; } else { skipped.add(target.name()); From 91e9833f9e2de129fe5d88c6f981918ebfd39b78 Mon Sep 17 00:00:00 2001 From: downbtn Date: Fri, 31 Jul 2026 13:07:17 -0400 Subject: [PATCH 3/4] Don't ask to deduct rewards if using /eden dump --- src/tel/eden/mod/reward/GuildRewards.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index 66c16cd..e449072 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -417,7 +417,9 @@ private boolean runSingle(String name, RewardType type, int requested, boolean d if (currentStorageReporter != null) { currentStorageReporter.report((int) finalCounts[0], (int) finalCounts[1], finalCounts[2]); } - if (type.resetKind != null) { + // A dump empties the guild bank into one member and isn't settling what anyone + // is owed, so it never offers (or performs) a pending-balance deduction. + if (type.resetKind != null && !dump) { DeductReporter currentDeductReporter = deductReporter; if (currentDeductReporter != null) { currentDeductReporter.report(name, type.resetKind, displayUnits(type, amount), autoDeduct); From 9149ea1fff2bad181f45bdcc86763708f327d854 Mon Sep 17 00:00:00 2001 From: downbtn Date: Fri, 31 Jul 2026 14:13:51 -0400 Subject: [PATCH 4/4] Reward deduction requests handle errors better & log more sensibly --- src/tel/eden/mod/EdenModClient.java | 124 +++++++++++++----- .../eden/mod/net/BridgeWebSocketClient.java | 7 +- src/tel/eden/mod/reward/GuildRewards.java | 18 ++- 3 files changed, 114 insertions(+), 35 deletions(-) diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 9f719e9..9803d73 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -200,11 +200,11 @@ private record PendingWarReport(String territory, List members) { 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. + // Deduct replies carry no request id, but every one the backend can attribute echoes + // the target and kind, which is enough to claim the matching request by name. What + // this queue is for is the requests that get no such reply at all: a dropped socket, + // or a payload the backend couldn't parse (the one refusal with nothing to echo). + // Those are handed back to the Chief to settle manually, never retried. 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 @@ -600,18 +600,27 @@ public void onAspectsPending(java.util.List entries, String error, @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())); + displayColoredDirect(color, () -> DiscordChatFormatter.systemLine("Couldn't deduct pending rewards: " + error, ChatFormatting.RED)); + if (!target.isEmpty()) { + // The refusal echoes what was attempted, so the request it + // answers is known outright — no need to infer it from the queue. + claimPendingDeduct(target, rewardKind); + displayDirect(() -> GuildRewards.manageResetFallbackLine(rewardKind, target, amount)); + } else { + // The one refusal that can't echo anything: the backend couldn't + // parse the request, so it has no target to name and we can't + // tell which one it was. That means a bug in what this mod sends, + // so treat the whole batch as unsettled rather than guess. + handBackOutstandingDeducts("were not deducted"); } return; } - displayColored(color, () -> DiscordChatFormatter.systemLine("Deducted " + amount + " pending " + rewardKind + " from " + target + " — " + remaining + " remaining.", ChatFormatting.GREEN)); + // Claim by target rather than by position: a reply that goes missing + // then costs one stale entry instead of shifting every later reply onto + // the wrong player. + claimPendingDeduct(target, rewardKind); + displayColoredDirect(color, () -> DiscordChatFormatter.systemLine("Confirmed " + amount + " " + rewardKind + " deduction for " + target + " — " + remaining + " remaining.", ChatFormatting.GREEN)); } @Override @@ -957,26 +966,46 @@ private void onRewardHandedOut(String receiver, String rewardKind, int displayUn 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)); + displayDirect(() -> GuildRewards.manageResetFallbackLine(rewardKind, receiver, displayUnits)); return; } if (autoDeduct) { sendDeduct(rewardKind, receiver, displayUnits); } else { - display(() -> DiscordChatFormatter.deductOffer(rewardKind, receiver, displayUnits)); + displayDirect(() -> 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)); + PendingDeduct entry = new PendingDeduct(rewardKind, target, displayUnits); + // Queue before sending: the reply arrives on the websocket thread and would + // otherwise be able to overtake the bookkeeping for its own request. + pendingDeducts.add(entry); + // A live client whose socket is mid-reconnect drops the send silently, so an + // unsent request must not be left outstanding — it would be answered by some + // later request's reply. + if (current == null || !current.sendRewardDeductRequest(rewardKind, target, displayUnits)) { + pendingDeducts.remove(entry); + displayDirect(() -> DiscordChatFormatter.systemLine("Not connected to the bridge — deduct " + target + "'s " + displayUnits + " pending " + rewardKind + " by hand:", ChatFormatting.RED)); + displayDirect(() -> GuildRewards.manageResetFallbackLine(rewardKind, target, displayUnits)); return; } - pendingDeducts.add(new PendingDeduct(rewardKind, target, displayUnits)); - current.sendRewardDeductRequest(rewardKind, target, displayUnits); + // Announce the request, not just its answer: a reply can be slow, refused, or + // never come at all, and without this line those cases are indistinguishable + // from the deduction never having been attempted. + displayDirect(() -> DiscordChatFormatter.systemLine("Sent deduction of " + displayUnits + " " + rewardKind + " for " + target + "...", ChatFormatting.GOLD)); + } + + /** Remove the outstanding request a successful reply answers, if still queued. */ + private void claimPendingDeduct(String target, String rewardKind) { + for (PendingDeduct entry : pendingDeducts) { + if (entry.target().equalsIgnoreCase(target) && entry.rewardKind().equals(rewardKind)) { + pendingDeducts.remove(entry); + return; + } + } } /** Gate the reward commands to Wynncraft and ensure the member list is loaded. */ @@ -1738,16 +1767,23 @@ private boolean checkWynncraftTabActive(Minecraft mc) { return false; } - /** 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. + /** + * Hand every deduct still awaiting a reply back to the Chief, naming each one and + * what became of it ({@code reason} completes "'s N aspects ..."). Retrying + * isn't safe — the backend may well have applied a request whose reply went missing + * — so an unanswered deduct is always settled by hand. + */ + private void handBackOutstandingDeducts(String reason) { 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())); + displayDirect(() -> DiscordChatFormatter.systemLine(entry.target() + "'s " + entry.displayUnits() + " " + entry.rewardKind() + " " + reason + " — check and reset if needed:", ChatFormatting.RED)); + displayDirect(() -> GuildRewards.manageResetFallbackLine(entry.rewardKind(), entry.target(), entry.displayUnits())); } + } + + /** On a fresh bridge connection, announce this session's login exactly once. */ + private void onBridgeConnected() { + handBackOutstandingDeducts("were not confirmed deducted before the bridge dropped"); if (loginPending) { loginPending = false; BridgeWebSocketClient current = socket; @@ -1775,6 +1811,10 @@ private synchronized void disconnect() { // connection (stale timers/defence/heads). ScoreboardCapture.reset(); AttackTimerMenu.reset(); + // Before the socket goes: an outstanding deduct must not outlive the session that + // made it, or it resurfaces at the next connect naming a player from another + // server — or, after an account switch, another guild. + handBackOutstandingDeducts("were not confirmed deducted before the bridge dropped"); if (socket != null) { socket.close(); socket = null; @@ -1989,11 +2029,26 @@ private void renderAndSendItemCard(BridgeWebSocketClient current, CapturedMessag * splitter, which must run on the client thread (not the WebSocket thread). */ private void display(java.util.function.Supplier builder) { + display(builder, true); + } + + /** + * Show a line that must not be routed through the Wynntils tab bridge. That bridge + * reports success as soon as tabs are enabled, but whether the line is ever rendered + * is up to the tab filters — one that matches nothing is dropped silently. Fine for + * ordinary bridge chatter; not for reward bookkeeping, where a swallowed line is + * indistinguishable from the deduction never having been attempted. + */ + private void displayDirect(java.util.function.Supplier builder) { + display(builder, false); + } + + private void display(java.util.function.Supplier builder, boolean viaChatTab) { Minecraft client = Minecraft.getInstance(); client.execute(() -> { if (client.player != null) { Component component = builder.get(); - if (!WynntilsChatBridge.sendToTab(component)) { + if (!viaChatTab || !WynntilsChatBridge.sendToTab(component)) { client.player.displayClientMessage(component, false); } } @@ -2007,13 +2062,22 @@ private void display(java.util.function.Supplier builder) { * backend retune any message's colour without a mod update. */ private void displayColored(String colorHex, java.util.function.Supplier builder) { + displayColored(colorHex, builder, true); + } + + /** {@link #displayColored} for a line that must bypass the Wynntils tab bridge. */ + private void displayColoredDirect(String colorHex, java.util.function.Supplier builder) { + displayColored(colorHex, builder, false); + } + + private void displayColored(String colorHex, java.util.function.Supplier builder, boolean viaChatTab) { Integer rgb = parseHexColor(colorHex); if (rgb == null) { - display(builder); + display(builder, viaChatTab); return; } int color = rgb; - display(() -> recolor(builder.get(), color)); + display(() -> recolor(builder.get(), color), viaChatTab); } /** Parse a {@code "RRGGBB"} hex colour, or {@code null} if empty/malformed. */ diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index ffecacd..0fc6673 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -236,11 +236,13 @@ public void sendAspectsPendingRequest() { * 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. + * Returns false when the socket is down (mid-reconnect included), so the caller can + * offer the manual route instead of waiting for a reply that will never come. */ - public void sendRewardDeductRequest(String rewardKind, String target, int amount) { + public boolean sendRewardDeductRequest(String rewardKind, String target, int amount) { WebSocket current = socket; if (current == null) { - return; + return false; } JsonObject obj = new JsonObject(); obj.addProperty("type", "rewardDeductRequest"); @@ -248,6 +250,7 @@ public void sendRewardDeductRequest(String rewardKind, String target, int amount obj.addProperty("target", target); obj.addProperty("amount", amount); current.sendText(obj.toString(), true); + return true; } /** Open a new party in-game for the given label (raid name or Annihilation). */ diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index e449072..b6c5be6 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -397,6 +397,12 @@ private boolean runSingle(String name, RewardType type, int requested, boolean d } int total = type == RewardType.EMERALD ? amount * EMERALDS_PER_ITEM : amount; chat("Gifting " + name + " " + total + " " + type.label + "...", ChatFormatting.GREEN); + if (!dump && amount < requested) { + // The guild ran short: what is about to be handed out no longer matches what + // the pending list said was owed, so neither the deduction below nor a + // /manage reset settles this member correctly. + chat("Only " + total + " of " + requested + " " + type.label + " were available for " + name + " — their pending total needs settling by hand.", ChatFormatting.YELLOW); + } for (int i = 0; i < amount; i++) { final int target = slot; onClientRun(() -> swapHotbar(target, type.hotbar)); @@ -424,7 +430,7 @@ private boolean runSingle(String name, RewardType type, int requested, boolean d if (currentDeductReporter != null) { currentDeductReporter.report(name, type.resetKind, displayUnits(type, amount), autoDeduct); } else { - chatComponent(manageResetFallbackLine(type.resetKind, name)); + chatComponent(manageResetFallbackLine(type.resetKind, name, displayUnits(type, amount))); } } else { chat("Done — gifted " + name + " " + total + " " + type.label + ".", ChatFormatting.GREEN); @@ -448,10 +454,16 @@ public static int displayUnits(RewardType type, int menuAmount) { /** * 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. + * + *

{@code paidUnits} is what the in-game handout actually came to, or -1 when the + * caller doesn't know. Reset zeroes the whole balance, so the two only agree when + * the payout covered all of it — the hover says so rather than leaving a Chief to + * discover it after wiping the remainder of a partially-paid member's total. */ - public static Component manageResetFallbackLine(String resetKind, String player) { + public static Component manageResetFallbackLine(String resetKind, String player, int paidUnits) { 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")))); + String hover = paidUnits > 0 ? "Click to copy — this zeroes " + player + "'s whole pending balance, not just the " + paidUnits + " paid" : "Click to copy this command"; + return Component.literal(command).withStyle(Style.EMPTY.withColor(ChatFormatting.GREEN).withUnderlined(true).withClickEvent(new ClickEvent.CopyToClipboard(command)).withHoverEvent(new HoverEvent.ShowText(Component.literal(hover)))); } /** Open {@code /gu man} and step into member management. True if the menu came up. */