diff --git a/gradle.properties b/gradle.properties index 336689c..605364a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ loader_version=0.19.3 fabric_version=0.141.4+1.21.11 loom_version=1.17.8 -mod_version=1.6.3 +mod_version=1.6.4-beta2 maven_group=tel.eden archives_base_name=edenmod modmenu_version=17.0.0 diff --git a/resources/assets/edenmod/textures/gui/event/all_raids.png b/resources/assets/edenmod/textures/gui/event/all_raids.png new file mode 100644 index 0000000..fecca50 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/all_raids.png differ diff --git a/resources/assets/edenmod/textures/gui/event/coinflip.png b/resources/assets/edenmod/textures/gui/event/coinflip.png new file mode 100644 index 0000000..9e79f71 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/coinflip.png differ diff --git a/resources/assets/edenmod/textures/gui/event/diceroll.png b/resources/assets/edenmod/textures/gui/event/diceroll.png new file mode 100644 index 0000000..d7149f5 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/diceroll.png differ diff --git a/resources/assets/edenmod/textures/gui/event/frame_daily.png b/resources/assets/edenmod/textures/gui/event/frame_daily.png new file mode 100644 index 0000000..73af0bb Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/frame_daily.png differ diff --git a/resources/assets/edenmod/textures/gui/event/frame_master.png b/resources/assets/edenmod/textures/gui/event/frame_master.png new file mode 100644 index 0000000..1119433 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/frame_master.png differ diff --git a/resources/assets/edenmod/textures/gui/event/frame_repeatable.png b/resources/assets/edenmod/textures/gui/event/frame_repeatable.png new file mode 100644 index 0000000..203ebe2 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/frame_repeatable.png differ diff --git a/resources/assets/edenmod/textures/gui/event/frame_weekly.png b/resources/assets/edenmod/textures/gui/event/frame_weekly.png new file mode 100644 index 0000000..8ecdab9 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/frame_weekly.png differ diff --git a/resources/assets/edenmod/textures/gui/event/guild_wars.png b/resources/assets/edenmod/textures/gui/event/guild_wars.png new file mode 100644 index 0000000..1fc8fcb Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/guild_wars.png differ diff --git a/resources/assets/edenmod/textures/gui/event/nol.png b/resources/assets/edenmod/textures/gui/event/nol.png new file mode 100644 index 0000000..cb4e278 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/nol.png differ diff --git a/resources/assets/edenmod/textures/gui/event/notg.png b/resources/assets/edenmod/textures/gui/event/notg.png new file mode 100644 index 0000000..27893ef Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/notg.png differ diff --git a/resources/assets/edenmod/textures/gui/event/other.png b/resources/assets/edenmod/textures/gui/event/other.png new file mode 100644 index 0000000..40f1502 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/other.png differ diff --git a/resources/assets/edenmod/textures/gui/event/tcc.png b/resources/assets/edenmod/textures/gui/event/tcc.png new file mode 100644 index 0000000..c6c7e7a Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/tcc.png differ diff --git a/resources/assets/edenmod/textures/gui/event/tna.png b/resources/assets/edenmod/textures/gui/event/tna.png new file mode 100644 index 0000000..d748b7a Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/tna.png differ diff --git a/resources/assets/edenmod/textures/gui/event/trivia.png b/resources/assets/edenmod/textures/gui/event/trivia.png new file mode 100644 index 0000000..e4ae8a5 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/trivia.png differ diff --git a/resources/assets/edenmod/textures/gui/event/wtp.png b/resources/assets/edenmod/textures/gui/event/wtp.png new file mode 100644 index 0000000..a344f13 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/event/wtp.png differ diff --git a/resources/assets/edenmod/textures/gui/icons/nol.png b/resources/assets/edenmod/textures/gui/icons/nol.png index 00db353..cb4e278 100644 Binary files a/resources/assets/edenmod/textures/gui/icons/nol.png and b/resources/assets/edenmod/textures/gui/icons/nol.png differ diff --git a/resources/assets/edenmod/textures/gui/icons/notg.png b/resources/assets/edenmod/textures/gui/icons/notg.png index 0bf34ed..27893ef 100644 Binary files a/resources/assets/edenmod/textures/gui/icons/notg.png and b/resources/assets/edenmod/textures/gui/icons/notg.png differ diff --git a/resources/assets/edenmod/textures/gui/icons/tcc.png b/resources/assets/edenmod/textures/gui/icons/tcc.png index c2324d4..c6c7e7a 100644 Binary files a/resources/assets/edenmod/textures/gui/icons/tcc.png and b/resources/assets/edenmod/textures/gui/icons/tcc.png differ diff --git a/resources/assets/edenmod/textures/gui/icons/tna.png b/resources/assets/edenmod/textures/gui/icons/tna.png index c1fcc84..d748b7a 100644 Binary files a/resources/assets/edenmod/textures/gui/icons/tna.png and b/resources/assets/edenmod/textures/gui/icons/tna.png differ diff --git a/resources/assets/edenmod/textures/gui/icons/wtp.png b/resources/assets/edenmod/textures/gui/icons/wtp.png index 4be4d06..a344f13 100644 Binary files a/resources/assets/edenmod/textures/gui/icons/wtp.png and b/resources/assets/edenmod/textures/gui/icons/wtp.png differ diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 281351d..489f600 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -43,6 +43,7 @@ import tel.eden.mod.gui.BridgeConfigScreen; import tel.eden.mod.gui.CommandAliasScreen; import tel.eden.mod.gui.CommandKeybindScreen; +import tel.eden.mod.gui.LongEventScreen; import tel.eden.mod.gui.PartyCreateScreen; import tel.eden.mod.gui.PartyListScreen; import tel.eden.mod.gui.PartyManageScreen; @@ -51,6 +52,7 @@ import tel.eden.mod.item.ItemStringDetector; import tel.eden.mod.net.AllyActivityEntry; import tel.eden.mod.net.BridgeWebSocketClient; +import tel.eden.mod.net.LongEventState; import tel.eden.mod.net.PartyInfo; import tel.eden.mod.net.GiveawayCandidate; import tel.eden.mod.net.MailEntry; @@ -168,6 +170,8 @@ public final class EdenModClient implements ClientModInitializer { private final java.util.concurrent.atomic.AtomicInteger partyCommandGeneration = new java.util.concurrent.atomic.AtomicInteger(); private final java.util.concurrent.atomic.AtomicLong allyViewRequestId = new java.util.concurrent.atomic.AtomicLong(); private final java.util.concurrent.atomic.AtomicLong allyViewPendingId = new java.util.concurrent.atomic.AtomicLong(); + private final java.util.concurrent.atomic.AtomicLong eventRewardsRequestId = new java.util.concurrent.atomic.AtomicLong(); + private final java.util.concurrent.atomic.AtomicLong eventRewardsPendingId = new java.util.concurrent.atomic.AtomicLong(); // Deterministic per-occurrence index so rapid identical bank events stay distinct. // Short window: only the current burst counts, so stale earlier deposits can't // make different clients assign divergent seqs (which duplicated Discord posts). @@ -270,6 +274,11 @@ public boolean onWynncraft() { private volatile int giveawayStorageAspects; private volatile String giveawayError; private final java.util.concurrent.atomic.AtomicInteger giveawayGeneration = new java.util.concurrent.atomic.AtomicInteger(); + private volatile LongEventState knownLongEventState; + // Last long-event error (a refused reroll, a failed request), shown briefly on the Event screen. + private volatile String longEventError = ""; + private volatile long longEventErrorAtMs; + private final java.util.concurrent.atomic.AtomicInteger longEventGeneration = 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(); @@ -506,6 +515,21 @@ public int giveawayGeneration() { return giveawayGeneration.get(); } + /** Latest long-event mission state from the backend, or {@code null} before first reply. */ + public LongEventState knownLongEventState() { + return knownLongEventState; + } + + /** The last long-event error if it arrived in the past 5 seconds, else {@code ""}. */ + public String recentLongEventError() { + return System.currentTimeMillis() - longEventErrorAtMs < 5_000L ? longEventError : ""; + } + + /** Bumped on every long-event state snapshot; 0 means none has arrived yet. */ + public int longEventGeneration() { + return longEventGeneration.get(); + } + /** The guild reward helper (rank lookups + gifting). */ public GuildRewards guildRewards() { return guildRewards; @@ -706,6 +730,7 @@ public void release() { tel.eden.mod.war.radiance.RadianceTracker.onJoin(); tel.eden.mod.war.eco.TerritoryGuideController.reset(); guildAllyManager.requestCancel(); + eventRewardsPendingId.set(0); // Connect if applicable; the backend reports link/membership standing via // authOk (handleAuthStatus), which drives any "please link" prompt. evaluateGating(client); @@ -742,7 +767,7 @@ public void release() { }).then(ClientCommandManager.literal("download").executes(ctx -> { updateDownload(ctx.getSource()); return 1; - }))).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 -> { + }))).then(buildGiftCommand()).then(buildDeductCommand()).then(buildAllyCommand()).then(buildEventCommand()).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 -> { @@ -1228,7 +1253,7 @@ public void onConnectionRejected(String code) { } @Override - public void onPillMessage(String label, String content, String colorHex, boolean bold) { + public void onPillMessage(String label, String content, String colorHex, boolean bold, boolean own) { if (GAMES_PILL_LABEL.equals(label) && config.gameDisplayMode != BridgeConfig.GameDisplayMode.ALL) { // React Only still shows your own coinflip/diceroll result; every // other "eden"-labelled message (streaks, gambit resets, reward @@ -1250,6 +1275,15 @@ public void onPillMessage(String label, String content, String colorHex, boolean } } Integer finalColorRgb = colorRgb; + if ("EVENT".equals(label)) { + // The bot flags your own completions (own), so this never depends on name matching. + if (!own && !config.eventAnnouncements) { + return; + } + String finalContent = own ? formatOwnEventAnnouncement(content) : content; + display(() -> DiscordChatFormatter.pill(label, finalContent, finalColorRgb, bold)); + return; + } display(() -> DiscordChatFormatter.pill(label, content, finalColorRgb, bold)); } @@ -1298,6 +1332,31 @@ public void onAllyActivity(java.util.List entries) { display(() -> DiscordChatFormatter.allyActivity(entries, canManage)); } + @Override + public void onLongEventState(LongEventState state, boolean hasSnapshot) { + if (state.error() != null && !state.error().isEmpty()) { + longEventError = state.error(); + longEventErrorAtMs = System.currentTimeMillis(); + display(() -> DiscordChatFormatter.systemLine(state.error(), ChatFormatting.RED)); + } + if (hasSnapshot || knownLongEventState == null) { + knownLongEventState = state; + longEventGeneration.incrementAndGet(); + } + } + + @Override + public void onLongEventRewards(int pendingLe, String error) { + if (eventRewardsPendingId.getAndSet(0) == 0) { + return; + } + if (error != null && !error.isEmpty()) { + display(() -> Component.literal("Could not fetch event rewards").withStyle(ChatFormatting.RED)); + return; + } + display(() -> DiscordChatFormatter.systemLine("Pending event rewards: " + pendingLe + " LE", ChatFormatting.GOLD)); + } + @Override public void onAuthStatus(boolean linked, boolean member, String guildRank, String discordRank) { handleAuthStatus(linked, member, guildRank, discordRank); @@ -1625,6 +1684,39 @@ private void requestAllyView(FabricClientCommandSource source) { }, 5, TimeUnit.SECONDS); } + private LiteralArgumentBuilder buildEventCommand() { + return ClientCommandManager.literal("event").executes(ctx -> openLongEventGui(ctx.getSource())).then(ClientCommandManager.literal("rewards").executes(ctx -> { + requestEventRewards(ctx.getSource()); + return 1; + })); + } + + private int openLongEventGui(FabricClientCommandSource source) { + BridgeWebSocketClient current = socket; + if (current != null) { + current.sendLongEventStateRequest(); + } + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> mc.setScreen(new LongEventScreen(mc.screen, this))); + return 1; + } + + private void requestEventRewards(FabricClientCommandSource source) { + BridgeWebSocketClient current = socket; + if (current == null || !current.isConnected()) { + source.sendFeedback(Component.literal("Could not fetch event rewards").withStyle(ChatFormatting.RED)); + return; + } + long id = eventRewardsRequestId.incrementAndGet(); + eventRewardsPendingId.set(id); + current.sendLongEventRewardsRequest(); + partyCommandExecutor.schedule(() -> { + if (eventRewardsPendingId.compareAndSet(id, 0)) { + display(() -> Component.literal("Could not fetch event rewards").withStyle(ChatFormatting.RED)); + } + }, 5, TimeUnit.SECONDS); + } + private CompletableFuture suggestMembers(CommandContext context, SuggestionsBuilder builder) { String remaining = builder.getRemaining().toLowerCase(Locale.ROOT); for (String name : guildRewards.memberNames()) { @@ -1744,6 +1836,16 @@ private static boolean isOwnGameResult(String content) { return name != null && content.contains(name); } + private static final java.util.regex.Pattern OWN_EVENT_LINE = java.util.regex.Pattern.compile("\\A(\\S+\\s+)\\S+\\s+(completed|unlocked)\\b"); + + /** Reword your own event line: "⭐ Name completed …" becomes "⭐ You've completed …". */ + public static String formatOwnEventAnnouncement(String content) { + if (content == null) { + return null; + } + return OWN_EVENT_LINE.matcher(content).replaceFirst("$1You've $2"); + } + private int openOverviewGui(FabricClientCommandSource source) { Minecraft mc = Minecraft.getInstance(); mc.execute(this::openMenuGui); @@ -2442,7 +2544,7 @@ private int handleReplyCommand(String messageId) { 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 mail ", "leave a member a message for their next login"), 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 party disband", "disband your hosted 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 ally view", "view guild ally activity (most inactive to active)"), new HelpEntry("/eden ally add ", "add a guild ally — Strategist+ only"), new HelpEntry("/eden ally remove ", "remove a guild ally — Strategist+ only"), new HelpEntry("/eden rewards", "check your pending aspects/emeralds and opt-out status"), new HelpEntry("/eden aspects optout [true|false]", "opt out of or into receiving aspects"), new HelpEntry("/eden emeralds optout [true|false]", "opt out of or into receiving emeralds"), 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 mail ", "leave a member a message for their next login"), 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 event", "open the event missions screen"), new HelpEntry("/eden event rewards", "check your pending event LE"), 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 party disband", "disband your hosted 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 ally view", "view guild ally activity (most inactive to active)"), new HelpEntry("/eden ally add ", "add a guild ally — Strategist+ only"), new HelpEntry("/eden ally remove ", "remove a guild ally — Strategist+ only"), new HelpEntry("/eden rewards", "check your pending aspects/emeralds and opt-out status"), new HelpEntry("/eden aspects optout [true|false]", "opt out of or into receiving aspects"), new HelpEntry("/eden emeralds optout [true|false]", "opt out of or into receiving emeralds"), new HelpEntry("/eden help", "this help screen")); private static final class TrackedCommandKeybind { private final String input; @@ -2637,6 +2739,11 @@ private synchronized void disconnect() { tel.eden.mod.war.eco.TerritoryGuideController.reset(); guildAllyManager.requestCancel(); territoryDefendedTracker.clear(); + eventRewardsPendingId.set(0); + knownLongEventState = null; + longEventGeneration.set(0); + longEventError = ""; + longEventErrorAtMs = 0L; // 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. @@ -2986,7 +3093,7 @@ private void display(java.util.function.Supplier builder, boolean via client.execute(() -> { if (client.player != null) { Component component = builder.get(); - if (!viaChatTab || !WynntilsChatBridge.sendToTab(component)) { + if (component != null && (!viaChatTab || !WynntilsChatBridge.sendToTab(component))) { client.player.displayClientMessage(component, false); } } diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index f4c9d23..ee9d618 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -65,6 +65,12 @@ private static Path resolveConfigPath() { */ public boolean partyAnnounce = true; + /** + * Whether other members' event announcements (Weekly, Master) are displayed in chat. + * When false, only your own event completions are shown. On by default. + */ + public boolean eventAnnouncements = true; + public enum GameDisplayMode { ALL("Shown (All)"), NONE("Hidden (All)"), REACTIONS("Show Only Reactions"); diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index 7eff181..a38251e 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -118,6 +118,7 @@ protected void init() { addToggleRow(Category.GENERAL, "My login/logout messages", () -> config.announceSelfPresence, v -> config.announceSelfPresence = v, "On", "Off", true); addToggleRow(Category.GENERAL, "Mail sound", () -> config.mailSound, v -> config.mailSound = v, "On", "Off", true); addToggleRow(Category.GENERAL, "Party feed", () -> config.partyAnnounce, v -> config.partyAnnounce = v, "On", "Off", true); + addToggleRow(Category.GENERAL, "Event announcements", () -> config.eventAnnouncements, v -> config.eventAnnouncements = v, "On", "Off", true); addToggleRow(Category.GENERAL, "Auto /stream on join", () -> config.autoStream, v -> config.autoStream = v, "On", "Off", false); addToggleRow(Category.GENERAL, "Click shouts to reply", () -> config.shoutsClickable, v -> config.shoutsClickable = v, "On", "Off", true); addToggleRow(Category.GENERAL, "Click-to-congratulate", () -> config.clickToCongratulate, v -> config.clickToCongratulate = v, "On", "Off", false); diff --git a/src/tel/eden/mod/gui/EdenMenuScreen.java b/src/tel/eden/mod/gui/EdenMenuScreen.java index 4da206e..857c391 100644 --- a/src/tel/eden/mod/gui/EdenMenuScreen.java +++ b/src/tel/eden/mod/gui/EdenMenuScreen.java @@ -17,7 +17,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 = 7; + private static final int BUTTON_COUNT = 8; private static final int LOGO_WIDTH = 128; private static final int LOGO_GAP = 20; private static final int CONTENT_MARGIN = 12; @@ -55,9 +55,17 @@ protected void init() { this.minecraft.setScreen(BridgeConfigScreen.create(this, EdenModClient.instance())); }).bounds(metrics.buttonX, metrics.startY, metrics.buttonWidth, metrics.buttonHeight).build()); + this.addRenderableWidget(Button.builder(Component.literal("Event"), button -> { + EdenModClient mod = EdenModClient.instance(); + if (mod != null && mod.socket() != null && mod.socket().isConnected()) { + mod.socket().sendLongEventStateRequest(); + } + this.minecraft.setScreen(new LongEventScreen(this, mod)); + }).bounds(metrics.buttonX, metrics.startY + metrics.buttonPitch, metrics.buttonWidth, metrics.buttonHeight).build()); + this.addRenderableWidget(Button.builder(Component.literal("Create Party"), button -> { this.minecraft.setScreen(new PartyCreateScreen(this, EdenModClient.instance())); - }).bounds(metrics.buttonX, metrics.startY + metrics.buttonPitch, metrics.buttonWidth, metrics.buttonHeight).build()); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 2), metrics.buttonWidth, metrics.buttonHeight).build()); this.addRenderableWidget(Button.builder(Component.literal("Manage Party"), button -> { String ign = EdenModClient.instance().playerName(); @@ -73,23 +81,23 @@ protected void init() { } else { this.minecraft.player.displayClientMessage(Component.literal("You are not hosting a party!").withStyle(net.minecraft.ChatFormatting.RED), false); } - }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 2), metrics.buttonWidth, metrics.buttonHeight).build()); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 3), metrics.buttonWidth, metrics.buttonHeight).build()); this.addRenderableWidget(Button.builder(Component.literal("Party List"), button -> { this.minecraft.setScreen(new PartyListScreen(this, EdenModClient.instance())); - }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 3), metrics.buttonWidth, metrics.buttonHeight).build()); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 4), metrics.buttonWidth, metrics.buttonHeight).build()); this.addRenderableWidget(Button.builder(Component.literal("Payouts"), button -> { this.minecraft.setScreen(new PayoutScreen(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 Aliases"), button -> { this.minecraft.setScreen(new CommandAliasScreen(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()); this.addRenderableWidget(Button.builder(Component.literal("Command Keybinds"), button -> { this.minecraft.setScreen(new CommandKeybindScreen(this, EdenModClient.instance())); - }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 6), metrics.buttonWidth, metrics.buttonHeight).build()); + }).bounds(metrics.buttonX, metrics.startY + (metrics.buttonPitch * 7), metrics.buttonWidth, metrics.buttonHeight).build()); } @Override diff --git a/src/tel/eden/mod/gui/LongEventScreen.java b/src/tel/eden/mod/gui/LongEventScreen.java new file mode 100644 index 0000000..7e8b968 --- /dev/null +++ b/src/tel/eden/mod/gui/LongEventScreen.java @@ -0,0 +1,743 @@ +package tel.eden.mod.gui; + +import com.mojang.blaze3d.platform.NativeImage; +import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.ConfirmLinkScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Util; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.chat.ImagePreviewManager; +import tel.eden.mod.raid.RaidGambitSafety; +import tel.eden.mod.net.LongEventState; + +/** Screen displaying Daily, Weekly, Repeatable, and Master long-event missions. */ +public final class LongEventScreen extends EdenReferenceScreen { + private static final int BASE_PANEL_WIDTH = 480; + private static final int BASE_PANEL_HEIGHT = 350; + private static final String[] SLOT_ORDER = {"daily", "special", "repeatable", "repeatable2"}; + private static final String[] SLOT_LABELS = {"Daily", "Special", "Repeatable", "Repeatable 2"}; + private static final int[] CARD_BASE_X = {12, 128, 244, 360}; + private static final int CARD_BASE_W = 108; + private static final int CARD_BASE_Y = 48; + private static final int CARD_BASE_H = 146; + + private static final SoundEvent CLICK_SOUND = SoundEvent.createVariableRangeEvent(Identifier.parse("minecraft:ui.button.click")); + private static final Map TEXTURE_CACHE = new HashMap<>(); + + private record LoadedTexture(Identifier id, int width, int height) { + private static final LoadedTexture EMPTY = new LoadedTexture(null, 0, 0); + } + + private record ClickBox(int x, int y, int w, int h) { + boolean contains(double mx, double my) { + return mx >= x && mx < x + w && my >= y && my < y + h; + } + } + + private final Screen parent; + private final EdenModClient mod; + private EdenPanelLayout layout; + private int seenGeneration = -1; + private LongEventState state; + private final Map requestedResetAtMs = new HashMap<>(); + private long lastStateRequestMs = 0L; + + public LongEventScreen(Screen parent, EdenModClient mod) { + super(Component.literal("Event")); + this.parent = parent; + this.mod = mod; + } + + @Override + protected void init() { + super.init(); + updateReferenceSpace(); + layout = EdenPanelLayout.centered(virtualWidth, virtualHeight, BASE_PANEL_WIDTH, BASE_PANEL_HEIGHT); + + this.addRenderableWidget(Button.builder(Component.literal("Back"), b -> onClose()).bounds(layout.x(160), layout.y(322), layout.w(160), layout.h(20)).build()); + + preloadTextures(); + + if (mod.socket() != null && mod.socket().isConnected()) { + lastStateRequestMs = System.currentTimeMillis(); + mod.socket().sendLongEventStateRequest(); + } + + refreshSnapshot(); + } + + private void refreshSnapshot() { + seenGeneration = mod.longEventGeneration(); + state = mod.knownLongEventState(); + } + + @Override + public void tick() { + super.tick(); + if (seenGeneration != mod.longEventGeneration()) { + refreshSnapshot(); + } + long now = System.currentTimeMillis(); + if (state == null && mod.socket() != null && mod.socket().isConnected()) { + if (now - lastStateRequestMs > 3000L) { + lastStateRequestMs = now; + mod.socket().sendLongEventStateRequest(); + } + } + if (state != null && state.active() && mod.socket() != null) { + for (LongEventState.Slot slot : state.slots()) { + long resetAt = slot.resetAtMs(); + if (resetAt > 0L && now >= resetAt) { + Long prev = requestedResetAtMs.get(slot.slot()); + if (prev == null || prev != resetAt) { + requestedResetAtMs.put(slot.slot(), resetAt); + mod.socket().sendLongEventStateRequest(); + break; + } + } + } + } + } + + private ClickBox rerollBox(int slotIndex) { + if (layout == null || slotIndex < 0 || slotIndex >= CARD_BASE_X.length) { + return null; + } + int cardX = layout.x(CARD_BASE_X[slotIndex]); + int cardY = layout.y(CARD_BASE_Y); + int cardW = layout.w(CARD_BASE_W); + int rerollSize = layout.w(14); + int rx = cardX + cardW - rerollSize - layout.w(4); + int ry = cardY + layout.h(4); + return new ClickBox(rx, ry, rerollSize, rerollSize); + } + + private ClickBox imageLinkBox(int slotIndex) { + if (layout == null || slotIndex < 0 || slotIndex >= CARD_BASE_X.length) { + return null; + } + int cardX = layout.x(CARD_BASE_X[slotIndex]); + int cardY = layout.y(CARD_BASE_Y); + int cardW = layout.w(CARD_BASE_W); + int cardH = layout.h(CARD_BASE_H); + int cardCenterX = cardX + cardW / 2; + int lw = this.font.width("[Image]"); + int lx = cardCenterX - lw / 2; + int ly = cardY + cardH - layout.h(12); + return new ClickBox(lx - 2, ly - 1, lw + 4, 10); + } + + @Override + public void render(GuiGraphics g, int mouseX, int mouseY, float delta) { + int scaledMouseX = scaledMouseX(mouseX); + int scaledMouseY = scaledMouseY(mouseY); + long nowMs = System.currentTimeMillis(); + + LongEventState.Slot hoveredRerollSlot = null; + String hoveredRerollSlotName = null; + String hoveredImageUrl = null; + LongEventState.Slot hoveredNoteSlot = null; + + pushReferencePose(g); + layout.drawBackground(g); + layout.drawPanel(g); + super.render(g, scaledMouseX, scaledMouseY, delta); + + // Header + // A refused reroll or failed request replaces the title for a few seconds; chat is hidden here. + String recentError = mod.recentLongEventError(); + if (recentError.isEmpty()) { + g.drawCenteredString(this.font, "Event", layout.centerX(), layout.y(10), 0xFFFFFFFF); + } else { + drawFittedCenteredString(g, recentError, layout.centerX(), layout.y(10), layout.w(250), 0xFFFF5555); + } + if (state != null && state.active()) { + String pendingText = "Pending: " + state.pendingLe() + " LE"; + g.drawString(this.font, pendingText, layout.x(12), layout.y(10), 0xFFFFD24A); + if (state.endsAtMs() > 0L) { + long rem = state.endsAtMs() - nowMs; + String endsText = rem > 0L ? "Ends in " + LongEventState.formatCountdown(rem, true) : "Ended"; + g.drawString(this.font, endsText, layout.x(BASE_PANEL_WIDTH - 12) - this.font.width(endsText), layout.y(10), 0xFFCCCCCC); + } + } + + if (seenGeneration == 0 || state == null) { + g.drawCenteredString(this.font, "Loading…", layout.centerX(), layout.y(155), 0xFFAAAAAA); + } else if (!state.active()) { + g.drawCenteredString(this.font, "No event is running", layout.centerX(), layout.y(155), 0xFFAAAAAA); + } else { + // Four mission cards: Daily, Special, Repeatable, Repeatable 2 + for (int i = 0; i < SLOT_ORDER.length; i++) { + String slotKey = SLOT_ORDER[i]; + String slotLabel = SLOT_LABELS[i]; + boolean isLockedRep2 = "repeatable2".equals(slotKey) && (state == null || !state.repeatSlot2Active()); + LongEventState.Slot slot = isLockedRep2 ? null : state.findSlot(slotKey); + + int cardX = layout.x(CARD_BASE_X[i]); + int cardY = layout.y(CARD_BASE_Y); + int cardW = layout.w(CARD_BASE_W); + int cardH = layout.h(CARD_BASE_H); + int cardCenterX = cardX + cardW / 2; + + // Slot title + live countdown above card + g.drawCenteredString(this.font, slotLabel, cardCenterX, layout.y(25), isLockedRep2 ? 0xFF888888 : 0xFFFFD24A); + String countdown = isLockedRep2 ? "Locked" : LongEventState.slotCountdownText(slotKey, slot != null ? slot.resetAtMs() : 0L, nowMs); + drawFittedCenteredString(g, countdown, cardCenterX, layout.y(36), cardW - 4, isLockedRep2 ? 0xFF666666 : 0xFFCCCCCC); + + // Card panel + if (isLockedRep2) { + g.fill(cardX, cardY, cardX + cardW, cardY + cardH, 0x33161616); + drawOutline(g, cardX, cardY, cardW, cardH, 0xFF333333); + g.drawCenteredString(this.font, "Locked Slot", cardCenterX, cardY + layout.h(62), 0xFF666666); + g.drawCenteredString(this.font, "Inactive", cardCenterX, cardY + layout.h(74), 0xFF444444); + continue; + } + + boolean completed = slot != null && slot.completed(); + int borderColor = completed ? 0xFF3A8A3A : 0xFF4A4A4A; + g.fill(cardX, cardY, cardX + cardW, cardY + cardH, 0x66161616); + drawOutline(g, cardX, cardY, cardW, cardH, borderColor); + + // Frame + centred tag icon + int frameSize = layout.w(42); + int frameX = cardX + (cardW - frameSize) / 2; + int frameY = cardY + layout.h(4); + LoadedTexture tagIcon = iconForTag(slot != null ? slot.tag() : "Other"); + if (tagIcon != null) { + int iconSize = layout.w(26); + drawTexture(g, tagIcon, frameX + (frameSize - iconSize) / 2, frameY + (frameSize - iconSize) / 2, iconSize, iconSize); + } + LoadedTexture frameTex = frameForSlot(slotKey); + if (frameTex != null) { + drawTexture(g, frameTex, frameX, frameY, frameSize, frameSize); + } + + if (slot == null || slot.missionId() == 0) { + g.drawCenteredString(this.font, "No mission", cardCenterX, cardY + layout.h(72), 0xFF888888); + continue; + } + + // Reroll icon button in top-right corner + ClickBox rBox = rerollBox(i); + int rerollSize = rBox.w(); + int rerollX = rBox.x(); + int rerollY = rBox.y(); + boolean canReroll = slot.canReroll(); + boolean canBuyReroll = slot.canBuyReroll(); + boolean rHover = rBox.contains(scaledMouseX, scaledMouseY); + if (rHover) { + hoveredRerollSlot = slot; + hoveredRerollSlotName = slotKey; + } + boolean shiftHeld = RaidGambitSafety.isShiftKeyDown(); + int rBg; + int rBorder; + int rFg; + if (canReroll) { + rBg = rHover ? 0xFF484848 : 0xFF303030; + rBorder = rHover ? 0xFFFFD24A : 0xFF999999; + rFg = rHover ? 0xFFFFD24A : 0xFFFFFFFF; + } else if (canBuyReroll && shiftHeld && rHover) { + rBg = 0xFF203838; + rBorder = 0xFF55FFFF; + rFg = 0xFF55FFFF; + } else { + rBg = 0xFF1C1C1C; + rBorder = 0xFF404040; + rFg = 0xFF666666; + } + g.fill(rerollX, rerollY, rerollX + rerollSize, rerollY + rerollSize, rBg); + drawOutline(g, rerollX, rerollY, rerollSize, rerollSize, rBorder); + g.drawCenteredString(this.font, "⟳", rerollX + rerollSize / 2, rerollY + (rerollSize - 8) / 2, rFg); + + boolean isOther = "Other".equalsIgnoreCase(slot.tag()); + String displayTitle = slot.title(); + if (isOther && slot.missionId() > 0 && !displayTitle.contains("#" + slot.missionId())) { + displayTitle = displayTitle + " #" + slot.missionId(); + } + int innerW = cardW - layout.w(10); + List titleLines = this.font.split(Component.literal(displayTitle), Math.max(20, innerW)); + int titleY = cardY + layout.h(48); + int maxTitleLines = Math.min(2, titleLines.size()); + for (int lineIdx = 0; lineIdx < maxTitleLines; lineIdx++) { + FormattedCharSequence seq = titleLines.get(lineIdx); + g.drawString(this.font, seq, cardCenterX - this.font.width(seq) / 2, titleY + lineIdx * 9, 0xFFFFFFFF); + } + + // Progress & +N LE row + int statY = cardY + layout.h(69); + int padX = cardX + layout.w(6); + int rightX = cardX + cardW - layout.w(6); + String rewardStr = "+" + slot.rewardLe() + " LE"; + int rewardW = this.font.width(rewardStr); + int maxLeftW = Math.max(10, rightX - padX - rewardW - 4); + if (completed) { + drawFittedString(g, "Completed", padX, statY, maxLeftW, 0xFF55FF55); + } else { + String progStr = slot.progress() + "/" + slot.target(); + drawFittedString(g, progStr, padX, statY, maxLeftW, 0xFFDDDDDD); + } + g.drawString(this.font, rewardStr, rightX - rewardW, statY, 0xFFFFD24A); + + // Progress bar + int barY = cardY + layout.h(80); + int barW = cardW - layout.w(12); + int barH = Math.max(3, layout.h(5)); + float ratio = completed ? 1.0f : Math.clamp(slot.progress() / (float) Math.max(1, slot.target()), 0.0f, 1.0f); + drawProgressBar(g, padX, barY, barW, barH, ratio, completed); + + // Note + optional [Image] hover/click + boolean hasNoteOrImage = !slot.note().isEmpty() || !slot.imageUrl().isEmpty(); + if (hasNoteOrImage) { + int noteTop = cardY + layout.h(89); + boolean hasImage = !slot.imageUrl().isEmpty(); + int maxNoteLines = hasImage ? 3 : 4; + if (!slot.note().isEmpty()) { + List noteLines = wrapWords(slot.note(), Math.max(20, innerW)); + boolean isTruncated = noteLines.size() > maxNoteLines; + int visibleLines = Math.min(maxNoteLines, noteLines.size()); + + for (int nIdx = 0; nIdx < visibleLines; nIdx++) { + int lineY = noteTop + nIdx * 9; + String lineText = noteLines.get(nIdx); + if (isTruncated && nIdx == visibleLines - 1) { + int dotW = this.font.width("..."); + while (!lineText.isEmpty() && this.font.width(lineText) + dotW + 2 > innerW) { + lineText = lineText.substring(0, lineText.length() - 1).trim(); + } + g.drawString(this.font, lineText, padX, lineY, 0xFFBBBBBB); + int dotX = padX + this.font.width(lineText) + 2; + g.drawString(this.font, "...", dotX, lineY, 0xFFFFD24A); + } else { + g.drawString(this.font, lineText, padX, lineY, 0xFFBBBBBB); + } + } + + if (isTruncated) { + int noteH = visibleLines * 9; + if (scaledMouseX >= padX && scaledMouseX <= padX + innerW && scaledMouseY >= noteTop && scaledMouseY <= noteTop + noteH) { + hoveredNoteSlot = slot; + } + } + } + if (hasImage) { + Component imgLabel = Component.literal("[Image]").withStyle(ChatFormatting.AQUA, ChatFormatting.UNDERLINE); + ClickBox imgBox = imageLinkBox(i); + if (imgBox.contains(scaledMouseX, scaledMouseY)) { + hoveredImageUrl = slot.imageUrl(); + } + int lx = cardCenterX - this.font.width(imgLabel) / 2; + int ly = cardY + cardH - layout.h(12); + g.drawString(this.font, imgLabel, lx, ly, 0xFF55FFFF); + } + } + } + + // Master section (up to 3 tracks: Raids, Guild Wars, Missions) + List masters = state.masters(); + if (!masters.isEmpty()) { + g.drawString(this.font, "Event Objectives", layout.x(12), layout.y(200), 0xFFFFD24A); + LoadedTexture masterFrame = getTexture("assets/edenmod/textures/gui/event/frame_master.png"); + for (int idx = 0; idx < Math.min(3, masters.size()); idx++) { + LongEventState.Master master = masters.get(idx); + int rowX = layout.x(12); + int rowY = layout.y(212 + idx * 35); + int rowW = layout.w(456); + int rowH = layout.h(32); + + boolean mDone = master.completed(); + g.fill(rowX, rowY, rowX + rowW, rowY + rowH, 0x66161616); + drawOutline(g, rowX, rowY, rowW, rowH, mDone ? 0xFF3A8A3A : 0xFF4A4A4A); + + int fSize = layout.w(26); + int fx = rowX + layout.w(3); + int fy = rowY + (rowH - fSize) / 2; + LoadedTexture mIcon = iconForMaster(master.kind()); + if (mIcon != null) { + int iSize = layout.w(18); + drawTexture(g, mIcon, fx + (fSize - iSize) / 2, fy + (fSize - iSize) / 2, iSize, iSize); + } + if (masterFrame != null) { + drawTexture(g, masterFrame, fx, fy, fSize, fSize); + } + + int textLeft = rowX + layout.w(34); + int topY = rowY + layout.h(4); + String displayTitle = switch (master.kind().toLowerCase(Locale.ROOT)) { + case "raids" -> "All Raids"; + case "missions" -> "Mission Completions"; + case "wars", "guild_wars" -> "Guild Wars"; + default -> master.title().replace(" Master", ""); + }; + String tierText = "Tier " + master.tier() + "/" + master.tierCount(); + g.drawString(this.font, displayTitle, textLeft, topY, 0xFFFFFFFF); + int afterTitle = textLeft + this.font.width(displayTitle) + 6; + g.drawString(this.font, tierText, afterTitle, topY, 0xFF55FFFF); + + String earnedText = "Earned: " + master.earnedLe() + " LE"; + g.drawString(this.font, earnedText, rowX + rowW - layout.w(6) - this.font.width(earnedText), topY, 0xFFFFD24A); + + int barY = rowY + layout.h(18); + int barW = layout.w(230); + int barH = Math.max(3, layout.h(5)); + float ratio = mDone ? 1.0f : Math.clamp(master.progress() / (float) Math.max(1, master.target()), 0.0f, 1.0f); + drawProgressBar(g, textLeft, barY, barW, barH, ratio, mDone); + + int statusX = textLeft + barW + layout.w(8); + int statusY = rowY + layout.h(16); + if (mDone) { + g.drawString(this.font, "All tiers complete", statusX, statusY, 0xFF55FF55); + } else { + String mProg = master.progress() + "/" + master.target(); + g.drawString(this.font, mProg, statusX, statusY, 0xFFDDDDDD); + g.drawString(this.font, "+" + master.rewardLe() + " LE", statusX + this.font.width(mProg) + 8, statusY, 0xFFFFD24A); + } + } + } + } + + popReferencePose(g); + + // Render hover tooltips / image previews in native screen space + if (hoveredRerollSlotName != null) { + g.setComponentTooltipForNextFrame(this.font, buildRerollTooltip(hoveredRerollSlotName, hoveredRerollSlot, nowMs), mouseX, mouseY); + } else if (hoveredImageUrl != null) { + if (ImagePreviewManager.isPreviewable(hoveredImageUrl)) { + int imgW = ImagePreviewManager.getWidth(hoveredImageUrl); + int imgH = ImagePreviewManager.getHeight(hoveredImageUrl); + int boxW = imgW > 0 ? imgW : 100; + int boxH = imgH > 0 ? imgH : 16; + int rx = (this.width - boxW) / 2; + int ry = (this.height - boxH) / 2; + g.fill(rx - 2, ry - 2, rx + boxW + 2, ry + boxH + 2, 0xCC000000); + ImagePreviewManager.renderPreview(g, hoveredImageUrl, rx, ry); + } else { + g.setComponentTooltipForNextFrame(this.font, List.of(Component.literal("Click to open image").withStyle(ChatFormatting.GRAY)), mouseX, mouseY); + } + } else if (hoveredNoteSlot != null) { + g.setComponentTooltipForNextFrame(this.font, buildNoteTooltip(hoveredNoteSlot), mouseX, mouseY); + } + } + + private List buildNoteTooltip(LongEventState.Slot slot) { + List lines = new ArrayList<>(); + lines.add(Component.literal("Mission Instructions").withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD)); + lines.add(Component.empty()); + for (String wrappedLine : wrapWords(slot.note(), 220)) { + lines.add(Component.literal(wrappedLine).withStyle(ChatFormatting.WHITE)); + } + if (!slot.imageUrl().isEmpty()) { + lines.add(Component.empty()); + lines.add(Component.literal("Image attached (click [Image] below to open)").withStyle(ChatFormatting.DARK_AQUA, ChatFormatting.ITALIC)); + } + return lines; + } + + private List wrapWords(String text, int maxWidth) { + List lines = new ArrayList<>(); + if (text == null || text.isBlank() || maxWidth <= 0) { + return lines; + } + String[] words = text.trim().split("\\s+"); + StringBuilder current = new StringBuilder(); + for (String word : words) { + if (current.length() == 0) { + if (this.font.width(word) > maxWidth) { + for (char ch : word.toCharArray()) { + if (this.font.width(current.toString() + ch) > maxWidth && current.length() > 0) { + lines.add(current.toString()); + current.setLength(0); + } + current.append(ch); + } + } else { + current.append(word); + } + } else { + String candidate = current + " " + word; + if (this.font.width(candidate) <= maxWidth) { + current.append(" ").append(word); + } else { + lines.add(current.toString()); + current.setLength(0); + if (this.font.width(word) > maxWidth) { + for (char ch : word.toCharArray()) { + if (this.font.width(current.toString() + ch) > maxWidth && current.length() > 0) { + lines.add(current.toString()); + current.setLength(0); + } + current.append(ch); + } + } else { + current.append(word); + } + } + } + } + if (current.length() > 0) { + lines.add(current.toString()); + } + return lines; + } + + private List buildRerollTooltip(String slotKey, LongEventState.Slot slot, long nowMs) { + List lines = new ArrayList<>(); + if (slot != null && slot.canReroll()) { + lines.add(Component.literal("Reroll this mission").withStyle(ChatFormatting.GOLD)); + lines.add(Component.literal(LongEventState.rerollRuleText(slotKey)).withStyle(ChatFormatting.GRAY)); + lines.add(Component.literal("The rerolled mission's type can't roll in this slot for the next 2 rolls").withStyle(ChatFormatting.GRAY)); + } else { + lines.add(Component.literal("Reroll on cooldown").withStyle(ChatFormatting.RED)); + lines.add(Component.literal(LongEventState.rerollRuleText(slotKey)).withStyle(ChatFormatting.GRAY)); + lines.add(Component.literal("The rerolled mission's type can't roll in this slot for the next 2 rolls").withStyle(ChatFormatting.GRAY)); + if (slot != null && slot.rerollAtMs() > 0L) { + long rem = Math.max(0L, slot.rerollAtMs() - nowMs); + boolean useDays = rem >= 86_400_000L; + lines.add(Component.literal("Available in " + LongEventState.formatCountdown(rem, useDays)).withStyle(ChatFormatting.RED)); + } + } + + if (slot != null && !slot.completed()) { + int cost = state != null ? state.rerollCost() : 0; + int pts = state != null ? state.triviaPoints() : 0; + if (cost > 0) { + lines.add(Component.empty()); + if (slot.canBuyReroll()) { + lines.add(Component.literal("Shift-Click to buy extra reroll").withStyle(ChatFormatting.AQUA)); + lines.add(Component.literal("Cost: " + cost + " Points (You have " + pts + ")").withStyle(ChatFormatting.DARK_AQUA)); + } else { + lines.add(Component.literal("Shift-Click to buy extra reroll").withStyle(ChatFormatting.DARK_GRAY)); + lines.add(Component.literal("Requires " + cost + " Points (You have " + pts + ")").withStyle(ChatFormatting.RED)); + } + } + } + return lines; + } + + private void drawFittedString(GuiGraphics g, String text, int x, int y, int maxWidth, int color) { + int textW = this.font.width(text); + if (textW <= maxWidth || textW <= 0) { + g.drawString(this.font, text, x, y, color); + return; + } + float s = maxWidth / (float) textW; + g.pose().pushMatrix(); + g.pose().translate(x, y); + g.pose().scale(s, s); + g.drawString(this.font, text, 0, 0, color); + g.pose().popMatrix(); + } + + private void drawFittedCenteredString(GuiGraphics g, String text, int centerX, int y, int maxWidth, int color) { + int textW = this.font.width(text); + if (textW <= maxWidth || textW <= 0) { + g.drawCenteredString(this.font, text, centerX, y, color); + return; + } + float s = maxWidth / (float) textW; + g.pose().pushMatrix(); + g.pose().translate(centerX, y); + g.pose().scale(s, s); + g.drawString(this.font, text, -textW / 2, 0, color); + g.pose().popMatrix(); + } + + private static void drawOutline(GuiGraphics g, int x, int y, int w, int h, int color) { + g.fill(x, y, x + w, y + 1, color); + g.fill(x, y + h - 1, x + w, y + h, color); + g.fill(x, y, x + 1, y + h, color); + g.fill(x + w - 1, y, x + w, y + h, color); + } + + private static void drawProgressBar(GuiGraphics g, int x, int y, int w, int h, float ratio, boolean completed) { + g.fill(x, y, x + w, y + h, 0xFF141414); + int fillW = Math.round((w - 2) * Math.clamp(ratio, 0.0f, 1.0f)); + if (fillW > 0) { + g.fill(x + 1, y + 1, x + 1 + fillW, y + h - 1, completed ? 0xFF55DD55 : 0xFFFFB833); + } + drawOutline(g, x, y, w, h, 0xFF3A3A3A); + } + + private static void drawTexture(GuiGraphics g, LoadedTexture tex, int x, int y, int w, int h) { + if (tex == null || tex.id() == null) { + return; + } + g.pose().pushMatrix(); + g.pose().translate(x, y); + g.pose().scale(w / (float) tex.width(), h / (float) tex.height()); + g.blit(RenderPipelines.GUI_TEXTURED, tex.id(), 0, 0, 0.0f, 0.0f, tex.width(), tex.height(), tex.width(), tex.height()); + g.pose().popMatrix(); + } + + private static LoadedTexture frameForSlot(String slotKey) { + String file = switch (slotKey.toLowerCase(Locale.ROOT)) { + case "daily" -> "frame_daily.png"; + case "special", "weekly" -> "frame_weekly.png"; + default -> "frame_repeatable.png"; + }; + return getTexture("assets/edenmod/textures/gui/event/" + file); + } + + private static LoadedTexture iconForTag(String tag) { + if (tag == null) { + return getTexture("assets/edenmod/textures/gui/event/other.png"); + } + return switch (tag.trim().toUpperCase(Locale.ROOT)) { + case "NOTG" -> getTexture("assets/edenmod/textures/gui/icons/notg.png"); + case "NOL" -> getTexture("assets/edenmod/textures/gui/icons/nol.png"); + case "TCC" -> getTexture("assets/edenmod/textures/gui/icons/tcc.png"); + case "TNA" -> getTexture("assets/edenmod/textures/gui/icons/tna.png"); + case "WTP" -> getTexture("assets/edenmod/textures/gui/icons/wtp.png"); + case "ALL RAIDS" -> getTexture("assets/edenmod/textures/gui/event/all_raids.png"); + case "GUILD WARS" -> getTexture("assets/edenmod/textures/gui/event/guild_wars.png"); + case "TRIVIA" -> getTexture("assets/edenmod/textures/gui/event/trivia.png"); + case "DICEROLL" -> getTexture("assets/edenmod/textures/gui/event/diceroll.png"); + case "COINFLIP" -> getTexture("assets/edenmod/textures/gui/event/coinflip.png"); + default -> getTexture("assets/edenmod/textures/gui/event/other.png"); + }; + } + + private static LoadedTexture iconForMaster(String kind) { + if ("raids".equalsIgnoreCase(kind)) { + return getTexture("assets/edenmod/textures/gui/event/all_raids.png"); + } + if ("wars".equalsIgnoreCase(kind) || "guild_wars".equalsIgnoreCase(kind)) { + return getTexture("assets/edenmod/textures/gui/event/guild_wars.png"); + } + return getTexture("assets/edenmod/icon.png"); + } + + private static final String[] PRELOAD_TEXTURES = {"assets/edenmod/textures/gui/event/frame_daily.png", "assets/edenmod/textures/gui/event/frame_weekly.png", "assets/edenmod/textures/gui/event/frame_repeatable.png", "assets/edenmod/textures/gui/event/frame_master.png", "assets/edenmod/textures/gui/event/all_raids.png", "assets/edenmod/textures/gui/event/guild_wars.png", "assets/edenmod/textures/gui/event/trivia.png", "assets/edenmod/textures/gui/event/diceroll.png", "assets/edenmod/textures/gui/event/coinflip.png", "assets/edenmod/textures/gui/event/other.png", "assets/edenmod/textures/gui/icons/notg.png", "assets/edenmod/textures/gui/icons/nol.png", "assets/edenmod/textures/gui/icons/tcc.png", "assets/edenmod/textures/gui/icons/tna.png", "assets/edenmod/textures/gui/icons/wtp.png", "assets/edenmod/icon.png"}; + + public static void preloadTextures() { + for (String path : PRELOAD_TEXTURES) { + getTexture(path); + } + } + + private static LoadedTexture getTexture(String resourcePath) { + LoadedTexture cached = TEXTURE_CACHE.get(resourcePath); + if (cached != null) { + if (cached == LoadedTexture.EMPTY) { + return null; + } + var tm = Minecraft.getInstance().getTextureManager(); + var tex = tm.getTexture(cached.id()); + if (tex != null && tex.getTexture() != null) { + return cached; + } + TEXTURE_CACHE.remove(resourcePath); + } + try (InputStream stream = LongEventScreen.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (stream != null) { + NativeImage img = NativeImage.read(stream); + int w = img.getWidth(); + int h = img.getHeight(); + String slug = resourcePath.replace("assets/edenmod/", "").replace('/', '_').replace('.', '_'); + Identifier loc = Identifier.parse("edenmod:event_tex/" + slug); + Minecraft.getInstance().getTextureManager().register(loc, new DynamicTexture(() -> slug, img)); + LoadedTexture loaded = new LoadedTexture(loc, w, h); + TEXTURE_CACHE.put(resourcePath, loaded); + return loaded; + } + } catch (Exception ignored) { + } + TEXTURE_CACHE.put(resourcePath, LoadedTexture.EMPTY); + return null; + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean bl) { + MouseButtonEvent scaled = rescale(event); + if (scaled.button() == 0 && state != null && state.active()) { + double mx = scaled.x(); + double my = scaled.y(); + for (int i = 0; i < SLOT_ORDER.length; i++) { + String slotKey = SLOT_ORDER[i]; + if ("repeatable2".equals(slotKey) && (state == null || !state.repeatSlot2Active())) { + continue; + } + LongEventState.Slot slot = state.findSlot(slotKey); + if (slot == null || slot.missionId() == 0) { + continue; + } + ClickBox rBox = rerollBox(i); + if (rBox != null && rBox.contains(mx, my)) { + if (mod.socket() != null) { + boolean shift = RaidGambitSafety.isShiftKeyDown(); + if (slot.canReroll()) { + this.minecraft.getSoundManager().play(SimpleSoundInstance.forUI(CLICK_SOUND, 1.0f)); + mod.socket().sendLongEventReroll(slotKey, false); + return true; + } else if (shift && slot.canBuyReroll()) { + this.minecraft.getSoundManager().play(SimpleSoundInstance.forUI(CLICK_SOUND, 1.0f)); + mod.socket().sendLongEventReroll(slotKey, true); + return true; + } + } + return true; + } + if (!slot.imageUrl().isEmpty()) { + ClickBox imgBox = imageLinkBox(i); + if (imgBox != null && imgBox.contains(mx, my)) { + openUrlSafely(slot.imageUrl()); + return true; + } + } + } + } + return super.mouseClicked(scaled, bl); + } + + private void openUrlSafely(String url) { + if (url == null || url.isBlank()) { + return; + } + try { + URI uri = URI.create(url.trim()); + String scheme = uri.getScheme(); + if (scheme != null && (scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http"))) { + ConfirmLinkScreen.confirmLinkNow(this, url.trim(), false); + } + } catch (Exception ignored) { + } + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + return super.mouseReleased(rescale(event)); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double d, double e) { + return super.mouseDragged(rescale(event), d / uiScale, e / uiScale); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double d, double e) { + return super.mouseScrolled(mouseX / uiScale, mouseY / uiScale, d, e); + } + + @Override + public void onClose() { + this.minecraft.setScreen(parent); + } +} diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index 18ea67c..7d89840 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -39,7 +39,7 @@ public final class BridgeWebSocketClient { * it (the last one, "guildLogSync", was retired once the server-side code * that checked for it was removed). */ - private static final java.util.Set SUPPORTED_FEATURES = java.util.Set.of("mail"); + private static final java.util.Set SUPPORTED_FEATURES = java.util.Set.of("mail", "longEvent"); /** Callbacks for inbound bridge events (delivered off the game thread). */ public interface MessageSink { @@ -132,9 +132,10 @@ default void onRewardOptOutReply(String rewardKind, boolean blocked, String erro * announcements): a pill labelled {@code label}, then {@code content}. * {@code colorHex} is an optional {@code "RRGGBB"} override; empty means the * default gold. {@code bold} makes a message that needs to stand out further - * than color alone (e.g. the storage-nearly-full warning). + * than color alone (e.g. the storage-nearly-full warning). {@code own} is the + * bot's {@code self} flag: the line is about this player (their own event win). */ - void onPillMessage(String label, String content, String colorHex, boolean bold); + void onPillMessage(String label, String content, String colorHex, boolean bold, boolean own); /** * Offline mail the bot held for this player ({@code mailDelivery}), sent when the @@ -190,6 +191,14 @@ default void onTccGolemDefeated(String sender, String golemId) { default void onTccExitReached(String sender, String path, double x, double z) { } + /** Long-event mission state snapshot (or error notice). */ + default void onLongEventState(LongEventState state, boolean hasSnapshot) { + } + + /** Response to a {@code longEventRewardsRequest}. */ + default void onLongEventRewards(int pendingLe, String error) { + } + /** * The Mojang session was verified ({@code authOk}); {@code linked} and * {@code member} report this player's backend standing. Both true means full @@ -967,6 +976,36 @@ public void sendAllyActivityRequest() { enqueueSend(current, obj.toString()); } + /** Ask the backend for this player's current long-event mission state. */ + public void sendLongEventStateRequest() { + sendType("longEventStateRequest"); + } + + /** Ask the backend to reroll a long-event mission slot ({@code "daily"}, {@code "weekly"}, or {@code "repeatable"}). */ + public void sendLongEventReroll(String slot) { + sendLongEventReroll(slot, false); + } + + /** Ask the backend to reroll a long-event mission slot, optionally purchasing with trivia points. */ + public void sendLongEventReroll(String slot, boolean buy) { + WebSocket current = socket; + if (current == null || slot == null || slot.isEmpty()) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "longEventReroll"); + obj.addProperty("slot", slot); + if (buy) { + obj.addProperty("buy", true); + } + enqueueSend(current, obj.toString()); + } + + /** Ask the backend for this player's pending event LE. */ + public void sendLongEventRewardsRequest() { + sendType("longEventRewardsRequest"); + } + /** * Tell the server whether this client is active in a game world ({@code true}) * or dormant (in queue, hub, or AFK with no recent guild activity — {@code false}). @@ -1356,7 +1395,7 @@ private void handlePayload(String payload) { case "partyListReply" -> sink.onPartyList(parsePartyList(obj), get(obj, "color")); case "partyFeedback" -> sink.onPartyFeedback(get(obj, "message"), get(obj, "color")); case "gameFeedback" -> sink.onGameFeedback(get(obj, "message"), get(obj, "color")); - case "pillMessage" -> sink.onPillMessage(get(obj, "label"), get(obj, "content"), get(obj, "color"), getBool(obj, "bold")); + case "pillMessage" -> sink.onPillMessage(get(obj, "label"), get(obj, "content"), get(obj, "color"), getBool(obj, "bold"), getBool(obj, "self")); case "mailDelivery" -> sink.onMailDelivery(parseMail(obj)); case "warCountsReply" -> sink.onWarCounts(getInt(obj, "days", 7), parseWarCounts(obj), get(obj, "requester"), get(obj, "color")); case "allyActivityReply" -> sink.onAllyActivity(parseAllyActivity(obj)); @@ -1367,6 +1406,11 @@ private void handlePayload(String payload) { case "tccMapPos" -> sink.onTccMapPos(get(obj, "sender"), get(obj, "senderUuid"), getDouble(obj, "x", 0.0), getDouble(obj, "y", 0.0), getDouble(obj, "z", 0.0), getFloat(obj, "yaw", 0.0f), getBool(obj, "isWings")); case "tccGolemDefeated" -> sink.onTccGolemDefeated(get(obj, "sender"), get(obj, "golemId")); case "tccExitReached" -> sink.onTccExitReached(get(obj, "sender"), get(obj, "path"), getDouble(obj, "x", 0.0), getDouble(obj, "z", 0.0)); + case "longEventState" -> { + LongEventState state = parseLongEventState(obj); + sink.onLongEventState(state, state.isSnapshot()); + } + case "longEventRewards" -> sink.onLongEventRewards(getInt(obj, "pendingLe", 0), get(obj, "error")); case "authChallenge" -> handleAuthChallenge(get(obj, "serverId")); case "authOk" -> { authenticated = true; @@ -1629,6 +1673,28 @@ private static java.util.List parseGiveawayCandidates(JsonObj return out; } + static LongEventState parseLongEventState(JsonObject obj) { + java.util.List slots = new java.util.ArrayList<>(); + if (obj.has("slots") && obj.get("slots").isJsonArray()) { + for (var element : obj.get("slots").getAsJsonArray()) { + if (element.isJsonObject()) { + JsonObject s = element.getAsJsonObject(); + slots.add(new LongEventState.Slot(get(s, "slot"), getLong(s, "resetAtMs", 0L), getInt(s, "missionId", 0), get(s, "tag"), get(s, "title"), getInt(s, "progress", 0), getInt(s, "target", 0), getInt(s, "rewardLe", 0), getBool(s, "completed"), getBool(s, "canReroll"), getBool(s, "canBuyReroll"), getLong(s, "rerollAtMs", 0L), get(s, "note"), get(s, "imageUrl"))); + } + } + } + java.util.List masters = new java.util.ArrayList<>(); + if (obj.has("masters") && obj.get("masters").isJsonArray()) { + for (var element : obj.get("masters").getAsJsonArray()) { + if (element.isJsonObject()) { + JsonObject m = element.getAsJsonObject(); + masters.add(new LongEventState.Master(get(m, "kind"), get(m, "title"), getInt(m, "tier", 1), getInt(m, "tierCount", 1), getInt(m, "progress", 0), getInt(m, "target", 0), getInt(m, "rewardLe", 0), getInt(m, "earnedLe", 0), getBool(m, "completed"))); + } + } + } + return new LongEventState(getBool(obj, "active"), getLong(obj, "endsAtMs", 0L), getInt(obj, "pendingLe", 0), getInt(obj, "rerollCost", 0), getInt(obj, "triviaPoints", 0), getBool(obj, "repeatSlot2Active"), get(obj, "error"), slots, masters); + } + private static java.util.List getStringArray(JsonObject obj, String key) { java.util.List out = new java.util.ArrayList<>(); if (obj.has(key) && obj.get(key).isJsonArray()) { diff --git a/src/tel/eden/mod/net/LongEventState.java b/src/tel/eden/mod/net/LongEventState.java new file mode 100644 index 0000000..a481547 --- /dev/null +++ b/src/tel/eden/mod/net/LongEventState.java @@ -0,0 +1,115 @@ +package tel.eden.mod.net; + +import java.util.List; +import java.util.Locale; + +/** + * Snapshot of the active long-event mission state for this player, received in a + * {@code longEventState} message from the bridge backend. + */ +public record LongEventState(boolean active, long endsAtMs, int pendingLe, int rerollCost, int triviaPoints, boolean repeatSlot2Active, String error, List slots, List masters) { + public LongEventState(boolean active, long endsAtMs, int pendingLe, int rerollCost, int triviaPoints, String error, List slots, List masters) { + this(active, endsAtMs, pendingLe, rerollCost, triviaPoints, false, error, slots, masters); + } + public LongEventState(boolean active, long endsAtMs, int pendingLe, String error, List slots, List masters) { + this(active, endsAtMs, pendingLe, 0, 0, false, error, slots, masters); + } + public LongEventState { + error = error == null ? "" : error; + slots = slots == null ? List.of() : List.copyOf(slots); + masters = masters == null ? List.of() : List.copyOf(masters); + } + + /** One mission slot ({@code "daily"}, {@code "weekly"}, or {@code "repeatable"}). */ + public record Slot(String slot, long resetAtMs, int missionId, String tag, String title, int progress, int target, int rewardLe, boolean completed, boolean canReroll, boolean canBuyReroll, long rerollAtMs, String note, String imageUrl) { + public Slot(String slot, long resetAtMs, int missionId, String tag, String title, int progress, int target, int rewardLe, boolean completed, boolean canReroll, long rerollAtMs, String note, String imageUrl) { + this(slot, resetAtMs, missionId, tag, title, progress, target, rewardLe, completed, canReroll, false, rerollAtMs, note, imageUrl); + } + public Slot { + slot = slot == null ? "" : slot; + tag = tag == null ? "" : tag; + title = title == null ? "" : title; + note = note == null ? "" : note; + imageUrl = imageUrl == null ? "" : imageUrl; + } + } + + /** One master mission track ({@code "raids"} or {@code "missions"}). */ + public record Master(String kind, String title, int tier, int tierCount, int progress, int target, int rewardLe, int earnedLe, boolean completed) { + public Master { + kind = kind == null ? "" : kind; + title = title == null ? "" : title; + } + } + + /** + * Whether this reply carries the player's real state. The bot's error fallback + * ({@code active=false}, no slots, an error) doesn't, so it must not replace a good snapshot. + */ + public boolean isSnapshot() { + return error.isEmpty() || !slots.isEmpty(); + } + + /** Find a slot by its identifier ({@code "daily"}, {@code "special"}, {@code "repeatable"}, {@code "repeatable2"}). */ + public Slot findSlot(String slotName) { + for (Slot s : slots) { + if (s.slot().equalsIgnoreCase(slotName)) { + return s; + } + } + if ("special".equalsIgnoreCase(slotName)) { + for (Slot s : slots) { + if ("weekly".equalsIgnoreCase(s.slot())) { + return s; + } + } + } else if ("weekly".equalsIgnoreCase(slotName)) { + for (Slot s : slots) { + if ("special".equalsIgnoreCase(s.slot())) { + return s; + } + } + } + return null; + } + + /** + * Format a remaining millisecond duration as {@code HH:MM:SS} or {@code Xd HH:MM:SS}. + * Non-positive durations clamp to zero. + */ + public static String formatCountdown(long remainingMs, boolean includeDays) { + long totalSeconds = Math.max(0L, remainingMs / 1000L); + if (includeDays) { + long days = totalSeconds / 86_400L; + long hours = (totalSeconds % 86_400L) / 3_600L; + long minutes = (totalSeconds % 3_600L) / 60L; + long seconds = totalSeconds % 60L; + return String.format(Locale.ROOT, "%dd %02d:%02d:%02d", days, hours, minutes, seconds); + } + long hours = totalSeconds / 3_600L; + long minutes = (totalSeconds % 3_600L) / 60L; + long seconds = totalSeconds % 60L; + return String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds); + } + + /** Live subtitle text shown above a slot card. */ + public static String slotCountdownText(String slotName, long resetAtMs, long nowMs) { + if ("repeatable".equalsIgnoreCase(slotName) || "repeatable2".equalsIgnoreCase(slotName)) { + return "Complete to unlock the next mission"; + } + boolean specialOrWeekly = "special".equalsIgnoreCase(slotName) || "weekly".equalsIgnoreCase(slotName); + long remaining = resetAtMs > 0L ? Math.max(0L, resetAtMs - nowMs) : 0L; + return "Resets in " + formatCountdown(remaining, specialOrWeekly); + } + + /** Cooldown rule line shown in the reroll tooltip for the given slot. */ + public static String rerollRuleText(String slotName) { + if ("daily".equalsIgnoreCase(slotName)) { + return "Daily: once per day"; + } + if ("special".equalsIgnoreCase(slotName) || "weekly".equalsIgnoreCase(slotName)) { + return "Special: once every 3 days"; + } + return "Repeatable: every 3 hours"; + } +} diff --git a/test/tel/eden/mod/chat/EventAnnouncementFormattingTest.java b/test/tel/eden/mod/chat/EventAnnouncementFormattingTest.java new file mode 100644 index 0000000..9b1a76c --- /dev/null +++ b/test/tel/eden/mod/chat/EventAnnouncementFormattingTest.java @@ -0,0 +1,27 @@ +package tel.eden.mod.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import tel.eden.mod.EdenModClient; + +class EventAnnouncementFormattingTest { + + @Test + void testOwnEventAnnouncementFormatting() { + assertEquals("🔁 You've completed Repeatable Mission (Complete any single raid 3 times) — +3 LE!", EdenModClient.formatOwnEventAnnouncement("🔁 Good627 completed Repeatable Mission (Complete any single raid 3 times) — +3 LE!")); + assertEquals("⭐ You've completed Daily Mission (NOL 1) — +8 LE!", EdenModClient.formatOwnEventAnnouncement("⭐ good627 completed Daily Mission (NOL 1) — +8 LE!")); + assertEquals("🎯 You've completed Special Mission (WTP 2) — +16 LE!", EdenModClient.formatOwnEventAnnouncement("🎯 Good627 completed Special Mission (WTP 2) — +16 LE!")); + assertEquals("🏆 You've unlocked All Raids Objective Tier 1 (10 raids)— +8 LE!", EdenModClient.formatOwnEventAnnouncement("🏆 Good627 unlocked All Raids Objective Tier 1 (10 raids)— +8 LE!")); + } + + @Test + void ownLineIsRewordedEvenWhenTheStoredNameIsStale() { + // The bot's name can lag an IGN change; the self flag still marks the line, and the + // rewording doesn't need the name to match. + assertEquals("🎯 You've completed Special Mission (WTP 2) — +16 LE!", EdenModClient.formatOwnEventAnnouncement("🎯 OldName completed Special Mission (WTP 2) — +16 LE!")); + assertEquals("not an event line", EdenModClient.formatOwnEventAnnouncement("not an event line")); + assertNull(EdenModClient.formatOwnEventAnnouncement(null)); + } +} diff --git a/test/tel/eden/mod/net/LongEventStateTest.java b/test/tel/eden/mod/net/LongEventStateTest.java new file mode 100644 index 0000000..7cb7f3d --- /dev/null +++ b/test/tel/eden/mod/net/LongEventStateTest.java @@ -0,0 +1,91 @@ +package tel.eden.mod.net; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class LongEventStateTest { + @Test + void formatCountdownFormatsCorrectly() { + // Non-positive clamps to 00:00:00 + assertEquals("00:00:00", LongEventState.formatCountdown(-500L, false)); + assertEquals("0d 00:00:00", LongEventState.formatCountdown(0L, true)); + + // 1 hour, 2 minutes, 3 seconds = 3723 seconds = 3,723,000 ms + long ms = (3600 + 120 + 3) * 1000L; + assertEquals("01:02:03", LongEventState.formatCountdown(ms, false)); + + // 2 days, 3 hours, 4 minutes, 5 seconds + long daysMs = (2 * 86400 + 3 * 3600 + 4 * 60 + 5) * 1000L; + assertEquals("2d 03:04:05", LongEventState.formatCountdown(daysMs, true)); + } + + @Test + void slotCountdownTextMatchesSlotRules() { + assertEquals("Complete to unlock the next mission", LongEventState.slotCountdownText("repeatable", 12345L, 0L)); + assertEquals("Complete to unlock the next mission", LongEventState.slotCountdownText("repeatable2", 12345L, 0L)); + + long now = 1000000L; + long resetAt = now + 3600 * 1000L; + assertEquals("Resets in 01:00:00", LongEventState.slotCountdownText("daily", resetAt, now)); + + long specialReset = now + 86400 * 1000L * 2 + 1800 * 1000L; + assertEquals("Resets in 2d 00:30:00", LongEventState.slotCountdownText("special", specialReset, now)); + assertEquals("Resets in 2d 00:30:00", LongEventState.slotCountdownText("weekly", specialReset, now)); + } + + @Test + void slotLookupAndRerollRules() { + assertEquals("Daily: once per day", LongEventState.rerollRuleText("daily")); + assertEquals("Special: once every 3 days", LongEventState.rerollRuleText("special")); + assertEquals("Special: once every 3 days", LongEventState.rerollRuleText("weekly")); + assertEquals("Repeatable: every 3 hours", LongEventState.rerollRuleText("repeatable")); + assertEquals("Repeatable: every 3 hours", LongEventState.rerollRuleText("repeatable2")); + + LongEventState.Slot daily = new LongEventState.Slot("daily", 100L, 1, "WTP", "WTP 1", 0, 3, 10, false, true, 0L, "", ""); + LongEventState.Slot special = new LongEventState.Slot("special", 200L, 2, "NOTG", "NOTG 1", 0, 3, 15, false, true, 0L, "", ""); + LongEventState state = new LongEventState(true, 1000L, 0, 0, 0, true, "", List.of(daily, special), List.of()); + + assertTrue(state.repeatSlot2Active()); + assertNotNull(state.findSlot("daily")); + assertEquals("WTP", state.findSlot("daily").tag()); + assertNotNull(state.findSlot("special")); + assertEquals("NOTG", state.findSlot("special").tag()); + assertNotNull(state.findSlot("weekly")); // fallback alias + assertEquals("NOTG", state.findSlot("weekly").tag()); + assertNull(state.findSlot("repeatable")); + } + + @Test + void errorFallbackIsNotASnapshot() { + LongEventState.Slot daily = new LongEventState.Slot("daily", 100L, 1, "WTP", "WTP 1", 0, 3, 10, false, true, 0L, "", ""); + // The bot's fallback after an internal error: inactive, no slots, an error. + assertFalse(new LongEventState(false, 0L, 0, "Something went wrong - try again.", List.of(), List.of()).isSnapshot()); + // A refused reroll still carries the real slots, and a quiet "no event" reply has no error. + assertTrue(new LongEventState(true, 1000L, 0, "Daily reroll already used today.", List.of(daily), List.of()).isSnapshot()); + assertTrue(new LongEventState(false, 0L, 0, "", List.of(), List.of()).isSnapshot()); + } + + @Test + void parsedFallbackKeepsTheLastGoodState() { + com.google.gson.JsonObject fallback = com.google.gson.JsonParser.parseString("{\"type\":\"longEventState\",\"active\":false,\"endsAtMs\":0,\"pendingLe\":0,\"error\":\"Something went wrong - try again.\",\"slots\":[],\"masters\":[]}").getAsJsonObject(); + assertFalse(BridgeWebSocketClient.parseLongEventState(fallback).isSnapshot()); + } + + @Test + void slotPurchasedRerollState() { + LongEventState.Slot daily = new LongEventState.Slot("daily", 100L, 1, "WTP", "WTP 1", 0, 3, 10, false, false, true, 5000L, "", ""); + LongEventState state = new LongEventState(true, 1000L, 0, 5, 8, "", List.of(daily), List.of()); + + assertEquals(5, state.rerollCost()); + assertEquals(8, state.triviaPoints()); + assertFalse(daily.canReroll()); + assertTrue(daily.canBuyReroll()); + assertEquals(5000L, daily.rerollAtMs()); + } +}