diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index adb6838..f3fcb6b 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -159,6 +159,17 @@ public BridgeWebSocketClient socket() { // Mutated on the inbound-message path and read from GUI screens on the render // thread; copy-on-write keeps those cross-thread reads from racing the writes. private final java.util.List knownParties = new java.util.concurrent.CopyOnWriteArrayList<>(); + // Latest aspects-owed list from the backend, read by AspectsPayoutScreen on the + // render thread. Each reply replaces the whole list, so publishing an immutable + // one through a volatile field keeps readers from ever seeing a half-applied + // update. The generation counter lets the screen tell "no reply yet" apart from + // "replied with an empty list". + private volatile java.util.List knownPendingAspects = java.util.List.of(); + private volatile String pendingAspectsError; + // Atomic rather than a volatile int: a lost increment between two concurrent + // replies would leave a reader's cached generation matching the live one, and + // it would sit on a stale list believing it was current. + private final java.util.concurrent.atomic.AtomicInteger pendingAspectsGeneration = new java.util.concurrent.atomic.AtomicInteger(); // GitHub update check: run once per game session; the prompt offers a one-click // download (applied on game close) and a link to the release page. private final UpdateChecker updateChecker = new UpdateChecker(); @@ -255,6 +266,32 @@ public java.util.List knownParties() { return java.util.List.copyOf(knownParties); } + /** Members owed aspects, highest first, as of the last backend reply. */ + public java.util.List knownPendingAspects() { + return knownPendingAspects; + } + + /** The error from the last aspects-pending reply, or {@code null} if it succeeded. */ + public String pendingAspectsError() { + return pendingAspectsError; + } + + /** + * Bumped on every aspects-pending reply; 0 means none has arrived yet. + * + *

Readers must sample this before reading the list, mirroring the + * write order below: sampling it afterwards can pair an old list with a new + * generation, and the reader then never notices it missed a reply. + */ + public int pendingAspectsGeneration() { + return pendingAspectsGeneration.get(); + } + + /** The guild reward helper (rank lookups + gifting). */ + public GuildRewards guildRewards() { + return guildRewards; + } + @Override public void onInitializeClient() { instance = this; @@ -527,7 +564,15 @@ public void onOnlineList(java.util.List users, String color) { @Override public void onAspectsPending(java.util.List entries, String error, String color) { - displayColored(color, () -> DiscordChatFormatter.aspectsPending(entries, error)); + // Rendered by AspectsPayoutScreen rather than chat; sort here so every + // reader sees the biggest debts first. + java.util.List sorted = new ArrayList<>(entries); + sorted.sort(java.util.Comparator.comparingInt(PendingEntry::aspects).reversed()); + // Generation last: it is the reader's signal that the other two + // fields are the ones belonging to this reply. + knownPendingAspects = java.util.List.copyOf(sorted); + pendingAspectsError = (error == null || error.isEmpty()) ? null : error; + pendingAspectsGeneration.incrementAndGet(); } @Override @@ -783,12 +828,11 @@ private void requestDiceroll(FabricClientCommandSource source) { } private void requestAspectsPending(FabricClientCommandSource source) { - BridgeWebSocketClient current = socket; - if (current == null) { - source.sendFeedback(notConnected()); - return; - } - current.sendAspectsPendingRequest(); + // Warm the rank cache so the payout table can show ranks straight away; the + // screen itself issues the backend request from its init(). + guildRewards.ensureFresh(playerName()); + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> mc.setScreen(new tel.eden.mod.gui.AspectsPayoutScreen(mc.screen, this))); } /** Build {@code /eden gift } (Chiefs only). */ diff --git a/src/tel/eden/mod/chat/DiscordChatFormatter.java b/src/tel/eden/mod/chat/DiscordChatFormatter.java index 5a34a9d..f356fe0 100644 --- a/src/tel/eden/mod/chat/DiscordChatFormatter.java +++ b/src/tel/eden/mod/chat/DiscordChatFormatter.java @@ -1,6 +1,5 @@ package tel.eden.mod.chat; -import tel.eden.mod.net.PendingEntry; import java.net.URI; import java.util.List; import java.util.Optional; @@ -144,23 +143,6 @@ public static Component warCounts(int days, java.util.List entries, String error) { - if (error != null && !error.isEmpty()) { - return Component.empty().append(prefix(SHIELD)).append(Component.literal(error).withStyle(ChatFormatting.RED)); - } - if (entries.isEmpty()) { - return Component.empty().append(prefix(SHIELD)).append(Component.literal("No members have pending aspects.").withStyle(ChatFormatting.GREEN)); - } - MutableComponent out = Component.empty().append(prefix(SHIELD)).append(Component.literal("Pending aspects (" + entries.size() + "):").withStyle(ChatFormatting.GREEN)); - for (PendingEntry entry : entries) { - String command = "/eden gift " + entry.name() + " aspect " + entry.aspects(); - Style click = Style.EMPTY.withColor(ChatFormatting.YELLOW).withUnderlined(true).withClickEvent(new ClickEvent.SuggestCommand(command)).withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to fill: " + command))); - out.append("\n").append(Component.literal(" " + entry.name() + " ").withStyle(ChatFormatting.AQUA)).append(Component.literal("[" + entry.aspects() + " aspects]").setStyle(click)); - } - return out; - } - // Inline reply excerpt is truncated to this many characters (full text on hover). private static final int EXCERPT_MAX = 50; diff --git a/src/tel/eden/mod/gui/AspectsPayoutScreen.java b/src/tel/eden/mod/gui/AspectsPayoutScreen.java new file mode 100644 index 0000000..141b817 --- /dev/null +++ b/src/tel/eden/mod/gui/AspectsPayoutScreen.java @@ -0,0 +1,417 @@ +package tel.eden.mod.gui; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.net.PendingEntry; +import tel.eden.mod.reward.GuildRewards; + +/** + * Table of guild members owed aspects, with per-row selection and a batch pay-out. + * + *

Rows are supplied by the backend (already sorted by amount owed, descending) and + * annotated locally with each member's guild rank. Members who haven't been in the + * guild a week can't receive rewards, so their rows are greyed out and unselectable. + * + *

Columns: selection checkbox, player name, guild rank, aspects owed. + */ +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 ROW_HEIGHT = 28; + private static final int VISIBLE_ROWS = 7; + private static final int LIST_TOP = 36; + private static final int LIST_BOTTOM = LIST_TOP + (ROW_HEIGHT * VISIBLE_ROWS); + private static final long WEEK_MS = 604_800_000L; + + private final Screen parent; + private final EdenModClient mod; + + private final List rows = new ArrayList<>(); + private final Set selected = new LinkedHashSet<>(); + + private EdenPanelLayout layout; + private Button payOutButton; + private int scrollOffset; + private boolean draggingScrollbar; + // Generation of the reply currently on screen; -1 means we haven't taken a + // snapshot yet, which is how "still loading" is told from "replied, but empty". + private int seenGeneration = -1; + // The "everything ticked" default applies to the first list that actually has rows, + // not to the empty placeholder we snapshot before the reply lands. + private boolean defaultsApplied; + + public AspectsPayoutScreen(Screen parent, EdenModClient mod) { + super(Component.literal("Aspect Payouts")); + this.parent = parent; + this.mod = mod; + } + + @Override + protected void init() { + super.init(); + updateReferenceSpace(); + layout = EdenPanelLayout.centered(virtualWidth, virtualHeight, BASE_PANEL_WIDTH, BASE_PANEL_HEIGHT); + + int quickY = 240; + this.addRenderableWidget(Button.builder(Component.literal("Select All"), b -> selectAll()).bounds(layout.x(15), layout.y(quickY), layout.w(124), layout.h(20)).build()); + 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()); + + requestPending(); + refreshSnapshot(); + } + + private void requestPending() { + if (mod.socket() != null) { + mod.socket().sendAspectsPendingRequest(); + } + mod.guildRewards().ensureFresh(EdenModClient.instance().playerName()); + } + + /** Adopt the latest backend reply, keeping selections for names that survived it. */ + private void refreshSnapshot() { + // Generation first, list second — the opposite of the write order. Sampling + // the generation after the list could pair the previous reply's rows with the + // new reply's generation, and tick() would then see nothing left to pick up. + // This way the worst case is snapshotting the same reply twice. + seenGeneration = mod.pendingAspectsGeneration(); + List latest = mod.knownPendingAspects(); + + Set stillListed = new LinkedHashSet<>(); + for (PendingEntry entry : latest) { + stillListed.add(entry.name()); + } + if (!defaultsApplied && !latest.isEmpty()) { + // Paying everyone is the common case, so start with everything eligible ticked. + selected.clear(); + for (PendingEntry entry : latest) { + if (isEligible(entry.name())) { + selected.add(entry.name()); + } + } + defaultsApplied = true; + } else { + selected.retainAll(stillListed); + } + + rows.clear(); + rows.addAll(latest); + clampScroll(); + } + + private void clampScroll() { + scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, rows.size() - VISIBLE_ROWS))); + } + + @Override + public void tick() { + super.tick(); + if (seenGeneration != mod.pendingAspectsGeneration()) { + refreshSnapshot(); + } + if (payOutButton != null) { + payOutButton.active = !selected.isEmpty() && mod.guildRewards().isChief() && !mod.guildRewards().isGiftInProgress(); + } + } + + /** + * Whether a row can be selected. The guild roster loads asynchronously, so an + * unknown member is treated as selectable rather than locking every row until the + * API answers; only a member we positively know joined too recently is blocked. + * A genuinely non-member selection is caught by the batch's own validation. + */ + private boolean isEligible(String name) { + return !isTooNew(name); + } + + private boolean isTooNew(String name) { + Long joined = mod.guildRewards().memberJoined(name); + return joined != null && System.currentTimeMillis() - joined < WEEK_MS; + } + + private String rankOf(String name) { + String rank = mod.guildRewards().memberRank(name); + return rank == null ? "—" : rank; + } + + private boolean isChiefRank(String name) { + String rank = mod.guildRewards().memberRank(name); + if (rank == null) { + return false; + } + String lower = rank.toLowerCase(Locale.ROOT); + return lower.equals("chief") || lower.equals("owner"); + } + + private int selectedAspects() { + int total = 0; + for (PendingEntry entry : rows) { + if (selected.contains(entry.name())) { + total += entry.aspects(); + } + } + return total; + } + + // -- quick actions ---------------------------------------------------------- + + private void selectAll() { + selected.clear(); + for (PendingEntry entry : rows) { + if (isEligible(entry.name())) { + selected.add(entry.name()); + } + } + } + + private void selectNonChief() { + selected.clear(); + for (PendingEntry entry : rows) { + if (isEligible(entry.name()) && !isChiefRank(entry.name())) { + selected.add(entry.name()); + } + } + } + + private void payOut() { + if (selected.isEmpty()) { + return; + } + if (!mod.guildRewards().isChief()) { + sendChat("Only guild Chiefs can pay out rewards."); + return; + } + List targets = new ArrayList<>(); + for (PendingEntry entry : rows) { + if (selected.contains(entry.name()) && entry.aspects() > 0) { + targets.add(new GuildRewards.PayoutTarget(entry.name(), entry.aspects())); + } + } + if (targets.isEmpty()) { + return; + } + // The guild-manage menu needs the screen, and progress is reported in chat. + this.minecraft.setScreen(null); + mod.guildRewards().payoutAspects(targets); + } + + private void sendChat(String message) { + if (this.minecraft.player != null) { + this.minecraft.player.displayClientMessage(Component.literal(message).withStyle(ChatFormatting.RED), false); + } + } + + // -- rendering -------------------------------------------------------------- + + @Override + public void render(GuiGraphics g, int mouseX, int mouseY, float delta) { + int scaledMouseX = scaledMouseX(mouseX); + int scaledMouseY = scaledMouseY(mouseY); + + this.renderMenuBackground(g); + pushReferencePose(g); + layout.drawBackground(g); + layout.drawPanel(g); + super.render(g, scaledMouseX, scaledMouseY, delta); + + g.drawCenteredString(this.font, "Pending Aspects", layout.centerX(), layout.y(12), 0xFFFFFFFF); + + int listLeft = layout.x(15); + int listTop = layout.y(LIST_TOP); + int listWidth = layout.w(390); + int listHeight = layout.h(ROW_HEIGHT * VISIBLE_ROWS); + g.fill(listLeft, listTop, listLeft + listWidth, listTop + listHeight, 0x22000000); + + if (rows.isEmpty()) { + g.drawCenteredString(this.font, emptyMessage(), layout.centerX(), layout.y(122), emptyColor()); + } else { + renderRows(g, scaledMouseX, scaledMouseY); + } + + 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); + popReferencePose(g); + } + + private void renderRows(GuiGraphics g, double mouseX, double mouseY) { + for (int visible = 0; visible < VISIBLE_ROWS; visible++) { + int index = scrollOffset + visible; + if (index >= rows.size()) { + break; + } + PendingEntry entry = rows.get(index); + // One roster lookup each per row per frame; both were previously repeated + // two or three times on the way through this loop. + boolean tooNew = isTooNew(entry.name()); + String rank = rankOf(entry.name()); + boolean eligible = !tooNew; + boolean checked = selected.contains(entry.name()); + + int rowTop = layout.y(38 + visible * ROW_HEIGHT); + int rowBottom = rowTop + layout.h(24); + boolean hovered = eligible && mouseX >= layout.x(17) && mouseX <= layout.x(391) && mouseY >= rowTop && mouseY <= rowBottom; + g.fill(layout.x(17), rowTop, layout.x(391), rowBottom, hovered ? 0x66383838 : 0x44282828); + + drawCheckbox(g, layout.x(23), rowTop + layout.h(6), layout.w(12), checked, eligible); + + int textY = rowTop + layout.h(8); + int nameColor = eligible ? 0xFFFFFFFF : 0xFF777777; + int rankColor = eligible ? 0xFFAAAAAA : 0xFF666666; + g.drawString(this.font, trimToWidth(entry.name(), layout.w(140)), layout.x(45), textY, nameColor); + + String rankLabel = tooNew ? rank + " (<1 week)" : rank; + g.drawString(this.font, trimToWidth(rankLabel, layout.w(120)), layout.x(196), textY, rankColor); + + String owed = entry.aspects() + " aspects"; + g.drawString(this.font, owed, layout.x(385) - this.font.width(owed), textY, eligible ? 0xFFFFD24A : 0xFF7A6A32); + } + } + + private void drawCheckbox(GuiGraphics g, int x, int y, int size, boolean checked, boolean enabled) { + int border = enabled ? 0xFFD8D8D8 : 0xFF666666; + g.fill(x, y, x + size, y + size, 0xFF1E1E1E); + g.fill(x, y, x + size, y + 1, border); + g.fill(x, y + size - 1, x + size, y + size, border); + g.fill(x, y, x + 1, y + size, border); + g.fill(x + size - 1, y, x + size, y + size, border); + if (checked) { + g.fill(x + 3, y + 3, x + size - 3, y + size - 3, enabled ? 0xFF55DD55 : 0xFF3A6A3A); + } + } + + private String emptyMessage() { + if (mod.pendingAspectsError() != null) { + return mod.pendingAspectsError(); + } + if (mod.socket() == null) { + return "Not connected to the bridge"; + } + if (mod.pendingAspectsGeneration() == 0) { + return "Loading pending aspects..."; + } + return "No members have pending aspects."; + } + + private int emptyColor() { + return mod.pendingAspectsError() != null || mod.socket() == null ? 0xFFFF5555 : 0xFFAAAAAA; + } + + private String footerText() { + if (mod.guildRewards().isGiftInProgress()) { + return "Payout in progress — see chat"; + } + return "Selected: " + selected.size() + " players — " + selectedAspects() + " aspects"; + } + + // -- input ------------------------------------------------------------------ + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean bl) { + MouseButtonEvent scaled = rescale(event); + double mouseX = scaled.x(); + double mouseY = scaled.y(); + + if (scaled.button() == 0) { + if (isOverScrollbar(mouseX, mouseY)) { + draggingScrollbar = true; + updateScrollFromMouse(mouseY); + return true; + } + int row = rowAt(mouseX, mouseY); + if (row >= 0) { + String name = rows.get(row).name(); + if (isEligible(name)) { + if (!selected.remove(name)) { + selected.add(name); + } + } + return true; + } + } + + return super.mouseClicked(scaled, bl); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + draggingScrollbar = false; + return super.mouseReleased(rescale(event)); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double d, double e) { + MouseButtonEvent scaled = rescale(event); + if (draggingScrollbar) { + updateScrollFromMouse(scaled.y()); + return true; + } + return super.mouseDragged(scaled, d / uiScale, e / uiScale); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double d, double e) { + double scaledMouseX = mouseX / uiScale; + double scaledMouseY = mouseY / uiScale; + if (!isOverList(scaledMouseX, scaledMouseY) && !isOverScrollbar(scaledMouseX, scaledMouseY)) { + return super.mouseScrolled(scaledMouseX, scaledMouseY, d, e); + } + int maxOffset = Math.max(0, rows.size() - VISIBLE_ROWS); + if (maxOffset == 0) { + return true; + } + scrollOffset = Math.max(0, Math.min(maxOffset, scrollOffset - (int) Math.signum(e))); + return true; + } + + 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); + } + + 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); + } + + private int rowAt(double mouseX, double mouseY) { + if (!isOverList(mouseX, mouseY)) { + return -1; + } + int row = ((int) mouseY - layout.y(LIST_TOP)) / layout.h(ROW_HEIGHT); + int index = scrollOffset + row; + return index >= 0 && index < rows.size() ? index : -1; + } + + private void updateScrollFromMouse(double mouseY) { + int maxOffset = Math.max(0, rows.size() - VISIBLE_ROWS); + if (maxOffset == 0) { + scrollOffset = 0; + return; + } + int trackTop = layout.y(LIST_TOP); + int trackHeight = layout.h(ROW_HEIGHT * VISIBLE_ROWS); + int thumbHeight = Math.max(layout.h(18), Math.round(trackHeight * (VISIBLE_ROWS / (float) rows.size()))); + double relative = mouseY - trackTop - (thumbHeight / 2.0); + double range = Math.max(1, trackHeight - thumbHeight); + double percent = Math.max(0.0, Math.min(1.0, relative / range)); + scrollOffset = (int) Math.round(percent * maxOffset); + } + + private String trimToWidth(String text, int width) { + if (this.font.width(text) <= width) { + return text; + } + return this.font.plainSubstrByWidth(text, Math.max(0, width - this.font.width("..."))) + "..."; + } +} diff --git a/src/tel/eden/mod/gui/EdenMenuScreen.java b/src/tel/eden/mod/gui/EdenMenuScreen.java index 38795ad..ff7a668 100644 --- a/src/tel/eden/mod/gui/EdenMenuScreen.java +++ b/src/tel/eden/mod/gui/EdenMenuScreen.java @@ -12,7 +12,7 @@ public class EdenMenuScreen extends Screen { private static final int BASE_CONTENT_WIDTH = 200; private static final int BUTTON_HEIGHT = 20; private static final int BUTTON_SPACING = 24; - private static final int BUTTON_COUNT = 6; + private static final int BUTTON_COUNT = 7; private static final int LOGO_WIDTH = 128; private static final int LOGO_GAP = 20; private static final int CONTENT_MARGIN = 12; @@ -68,13 +68,17 @@ protected void init() { this.minecraft.setScreen(new PartyListScreen(this, EdenModClient.instance())); }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 3), metrics.buttonWidth, metrics.buttonHeight).build()); + this.addRenderableWidget(Button.builder(Component.literal("Aspect Payouts"), button -> { + this.minecraft.setScreen(new AspectsPayoutScreen(this, EdenModClient.instance())); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 4), metrics.buttonWidth, metrics.buttonHeight).build()); + this.addRenderableWidget(Button.builder(Component.literal("Command Aliases"), button -> { this.minecraft.setScreen(new CommandAliasScreen(this, EdenModClient.instance())); - }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 4), metrics.buttonWidth, metrics.buttonHeight).build()); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 5), metrics.buttonWidth, metrics.buttonHeight).build()); this.addRenderableWidget(Button.builder(Component.literal("Command Keybinds"), button -> { this.minecraft.setScreen(new CommandKeybindScreen(this, EdenModClient.instance())); - }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 5), metrics.buttonWidth, metrics.buttonHeight).build()); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 6), metrics.buttonWidth, metrics.buttonHeight).build()); } @Override diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index 7d08f22..f75ba5b 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -11,11 +11,11 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -89,6 +89,14 @@ public String unitReward() { } } + /** A guild member's join time and role bucket ("chief", "recruit", ...) from the API. */ + public record MemberInfo(long joinedEpochMillis, String rank) { + } + + /** One member's share of a batch payout. */ + public record PayoutTarget(String name, int aspects) { + } + /** Notified once per completed gift run with the exact number of handouts. */ public interface RewardReporter { void report(String receiver, RewardType type, int count); @@ -161,7 +169,7 @@ public long[] readAllCounts() { }); private volatile String rank = ""; - private volatile Map members = Map.of(); + private volatile Map members = emptyMembers(); private volatile long lastRefresh = 0L; private volatile boolean refreshing; @@ -188,6 +196,40 @@ public boolean isMember(String name) { return false; } + /** + * A roster map that matches names case-insensitively while keeping each member's + * original spelling as the key. Making that a property of the map itself means no + * lookup site has to remember to normalise (and can't trip over locale-dependent + * {@code toLowerCase} in the process). + */ + private static Map emptyMembers() { + return new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + + /** Look up a member's cached info by name (case-insensitive), or {@code null}. */ + private MemberInfo memberInfo(String name) { + if (name == null || name.isBlank()) { + return null; + } + return members.get(name); + } + + /** Display rank ("Chief", "Recruit", ...) for a member, or {@code null} if unknown. */ + public String memberRank(String name) { + MemberInfo info = memberInfo(name); + if (info == null || info.rank() == null || info.rank().isBlank()) { + return null; + } + String rankName = info.rank(); + return Character.toUpperCase(rankName.charAt(0)) + rankName.substring(1).toLowerCase(); + } + + /** When a member joined the guild, or {@code null} if unknown. */ + public Long memberJoined(String name) { + MemberInfo info = memberInfo(name); + return info == null ? null : info.joinedEpochMillis(); + } + /** Refresh the rank + member list from the API if stale, off-thread (non-blocking). */ public void ensureFresh(String playerName) { long now = System.currentTimeMillis(); @@ -213,7 +255,7 @@ private void refresh(String playerName) throws Exception { JsonObject player = getJson("https://api.wynncraft.com/v3/player/" + URLEncoder.encode(playerName, StandardCharsets.UTF_8)); if (player == null || !player.has("guild") || player.get("guild").isJsonNull()) { rank = ""; - members = Map.of(); + members = emptyMembers(); return; } JsonObject guild = player.getAsJsonObject("guild"); @@ -224,8 +266,8 @@ private void refresh(String playerName) throws Exception { lastRefresh = System.currentTimeMillis(); } - private static Map parseMembers(JsonObject guild) { - Map out = new HashMap<>(); + private static Map parseMembers(JsonObject guild) { + Map out = emptyMembers(); if (guild == null || !guild.has("members") || !guild.get("members").isJsonObject()) { return out; } @@ -239,7 +281,8 @@ private static Map parseMembers(JsonObject guild) { } JsonObject data = member.getValue().getAsJsonObject(); long joined = data.has("joined") ? Instant.parse(data.get("joined").getAsString()).toEpochMilli() : 0L; - out.put(member.getKey(), joined); + // The bucket key is the member's rank (owner/chief/strategist/...). + out.put(member.getKey(), new MemberInfo(joined, role.getKey())); } } return out; @@ -270,83 +313,202 @@ private void run(String name, RewardType type, int requested, boolean dump) { chat("Only guild Chiefs can gift rewards.", ChatFormatting.RED); return; } - Long joined = members.get(name); - if (joined == null) { + MemberInfo info = memberInfo(name); + if (info == null) { chat("Unknown member: " + name, ChatFormatting.RED); return; } - if (System.currentTimeMillis() - joined < WEEK_MS) { + if (System.currentTimeMillis() - info.joinedEpochMillis() < WEEK_MS) { chat(name + " has not been in the guild for a week, and is not eligible " + "for rewards.", ChatFormatting.YELLOW); return; } - Minecraft mc = Minecraft.getInstance(); - onClientRun(() -> { - if (mc.getConnection() != null) { - mc.getConnection().sendCommand("gu man"); - } - }); - sleep(MENU_DELAY_MS); - onClientRun(() -> click(OPEN_MEMBERS_SLOT)); - sleep(MENU_DELAY_MS); - if (!Boolean.TRUE.equals(onClient(this::containerOpen))) { - chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); + runSingle(name, type, requested, dump); + } catch (Exception e) { + LOGGER.warn("Gift run failed", e); + chat("Gift failed: " + e.getMessage(), ChatFormatting.RED); + } finally { + giftInProgress = false; + } + } + + /** + * Drive one member's gift run through the guild-manage menu. Assumes the caller has + * already validated chief/membership/eligibility and owns the {@code giftInProgress} + * 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. + */ + private boolean runSingle(String name, RewardType type, int requested, boolean dump) { + if (!openRewardsMenu()) { + chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); + return false; + } + // Read all three counts up front (index == RewardType.hotbar): the gifted + // type gives us `available`, and the trio becomes the post-gift snapshot. + long[] counts = onClient(this::readAllCounts); + if (counts == null) { + counts = new long[]{0, 0, 0}; + } + int available = (int) counts[type.hotbar]; + int availableItems = type == RewardType.EMERALD ? available / EMERALDS_PER_ITEM : available; + // Never attempt to gift more than the guild actually has; this both avoids + // wasted clicks and keeps the reported handout count exact. + int amount = dump ? availableItems : Math.min(requested, availableItems); + if (amount <= 0) { + chat("There aren't any " + type.label + " to gift!", ChatFormatting.YELLOW); + onClientRun(this::closeMenu); + return false; + } + int slot = findMemberSlot(name); + if (slot < 0) { + chat("Couldn't find " + name + "'s item in the menu.", ChatFormatting.RED); + onClientRun(this::closeMenu); + return false; + } + int total = type == RewardType.EMERALD ? amount * EMERALDS_PER_ITEM : amount; + chat("Gifting " + name + " " + total + " " + type.label + "...", ChatFormatting.GREEN); + for (int i = 0; i < amount; i++) { + final int target = slot; + onClientRun(() -> swapHotbar(target, type.hotbar)); + sleep(GIFT_DELAY_MS); + } + onClientRun(this::closeMenu); + // Report the exact handout count so the backend logs the right total even + // if the server bunched some identical reward announcements together. + RewardReporter currentReporter = reporter; + if (currentReporter != null) { + currentReporter.report(name, type, amount); + } + // Relay the exact storage left after this run (authoritative final value): + // decrement the gifted type by what we just handed out. + long[] finalCounts = counts.clone(); + finalCounts[type.hotbar] -= type == RewardType.EMERALD ? (long) amount * EMERALDS_PER_ITEM : amount; + StorageReporter currentStorageReporter = storageReporter; + if (currentStorageReporter != null) { + 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"))))); + } else { + chat("Done — gifted " + name + " " + total + " " + type.label + ".", ChatFormatting.GREEN); + } + return true; + } + + /** Open {@code /gu man} and step into member management. True if the menu came up. */ + private boolean openRewardsMenu() { + Minecraft mc = Minecraft.getInstance(); + onClientRun(() -> { + if (mc.getConnection() != null) { + mc.getConnection().sendCommand("gu man"); + } + }); + sleep(MENU_DELAY_MS); + onClientRun(() -> click(OPEN_MEMBERS_SLOT)); + sleep(MENU_DELAY_MS); + return Boolean.TRUE.equals(onClient(this::containerOpen)); + } + + /** + * 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. + */ + public void payoutAspects(List targets) { + List copy = List.copyOf(targets); + if (!copy.isEmpty()) { + worker.submit(() -> batchRun(copy)); + } + } + + private void batchRun(List requested) { + giftInProgress = true; + try { + if (!isChief()) { + chat("Only guild Chiefs can pay out rewards.", ChatFormatting.RED); return; } - // Read all three counts up front (index == RewardType.hotbar): the gifted - // type gives us `available`, and the trio becomes the post-gift snapshot. - long[] counts = onClient(this::readAllCounts); - if (counts == null) { - counts = new long[]{0, 0, 0}; + if (members.isEmpty()) { + chat("The guild roster hasn't loaded yet — try again in a moment.", ChatFormatting.RED); + return; + } + // Validate every target up front, before any aspects move. A member the + // roster doesn't know is dropped from the batch rather than aborting it: + // the roster refreshes on its own schedule, so an unknown name usually + // means a stale snapshot, and one such name shouldn't block everyone else. + // A positively-too-new member is a different matter — the screen greys + // those rows out, so one reaching us means the selection raced a refresh, + // and paying the rest of a batch the user picked under stale information + // isn't obviously what they wanted. + List tooNew = new ArrayList<>(); + List unknown = new ArrayList<>(); + List targets = new ArrayList<>(); + int total = 0; + for (PayoutTarget target : requested) { + MemberInfo info = memberInfo(target.name()); + if (info == null) { + unknown.add(target.name()); + continue; + } + if (System.currentTimeMillis() - info.joinedEpochMillis() < WEEK_MS) { + tooNew.add(target.name()); + continue; + } + targets.add(target); + total += Math.max(0, target.aspects()); } - int available = (int) counts[type.hotbar]; - int availableItems = type == RewardType.EMERALD ? available / EMERALDS_PER_ITEM : available; - // Never attempt to gift more than the guild actually has; this both avoids - // wasted clicks and keeps the reported handout count exact. - int amount = dump ? availableItems : Math.min(requested, availableItems); - if (amount <= 0) { - chat("There aren't any " + type.label + " to gift!", ChatFormatting.YELLOW); - onClientRun(this::closeMenu); + if (!tooNew.isEmpty()) { + chat("Nothing was distributed — these members joined less than a week ago: " + String.join(", ", tooNew), ChatFormatting.RED); return; } - int slot = findMemberSlot(name); - if (slot < 0) { - chat("Couldn't find " + name + "'s item in the menu.", ChatFormatting.RED); - onClientRun(this::closeMenu); + if (!unknown.isEmpty()) { + chat("Skipping (not in the guild roster): " + String.join(", ", unknown), ChatFormatting.YELLOW); + } + if (total <= 0) { + chat("Nothing to pay out.", ChatFormatting.YELLOW); return; } - int total = type == RewardType.EMERALD ? amount * EMERALDS_PER_ITEM : amount; - chat("Gifting " + name + " " + total + " " + type.label + "...", ChatFormatting.GREEN); - for (int i = 0; i < amount; i++) { - final int target = slot; - onClientRun(() -> swapHotbar(target, type.hotbar)); - sleep(GIFT_DELAY_MS); + + if (!openRewardsMenu()) { + chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); + return; } + long[] counts = onClient(this::readAllCounts); + int available = counts == null ? 0 : (int) counts[RewardType.ASPECT.hotbar]; onClientRun(this::closeMenu); - // Report the exact handout count so the backend logs the right total even - // if the server bunched some identical reward announcements together. - RewardReporter currentReporter = reporter; - if (currentReporter != null) { - currentReporter.report(name, type, amount); + if (total > available) { + chat("Not enough aspects: selected " + total + " but the guild only has " + available + " — nothing was distributed.", ChatFormatting.RED); + return; } - // Relay the exact storage left after this run (authoritative final value): - // decrement the gifted type by what we just handed out. - long[] finalCounts = counts.clone(); - finalCounts[type.hotbar] -= type == RewardType.EMERALD ? (long) amount * EMERALDS_PER_ITEM : amount; - StorageReporter currentStorageReporter = storageReporter; - if (currentStorageReporter != null) { - currentStorageReporter.report((int) finalCounts[0], (int) finalCounts[1], finalCounts[2]); + + chat("Paying out " + total + " aspects to " + targets.size() + " members...", ChatFormatting.GREEN); + List skipped = new ArrayList<>(); + int paid = 0; + int done = 0; + try { + for (PayoutTarget target : targets) { + done++; + if (runSingle(target.name(), RewardType.ASPECT, target.aspects(), false)) { + paid++; + } else { + skipped.add(target.name()); + } + } + } catch (Exception e) { + LOGGER.warn("Batch payout interrupted", e); + chat("Payout stopped after " + done + " of " + targets.size() + " members: " + e.getMessage(), ChatFormatting.RED); + return; } - 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"))))); - } else { - chat("Done — gifted " + name + " " + total + " " + type.label + ".", ChatFormatting.GREEN); + chat("Payout complete: " + paid + "/" + targets.size() + " members paid.", ChatFormatting.GREEN); + if (!skipped.isEmpty()) { + chat("Skipped: " + String.join(", ", skipped), ChatFormatting.RED); } } catch (Exception e) { - LOGGER.warn("Gift run failed", e); - chat("Gift failed: " + e.getMessage(), ChatFormatting.RED); + LOGGER.warn("Batch payout failed", e); + chat("Payout failed: " + e.getMessage(), ChatFormatting.RED); } finally { giftInProgress = false; }