Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 102 additions & 2 deletions src/tel/eden/mod/EdenModClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ private record PendingWarReport(String territory, List<String> members) {
}

private final java.util.concurrent.ConcurrentLinkedQueue<PendingWarReport> 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<PendingDeduct> 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).
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -584,6 +598,22 @@ public void onAspectsPending(java.util.List<PendingEntry> 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());
Expand Down Expand Up @@ -861,6 +891,35 @@ private LiteralArgumentBuilder<FabricClientCommandSource> 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 <aspects|emeralds> <member> <amount>} (Chiefs only). */
private LiteralArgumentBuilder<FabricClientCommandSource> buildDeductCommand() {
return ClientCommandManager.literal("deduct").then(deductKindArg("aspects")).then(deductKindArg("emeralds"));
}

private LiteralArgumentBuilder<FabricClientCommandSource> 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<Suggestions> suggestMembers(CommandContext<FabricClientCommandSource> context, SuggestionsBuilder builder) {
String remaining = builder.getRemaining().toLowerCase(Locale.ROOT);
for (String name : guildRewards.memberNames()) {
Expand All @@ -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.
*
* <p>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) {
Expand Down Expand Up @@ -1572,7 +1664,7 @@ private int showEmojis(FabricClientCommandSource source) {
private record HelpEntry(String command, String description) {
}

private static final List<HelpEntry> 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 <raid> [note]", "open a raid party"), new HelpEntry("/eden party join <id>", "join a party"), new HelpEntry("/eden party leave [id]", "leave your party"), new HelpEntry("/eden anni <size> [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 <member> <aspect|emerald|tome> <amount>", "gift guild rewards — Chiefs only"), new HelpEntry("/eden dump <member>", "gift all guild-bank emeralds to a member — Chiefs only"), new HelpEntry("/eden help", "this help screen"));
private static final List<HelpEntry> 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 <raid> [note]", "open a raid party"), new HelpEntry("/eden party join <id>", "join a party"), new HelpEntry("/eden party leave [id]", "leave your party"), new HelpEntry("/eden anni <size> [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 <member> <aspect|emerald|tome> <amount>", "gift guild rewards — Chiefs only"), new HelpEntry("/eden dump <member>", "gift all guild-bank emeralds to a member — Chiefs only"), new HelpEntry("/eden deduct <aspects|emeralds> <member> <amount>", "deduct a payout from pending rewards — Chiefs only"), new HelpEntry("/eden help", "this help screen"));

private static final class TrackedCommandKeybind {
private final String input;
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/tel/eden/mod/chat/DiscordChatFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
27 changes: 27 additions & 0 deletions src/tel/eden/mod/net/BridgeWebSocketClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public interface MessageSink {
*/
void onAspectsPending(java.util.List<PendingEntry> 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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
Expand Down
Loading
Loading