Skip to content
Binary file modified resources/assets/edenmod/textures/catplush/cartiye.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepanger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepbleh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepblush.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepconfused.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepcross.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepdizzy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepnerd.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepsilent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepsleep.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepsmile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepsob.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepsorry.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepstarstruck.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepsurprised.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepunamused.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepwink.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/assets/edenmod/textures/catplush/meepworried.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 28 additions & 9 deletions src/tel/eden/mod/EdenModClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ public final class EdenModClient implements ClientModInitializer {
t.setDaemon(true);
return t;
});
// Invalidates delayed party commands when the client changes server/world, so invites
// queued for one session can never be delivered to the next connection.
private final java.util.concurrent.atomic.AtomicInteger partyCommandGeneration = new java.util.concurrent.atomic.AtomicInteger();
// 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).
Expand Down Expand Up @@ -198,8 +201,9 @@ public String liveDiscordRank() {

private boolean onWynncraft;
// Mutated on the inbound-message path and read from GUI screens on the render
// thread; copy-on-write keeps those cross-thread reads from racing the writes.
private final java.util.List<PartyInfo> knownParties = new java.util.concurrent.CopyOnWriteArrayList<>();
// thread. Each change publishes one immutable snapshot through this volatile field,
// so readers cannot observe the remove/add or clear/addAll halves of an update.
private volatile java.util.List<PartyInfo> knownParties = java.util.List.of();
// Latest aspects-owed list from the backend, read by AspectsPayoutScreen on the
// render thread. Each reply replaces the whole list, so publishing an immutable
// one through a volatile field keeps readers from ever seeing a half-applied
Expand Down Expand Up @@ -331,7 +335,20 @@ public void openCenteredEmotePicker() {

/** An immutable snapshot of the currently known parties, safe to read off-thread. */
public java.util.List<PartyInfo> knownParties() {
return java.util.List.copyOf(knownParties);
return knownParties;
}

private synchronized void updateKnownParty(String event, PartyInfo party) {
java.util.List<PartyInfo> updated = new java.util.ArrayList<>(knownParties);
updated.removeIf(existing -> existing.id() == party.id());
if (!event.equals("closed")) {
updated.add(party);
}
knownParties = java.util.List.copyOf(updated);
}

private synchronized void replaceKnownParties(java.util.List<PartyInfo> parties) {
knownParties = java.util.List.copyOf(parties);
}

/** Members owed aspects, highest first, as of the last backend reply. */
Expand Down Expand Up @@ -426,6 +443,7 @@ public void onInitializeClient() {
});

ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
partyCommandGeneration.incrementAndGet();
loginPending = true;
// Fresh connection: drop the packet-captured scoreboard and all war-board state
// so a previous world/session's timers, defence, and heads never linger.
Expand Down Expand Up @@ -731,10 +749,7 @@ public void onRewardDeductReply(String target, String rewardKind, int amount, in

@Override
public void onPartyUpdate(String event, String actor, PartyInfo party, String color) {
knownParties.removeIf(p -> p.id() == party.id());
if (!event.equals("closed")) {
knownParties.add(party);
}
updateKnownParty(event, party);
// Auto-announce party activity only when the player has the
// (default-on) party feed enabled.
if (!config.partyAnnounce) {
Expand All @@ -752,8 +767,7 @@ public void onPartyUpdate(String event, String actor, PartyInfo party, String co

@Override
public void onPartyList(java.util.List<PartyInfo> parties, String color) {
knownParties.clear();
knownParties.addAll(parties);
replaceKnownParties(parties);
displayColored(color, () -> PartyFormatter.listing(parties));
}

Expand Down Expand Up @@ -1271,7 +1285,11 @@ public void createInGameParty(List<String> invites) {

/** Send one server command after {@code delayMs}, on the client thread. */
private void sendServerCommandLater(String command, long delayMs) {
int generation = partyCommandGeneration.get();
partyCommandExecutor.schedule(() -> Minecraft.getInstance().execute(() -> {
if (partyCommandGeneration.get() != generation) {
return;
}
var connection = Minecraft.getInstance().getConnection();
if (connection != null) {
connection.sendCommand(command);
Expand Down Expand Up @@ -1938,6 +1956,7 @@ private void onBridgeConnected() {
}

private synchronized void disconnect() {
partyCommandGeneration.incrementAndGet();
onWynncraft = false;
loginPending = false;
bridgeStatus = BridgeStatus.UNKNOWN;
Expand Down
11 changes: 10 additions & 1 deletion src/tel/eden/mod/chat/ChatText.java
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ private record MetaChar(char value, String hover, String insertion) {
* shapes are cleanly matchable.
*/
static String normalize(String raw) {
return normalizeWhitespace(raw).replaceAll("\\s+([,.:;!?])", "$1");
}

/**
* Strip private-use glyph spam and control characters and collapse whitespace
* 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) {
if (raw == null || raw.isBlank()) {
return "";
}
Expand All @@ -213,7 +222,7 @@ static String normalize(String raw) {
out.appendCodePoint(codePoint);
previousWasSpace = false;
}
return out.toString().trim().replaceAll("\\s+([,.:;!?])", "$1").replaceAll(" {2,}", " ");
return out.toString().trim();
}

/** Whether the code point is a control/format/private-use/surrogate/unassigned char. */
Expand Down
2 changes: 1 addition & 1 deletion src/tel/eden/mod/chat/DiscordChatFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ public static Component pill(String label, String content, Integer colorRgb) {
/** The "replyTo (excerpt)" segment: name in green, inline quote gray, full quote on hover. */
private static MutableComponent replyTarget(String replyTo, String replyExcerpt) {
MutableComponent segment = Component.literal(replyTo).withStyle(ChatFormatting.GREEN);
String quote = replyExcerpt == null ? "" : ChatText.normalize(replyExcerpt);
String quote = replyExcerpt == null ? "" : ChatText.normalizeWhitespace(replyExcerpt);
if (!quote.isEmpty()) {
String shown = quote.length() > EXCERPT_MAX ? quote.substring(0, EXCERPT_MAX).strip() + "…" : quote;
Style hover = Style.EMPTY.withColor(ChatFormatting.DARK_GRAY).withHoverEvent(new HoverEvent.ShowText(Component.literal(quote)));
Expand Down
7 changes: 3 additions & 4 deletions src/tel/eden/mod/chat/EmoteRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.network.chat.FontDescription;
import net.minecraft.resources.Identifier;
import tel.eden.mod.EdenLogger;
Expand All @@ -33,7 +33,6 @@ public final class EmoteRegistry {

private static volatile Map<String, Integer> emotes;
private static volatile FontDescription font;
private static volatile boolean failedToLoad;

private EmoteRegistry() {
}
Expand Down Expand Up @@ -70,7 +69,7 @@ private static void ensureLoaded() {
}

private static void load() {
Map<String, Integer> loaded = new ConcurrentHashMap<>();
Map<String, Integer> loaded = new HashMap<>();
String fontId = DEFAULT_FONT_ID;
try (InputStream in = EmoteRegistry.class.getResourceAsStream(MANIFEST_PATH)) {
if (in == null) {
Expand Down Expand Up @@ -101,4 +100,4 @@ private static void load() {
font = new FontDescription.Resource(Identifier.parse(fontId));
emotes = Collections.unmodifiableMap(loaded);
}
}
}
5 changes: 4 additions & 1 deletion src/tel/eden/mod/chat/GuildChatParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ public static Optional<CapturedMessage> parseSender(Component message) {
return Optional.empty();
}
List<Segment> segments = collect(message);
String cleaned = ChatText.normalize(concat(segments));
// Preserve punctuation spacing in the user-authored body. The stricter shared
// normalizer removes spaces before ':'/'!' for machine-parsed announcements,
// which would turn "hello :emote:" into "hello:emote:" in bridge payloads.
String cleaned = ChatText.normalizeWhitespace(concat(segments));
Matcher matcher = CHAT_PATTERN.matcher(cleaned);
if (!matcher.find()) {
return Optional.empty();
Expand Down
19 changes: 16 additions & 3 deletions src/tel/eden/mod/chat/OccurrenceSequencer.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

/**
Expand Down Expand Up @@ -34,10 +35,8 @@ public OccurrenceSequencer(long windowMillis) {
/** Record an occurrence of {@code signature} and return its index in the window. */
public synchronized int next(String signature) {
long now = System.currentTimeMillis();
purgeExpired(now);
ArrayDeque<Long> times = seen.computeIfAbsent(signature, k -> new ArrayDeque<>());
while (!times.isEmpty() && now - times.peekFirst() > windowMillis) {
times.pollFirst();
}
// A near-instant repeat is the same line emitted twice; reuse its index so the
// backend dedups it instead of treating it as a second deposit.
if (!times.isEmpty() && now - times.peekLast() < REEMIT_GUARD_MS) {
Expand All @@ -46,4 +45,18 @@ public synchronized int next(String signature) {
times.addLast(now);
return times.size();
}

/** Drop expired occurrences and their signatures so unique chat lines cannot accumulate forever. */
private void purgeExpired(long now) {
Iterator<ArrayDeque<Long>> iterator = seen.values().iterator();
while (iterator.hasNext()) {
ArrayDeque<Long> times = iterator.next();
while (!times.isEmpty() && now - times.peekFirst() > windowMillis) {
times.pollFirst();
}
if (times.isEmpty()) {
iterator.remove();
}
}
}
}
3 changes: 3 additions & 0 deletions src/tel/eden/mod/chat/RaidCompletion.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@
* from {@code "+633m Guild Experience"}); kept verbatim for display only
*/
public record RaidCompletion(List<String> party, String raidName, int aspects, int emeralds, String guildExp) {
public RaidCompletion {
party = List.copyOf(party);
}
}
7 changes: 4 additions & 3 deletions src/tel/eden/mod/chat/ShoutParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* </pre>
*
* <p>The rank badge between the name and {@code shouts:} is a
* custom-font glyph stripped by {@link ChatText#normalize}; the name is hover-resolved.
* custom-font glyph stripped by {@link ChatText#normalizeWhitespace}; the name is
* hover-resolved.
*/
public final class ShoutParser {
// The name group excludes ':' so a copied shout pasted into another channel — which
Expand Down Expand Up @@ -43,7 +44,7 @@ public static Optional<String> parse(Component message) {
if (!isCandidate(message)) {
return Optional.empty();
}
Matcher matcher = SHOUT.matcher(ChatText.normalize(message.getString()));
Matcher matcher = SHOUT.matcher(ChatText.normalizeWhitespace(message.getString()));
if (!matcher.matches()) {
return Optional.empty();
}
Expand All @@ -59,7 +60,7 @@ public static Optional<String> shouterRealName(Component message) {
if (!isCandidate(message)) {
return Optional.empty();
}
Matcher matcher = SHOUT.matcher(ChatText.normalize(message.getString()));
Matcher matcher = SHOUT.matcher(ChatText.normalizeWhitespace(message.getString()));
if (!matcher.matches()) {
return Optional.empty();
}
Expand Down
5 changes: 5 additions & 0 deletions src/tel/eden/mod/gui/PartyListScreen.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ private void requestPartyList() {
private void refreshPartySnapshot() {
lastKnownParties.clear();
lastKnownParties.addAll(mod.knownParties());
lastKnownParties.sort(java.util.Comparator.comparing(PartyListScreen::isFull));
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, lastKnownParties.size() - VISIBLE_ROWS)));
}

private static boolean isFull(PartyInfo party) {
return party.size() >= party.max();
}

@Override
public void tick() {
super.tick();
Expand Down
5 changes: 5 additions & 0 deletions src/tel/eden/mod/item/CustomItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
* under, and no {@code edenmod:} model has to exist for it.
*/
public record CustomItem(String type, List<String> names, List<String> lores, String texture, boolean labelOnly) {
public CustomItem {
names = List.copyOf(names);
lores = List.copyOf(lores);
}

/** A rule that swaps the item's model to {@code edenmod:<texture>} and labels it. */
public CustomItem(String type, List<String> names, List<String> lores, String texture) {
this(type, names, lores, texture, false);
Expand Down
3 changes: 3 additions & 0 deletions src/tel/eden/mod/item/DecodedItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
* @param powderSlots the number of powder slots on the item
*/
public record DecodedItem(String name, String tier, int tierColor, String type, float overallPercent, List<Identification> identifications, int powderSlots) {
public DecodedItem {
identifications = List.copyOf(identifications);
}

/** Whether an overall roll quality is present (gear with variable stats). */
public boolean hasOverall() {
Expand Down
Loading
Loading