Skip to content
Merged
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
75 changes: 73 additions & 2 deletions src/tel/eden/mod/EdenModClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import tel.eden.mod.chat.TerritoryDefendedEvent;
import tel.eden.mod.chat.TerritoryDefendedTracker;
import tel.eden.mod.config.BridgeConfig;
import tel.eden.mod.guild.GuildAllyManager;
import tel.eden.mod.guild.GuildLogSync;
import tel.eden.mod.guild.GuildMenuScraper;
import tel.eden.mod.guild.GuildRewardStorageSnapshot;
Expand All @@ -47,6 +48,7 @@
import tel.eden.mod.item.DecodedItem;
import tel.eden.mod.item.ItemCardRenderer;
import tel.eden.mod.item.ItemStringDetector;
import tel.eden.mod.net.AllyActivityEntry;
import tel.eden.mod.net.BridgeWebSocketClient;
import tel.eden.mod.net.PartyInfo;
import tel.eden.mod.net.GiveawayCandidate;
Expand Down Expand Up @@ -175,6 +177,7 @@ public final class EdenModClient implements ClientModInitializer {
private final BackgroundContainerSession backgroundContainers = new BackgroundContainerSession();
private final GuildRewards guildRewards = new GuildRewards(backgroundContainers);
private final GuildLogSync guildLogSync = new GuildLogSync(backgroundContainers);
private final GuildAllyManager guildAllyManager = new GuildAllyManager(backgroundContainers);
private final List<TrackedCommandKeybind> trackedCommandKeybinds = new ArrayList<>();
private KeyMapping openConfigKey;
private KeyMapping createPartyKey;
Expand Down Expand Up @@ -492,6 +495,31 @@ public GuildLogSync guildLogSync() {
return guildLogSync;
}

public GuildAllyManager guildAllyManager() {
return guildAllyManager;
}

/**
* Whether the player is a Chief, Owner, or Strategist according to the bridge or Wynncraft.
* If rank has not yet been fetched/verified, defaults to true so legitimate Strategists
* are not locked out; the in-game diplomacy menu validates permissions definitively.
*/
public boolean isChiefOwnerOrStrategist() {
if (liveGuildRank != null && !liveGuildRank.isBlank()) {
return liveGuildRank.equalsIgnoreCase("owner") || liveGuildRank.equalsIgnoreCase("chief") || liveGuildRank.equalsIgnoreCase("strategist");
}
if (guildRewards.hasKnownRank()) {
return guildRewards.isChiefOwnerOrStrategist();
}
guildRewards.ensureFresh(playerName());
return true;
}

public void onWorldRespawn() {
enteredWynnGameWorld = true;
guildLogSync.onWorldJoin();
}

/** Packet hooks for the invisible guild-log query (mirrors Wynntils' query path). */
public boolean onGuildLogOpenScreen(ClientboundOpenScreenPacket packet) {
return guildLogSync.onOpenScreen(packet);
Expand Down Expand Up @@ -634,6 +662,7 @@ public void release() {
AllianceMenuScraper.reset();
GuildMenuScraper.reset();
AttackTimerMenu.reset();
guildAllyManager.requestCancel();
// Connect if applicable; the backend reports link/membership standing via
// authOk (handleAuthStatus), which drives any "please link" prompt.
evaluateGating(client);
Expand Down Expand Up @@ -670,7 +699,7 @@ public void release() {
}).then(ClientCommandManager.literal("download").executes(ctx -> {
updateDownload(ctx.getSource());
return 1;
}))).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 -> {
}))).then(buildGiftCommand()).then(buildDeductCommand()).then(buildAllyCommand()).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 @@ -1057,6 +1086,12 @@ public void onGameFeedback(String message, String color) {
displayColored(color, () -> DiscordChatFormatter.systemLine(message, net.minecraft.ChatFormatting.GOLD));
}

@Override
public void onAllyActivity(java.util.List<AllyActivityEntry> entries) {
boolean canManage = isChiefOwnerOrStrategist();
display(() -> DiscordChatFormatter.allyActivity(entries, canManage));
}

@Override
public void onAuthStatus(boolean linked, boolean member, String guildRank, String discordRank) {
handleAuthStatus(linked, member, guildRank, discordRank);
Expand Down Expand Up @@ -1264,6 +1299,40 @@ private int deductReward(FabricClientCommandSource source, String rewardKind, St
return 1;
}

/** Build {@code /eden ally (view | add <name> | remove <name>)}. */
private LiteralArgumentBuilder<FabricClientCommandSource> buildAllyCommand() {
return ClientCommandManager.literal("ally").executes(ctx -> {
requestAllyView(ctx.getSource());
return 1;
}).then(ClientCommandManager.literal("view").executes(ctx -> {
requestAllyView(ctx.getSource());
return 1;
})).then(ClientCommandManager.literal("add").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> {
if (!isChiefOwnerOrStrategist()) {
ctx.getSource().sendFeedback(Component.literal(tel.eden.mod.guild.GuildAllyManager.PERMISSION_ERROR).withStyle(ChatFormatting.RED));
return 0;
}
guildAllyManager.addAlly(StringArgumentType.getString(ctx, "name"));
return 1;
}))).then(ClientCommandManager.literal("remove").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> {
if (!isChiefOwnerOrStrategist()) {
ctx.getSource().sendFeedback(Component.literal(tel.eden.mod.guild.GuildAllyManager.PERMISSION_ERROR).withStyle(ChatFormatting.RED));
return 0;
}
guildAllyManager.removeAlly(StringArgumentType.getString(ctx, "name"));
return 1;
})));
}

private void requestAllyView(FabricClientCommandSource source) {
BridgeWebSocketClient current = socket;
if (current == null) {
source.sendFeedback(notConnected());
return;
}
current.sendAllyActivityRequest();
}

private CompletableFuture<Suggestions> suggestMembers(CommandContext<FabricClientCommandSource> context, SuggestionsBuilder builder) {
String remaining = builder.getRemaining().toLowerCase(Locale.ROOT);
for (String name : guildRewards.memberNames()) {
Expand Down Expand Up @@ -2081,7 +2150,7 @@ private int handleReplyCommand(String messageId) {
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 party disband", "disband your hosted 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 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 party disband", "disband your hosted 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 ally view", "view guild ally activity (most inactive to active)"), new HelpEntry("/eden ally add <name>", "add a guild ally — Strategist+ only"), new HelpEntry("/eden ally remove <name>", "remove a guild ally — Strategist+ only"), new HelpEntry("/eden help", "this help screen"));

private static final class TrackedCommandKeybind {
private final String input;
Expand Down Expand Up @@ -2245,6 +2314,7 @@ private synchronized void disconnect() {
RaidReadyTracker.reset();
PartyHealthTracker.reset();
ChatReplyManager.reset();
guildAllyManager.requestCancel();
territoryDefendedTracker.clear();
// 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
Expand Down Expand Up @@ -2291,6 +2361,7 @@ private static void sendStreamCommand() {
public void handleSystemChat(Component message) {
PlayerNameResolver.observeMessage(message);
handleRewardFeedback(message);
guildAllyManager.onSystemChat(message.getString());
// A real chat line breaks any in-progress Discord emblem block, so the next
// relayed Discord message starts with a fresh shield (like guild chat).
DiscordChatFormatter.onServerChatLine();
Expand Down
4 changes: 2 additions & 2 deletions src/tel/eden/mod/chat/ChatText.java
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ private record MetaChar(char value, String hover, String insertion) {
* fixing spacing before punctuation so {@code name: message} and raid-completion
* shapes are cleanly matchable.
*/
static String normalize(String raw) {
public static String normalize(String raw) {
return normalizeWhitespace(raw).replaceAll("\\s+([,.:;!?])", "$1");
}

Expand Down Expand Up @@ -241,7 +241,7 @@ static String unwrapWynncraftSoftWraps(String raw) {
* without changing punctuation spacing. Use this for user-authored text, where a
* space before {@code :shortcode:} or punctuation may be intentional.
*/
static String normalizeWhitespace(String raw) {
public static String normalizeWhitespace(String raw) {
if (raw == null || raw.isBlank()) {
return "";
}
Expand Down
40 changes: 40 additions & 0 deletions src/tel/eden/mod/chat/DiscordChatFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
import net.minecraft.resources.Identifier;
import tel.eden.mod.guild.AllyTagCache;

/**
* Renders a relayed Discord message for in-game display so it sits flush with
Expand Down Expand Up @@ -143,6 +144,45 @@ public static Component warCounts(int days, java.util.List<tel.eden.mod.net.WarC
return out;
}

/**
* Guild ally activity list (backend-authoritative, ordered from most inactive to active).
* If {@code canManage} is true (Strategist+), each ally row has a hover tooltip and click
* action to suggest {@code /eden ally remove {name}}.
*/
public static Component allyActivity(java.util.List<tel.eden.mod.net.AllyActivityEntry> entries, boolean canManage) {
MutableComponent out = Component.empty().append(prefix(SHIELD));
if (entries.isEmpty()) {
return out.append(Component.literal("No guild allies currently found.").withStyle(ChatFormatting.GOLD));
}
out.append(Component.literal("Guild Allies (by inactivity):").withStyle(ChatFormatting.GREEN));
for (tel.eden.mod.net.AllyActivityEntry entry : entries) {
ChatFormatting color;
if (entry.elapsedMinutes() > 45) {
color = ChatFormatting.RED;
} else if (entry.elapsedMinutes() >= 30) {
color = ChatFormatting.YELLOW;
} else {
color = ChatFormatting.GREEN;
}
long mins = Math.round(entry.elapsedMinutes());
String timeStr = mins < 60 ? mins + "m ago" : (mins / 60) + "h " + (mins % 60) + "m ago";
String tag = (entry.tag() != null && !entry.tag().isEmpty()) ? entry.tag() : AllyTagCache.getTag(entry.name());
if (tag != null && !tag.isEmpty()) {
AllyTagCache.remember(entry.name(), tag);
}
String tagStr = (tag != null && !tag.isEmpty()) ? " [" + tag + "]" : "";
String lineText = "\n " + entry.name() + tagStr + " — " + timeStr;

MutableComponent line = Component.literal(lineText).withStyle(color);
if (canManage) {
String target = (tag != null && !tag.isEmpty()) ? tag : entry.name();
line = line.withStyle(style -> style.withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to suggest /eden ally remove " + target).withStyle(ChatFormatting.GRAY))).withClickEvent(new ClickEvent.SuggestCommand("/eden ally remove " + target)));
}
out.append(line);
}
return out;
}

// Inline reply excerpt is truncated to this many characters (full text on hover).
private static final int EXCERPT_MAX = 50;

Expand Down
47 changes: 47 additions & 0 deletions src/tel/eden/mod/guild/AllyTagCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package tel.eden.mod.guild;

import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Thread-safe cache of known allied guild names to their tags.
* Populated from diplomacy screen scraping, root manage menu tooltips,
* and ally activity replies to ensure tags are always present in output.
*/
public final class AllyTagCache {
private static final Map<String, String> NAME_TO_TAG = new ConcurrentHashMap<>();
private static final Map<String, String> TAG_TO_NAME = new ConcurrentHashMap<>();

private AllyTagCache() {
}

public static void remember(String name, String tag) {
if (name == null || name.isBlank() || tag == null || tag.isBlank()) {
return;
}
String cleanName = name.trim();
String cleanTag = tag.trim();
NAME_TO_TAG.put(cleanName.toLowerCase(Locale.ROOT), cleanTag);
TAG_TO_NAME.put(cleanTag.toLowerCase(Locale.ROOT), cleanName);
}

public static String getTag(String name) {
if (name == null || name.isBlank()) {
return null;
}
return NAME_TO_TAG.get(name.trim().toLowerCase(Locale.ROOT));
}

public static String getName(String tag) {
if (tag == null || tag.isBlank()) {
return null;
}
return TAG_TO_NAME.get(tag.trim().toLowerCase(Locale.ROOT));
}

public static void clear() {
NAME_TO_TAG.clear();
TAG_TO_NAME.clear();
}
}
42 changes: 30 additions & 12 deletions src/tel/eden/mod/guild/BackgroundContainerSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public final class BackgroundContainerSession {
private volatile int stateId;
private volatile boolean open;
private volatile boolean aborted;
private volatile boolean serverClosed;
private volatile List<ItemStack> items = List.of();
private volatile long lastContentAt;
private volatile long snapshotRevision;
Expand All @@ -68,31 +69,38 @@ public void onWorldLeaving() {
/** Claim the next matching server menu. Returns false if a real foreign menu is open. */
public boolean begin(String owner, Predicate<String> expectedTitle, int menuSlotCount) {
Minecraft mc = Minecraft.getInstance();
if (owner == null || owner.isBlank() || expectedTitle == null || menuSlotCount <= 0 || mc.player == null || mc.getConnection() == null || open || !this.owner.isEmpty()) {
return false;
}
if (System.currentTimeMillis() - joinedAt < WORLD_COMMAND_GRACE_MS) {
if (owner == null || owner.isBlank() || expectedTitle == null || menuSlotCount <= 0 || mc.player == null || mc.getConnection() == null) {
return false;
}
if (mc.screen instanceof AbstractContainerScreen<?>) {
return false;
}
if (observedServerContainerId > 0) {
long elapsed = System.currentTimeMillis() - joinedAt;
boolean localInventoryOnly = mc.player.containerMenu == mc.player.inventoryMenu && !(mc.screen instanceof AbstractContainerScreen<?>);
if (elapsed >= STARTUP_ORPHAN_GRACE_MS && elapsed <= STARTUP_ORPHAN_WINDOW_MS && localInventoryOnly) {
LOGGER.warn("Background container: closing startup orphan {} ('{}') after {}ms", observedServerContainerId, observedServerTitle, elapsed);
mc.getConnection().send(new ServerboundContainerClosePacket(observedServerContainerId));
observedServerContainerId = -1;
observedServerTitle = "";
if (open || !this.owner.isEmpty()) {
if ("guild_log".equals(this.owner) && !"guild_log".equals(owner)) {
LOGGER.info("Background container: {} preempting background guild_log", owner);
close();
} else {
return false;
}
}
if (System.currentTimeMillis() - joinedAt < WORLD_COMMAND_GRACE_MS) {
return false;
}
if (mc.player.containerMenu != mc.player.inventoryMenu) {
LOGGER.info("Background container: closing orphan containerMenu {} ('{}')", mc.player.containerMenu.containerId, observedServerTitle);
mc.player.closeContainer();
}
if (observedServerContainerId > 0) {
LOGGER.info("Background container: closing lingering container {} ('{}')", observedServerContainerId, observedServerTitle);
mc.getConnection().send(new ServerboundContainerClosePacket(observedServerContainerId));
observedServerContainerId = -1;
observedServerTitle = "";
}
this.owner = owner;
this.expectedTitle = expectedTitle;
this.menuSlotCount = menuSlotCount;
this.aborted = false;
this.serverClosed = false;
return true;
}

Expand Down Expand Up @@ -155,6 +163,14 @@ public boolean isAborted() {
return aborted;
}

public boolean isClosed() {
return serverClosed || aborted || !open;
}

public boolean isServerClosed() {
return serverClosed;
}

public boolean inStartupRecoveryWindow() {
long elapsed = System.currentTimeMillis() - joinedAt;
return elapsed >= 0L && elapsed <= STARTUP_ORPHAN_WINDOW_MS;
Expand Down Expand Up @@ -277,6 +293,7 @@ public void onContainerClosed(int closedId) {
}
if (closedId == containerId) {
aborted = true;
serverClosed = true;
}
}

Expand All @@ -288,6 +305,7 @@ private void clearActive() {
stateId = 0;
open = false;
aborted = false;
serverClosed = false;
items = List.of();
lastContentAt = 0L;
snapshotRevision = 0L;
Expand Down
Loading
Loading